Files
claude-code/src/components/Markdown.tsx
claude-code-best 5b1a52b8e0 更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-2): 格式化 commands (79 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-5): 格式化 components其余 + hooks + tools (232 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md

- README.md: 大幅重写,更详细版本历史和配置示例
- Run.ps1: 新增 Windows 启动脚本
- TODO.md: 新增包完成清单
- V6.md: 删除(架构重构规划已不适用)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复以前的问题

* fix: 修复 login 面板的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:24:27 +08:00

216 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { marked, type Token, type Tokens } from 'marked'
import React, { Suspense, use, useMemo, useRef } from 'react'
import { useSettings } from '../hooks/useSettings.js'
import { Ansi, Box, useTheme } from '../ink.js'
import {
type CliHighlight,
getCliHighlightPromise,
} from '../utils/cliHighlight.js'
import { hashContent } from '../utils/hash.js'
import { configureMarked, formatToken } from '../utils/markdown.js'
import { stripPromptXMLTags } from '../utils/messages.js'
import { MarkdownTable } from './MarkdownTable.js'
type Props = {
children: string
/** When true, render all text content as dim */
dimColor?: boolean
}
// Module-level token cache — marked.lexer is the hot cost on virtual-scroll
// remounts (~3ms per message). useMemo doesn't survive unmount→remount, so
// scrolling back to a previously-visible message re-parses. Messages are
// immutable in history; same content → same tokens. Keyed by hash to avoid
// retaining full content strings (turn50→turn99 RSS regression, #24180).
const TOKEN_CACHE_MAX = 500
const tokenCache = new Map<string, Token[]>()
// Characters that indicate markdown syntax. If none are present, skip the
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
// the majority of short assistant responses and user prompts that are
// plain sentences. Checked via indexOf (not regex) for speed.
// Single regex: matches any MD marker or ordered-list start (N. at line start).
// One pass instead of 10× includes scans.
const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /
function hasMarkdownSyntax(s: string): boolean {
// Sample first 500 chars — if markdown exists it's usually early (headers,
// code fence, list). Long tool outputs are mostly plain text tails.
return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s)
}
function cachedLexer(content: string): Token[] {
// Fast path: plain text with no markdown syntax → single paragraph token.
// Skips marked.lexer's full GFM parse (~3ms on long content). Not cached —
// reconstruction is a single object allocation, and caching would retain
// 4× content in raw/text fields plus the hash key for zero benefit.
if (!hasMarkdownSyntax(content)) {
return [
{
type: 'paragraph',
raw: content,
text: content,
tokens: [{ type: 'text', raw: content, text: content }],
} as Token,
]
}
const key = hashContent(content)
const hit = tokenCache.get(key)
if (hit) {
// Promote to MRU — without this the eviction is FIFO (scrolling back to
// an early message evicts the very item you're looking at).
tokenCache.delete(key)
tokenCache.set(key, hit)
return hit
}
const tokens = marked.lexer(content)
if (tokenCache.size >= TOKEN_CACHE_MAX) {
// LRU-ish: drop oldest. Map preserves insertion order.
const first = tokenCache.keys().next().value
if (first !== undefined) tokenCache.delete(first)
}
tokenCache.set(key, tokens)
return tokens
}
/**
* Renders markdown content using a hybrid approach:
* - Tables are rendered as React components with proper flexbox layout
* - Other content is rendered as ANSI strings via formatToken
*/
export function Markdown(props: Props): React.ReactNode {
const settings = useSettings()
if (settings.syntaxHighlightingDisabled) {
return <MarkdownBody {...props} highlight={null} />
}
// Suspense fallback renders with highlight=null — plain markdown shows
// for ~50ms on first ever render while cli-highlight loads.
return (
<Suspense fallback={<MarkdownBody {...props} highlight={null} />}>
<MarkdownWithHighlight {...props} />
</Suspense>
)
}
function MarkdownWithHighlight(props: Props): React.ReactNode {
const highlight = use(getCliHighlightPromise())
return <MarkdownBody {...props} highlight={highlight} />
}
function MarkdownBody({
children,
dimColor,
highlight,
}: Props & { highlight: CliHighlight | null }): React.ReactNode {
const [theme] = useTheme()
configureMarked()
const elements = useMemo(() => {
const tokens = cachedLexer(stripPromptXMLTags(children))
const elements: React.ReactNode[] = []
let nonTableContent = ''
function flushNonTableContent(): void {
if (nonTableContent) {
elements.push(
<Ansi key={elements.length} dimColor={dimColor}>
{nonTableContent.trim()}
</Ansi>,
)
nonTableContent = ''
}
}
for (const token of tokens) {
if (token.type === 'table') {
flushNonTableContent()
elements.push(
<MarkdownTable
key={elements.length}
token={token as Tokens.Table}
highlight={highlight}
/>,
)
} else {
nonTableContent += formatToken(token, theme, 0, null, null, highlight)
}
}
flushNonTableContent()
return elements
}, [children, dimColor, highlight, theme])
return (
<Box flexDirection="column" gap={1}>
{elements}
</Box>
)
}
type StreamingProps = {
children: string
}
/**
* Renders markdown during streaming by splitting at the last top-level block
* boundary: everything before is stable (memoized, never re-parsed), only the
* final block is re-parsed per delta. marked.lexer() correctly handles
* unclosed code fences as a single token, so block boundaries are always safe.
*
* The stable boundary only advances (monotonic), so ref mutation during render
* is idempotent and safe under StrictMode double-rendering. Component unmounts
* between turns (streamingText → null), resetting the ref.
*/
export function StreamingMarkdown({
children,
}: StreamingProps): React.ReactNode {
// React Compiler: this component reads and writes stablePrefixRef.current
// during render by design. The boundary only advances (monotonic), so
// the ref mutation is idempotent under StrictMode double-render — but the
// compiler can't prove that, and memoizing around the ref reads would
// break the algorithm (stale boundary). Opt out.
'use no memo'
configureMarked()
// Strip before boundary tracking so it matches <Markdown>'s stripping
// (line 29). When a closing tag arrives, stripped(N+1) is not a prefix
// of stripped(N), but the startsWith reset below handles that with a
// one-time re-lex on the smaller stripped string.
const stripped = stripPromptXMLTags(children)
const stablePrefixRef = useRef('')
// Reset if text was replaced (defensive; normally unmount handles this)
if (!stripped.startsWith(stablePrefixRef.current)) {
stablePrefixRef.current = ''
}
// Lex only from current boundary — O(unstable length), not O(full text)
const boundary = stablePrefixRef.current.length
const tokens = marked.lexer(stripped.substring(boundary))
// Last non-space token is the growing block; everything before is final
let lastContentIdx = tokens.length - 1
while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') {
lastContentIdx--
}
let advance = 0
for (let i = 0; i < lastContentIdx; i++) {
advance += tokens[i]!.raw.length
}
if (advance > 0) {
stablePrefixRef.current = stripped.substring(0, boundary + advance)
}
const stablePrefix = stablePrefixRef.current
const unstableSuffix = stripped.substring(stablePrefix.length)
// stablePrefix is memoized inside <Markdown> via useMemo([children, ...])
// so it never re-parses as the unstable suffix grows
return (
<Box flexDirection="column" gap={1}>
{stablePrefix && <Markdown>{stablePrefix}</Markdown>}
{unstableSuffix && <Markdown>{unstableSuffix}</Markdown>}
</Box>
)
}