Merge remote-tracking branch 'origin/master' into worktree-session-hover-menu-fix

This commit is contained in:
creatixchu
2026-07-31 16:51:10 +08:00
281 changed files with 73550 additions and 3000 deletions
@@ -0,0 +1,117 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row,
markdown code-block font) so a read card and a fenced code block read as one
family. Content keeps `white-space: pre` and scrolls horizontally rather than
folding, because a source line's indentation is part of what a reader is
reading. */
.block {
--dsl-read-radius: 12px;
--dsl-read-line-height: 22px;
/* Fixed-width gutter column for the line numbers, so the content edge stays
put down the whole window regardless of how wide the numbers grow. */
--dsl-read-gutter: 48px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-read-radius);
}
.banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-read-radius);
border-top-right-radius: var(--dsl-read-radius);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.count {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.lang {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.copyButton {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* One row per file line: a fixed gutter column, then the content. No wrapping —
a source line's leading whitespace is meaningful and scrolls sideways. */
.line {
display: flex;
min-height: var(--dsl-read-line-height);
line-height: var(--dsl-read-line-height);
white-space: pre;
}
.gutter {
flex: none;
width: var(--dsl-read-gutter);
padding-right: 14px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
/* The gutter is chrome, not content: keep it out of a text selection so a
copy of the visible rows carries the source, not the line numbers. */
user-select: none;
}
.content {
color: var(--dsw-alias-label-primary);
}
.expand {
display: block;
width: 100%;
padding: 0 0 0 var(--dsl-read-gutter);
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
@@ -0,0 +1,172 @@
// ReadBlock: the file surface for a read tool result — a banner (label +
// language + a "showing N of M" note when the read is a window + a copy
// control) over line-numbered, syntax-highlighted source. Each row carries the
// file's OWN line number in a gutter, so a windowed read past an offset keeps
// its file numbering rather than re-counting from 1. Highlighting reuses the
// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
// needs; an unknown or absent language renders plain monospace. Long content is
// height-capped with the same head/tail arithmetic TerminalBlock uses, so the
// two cards collapse a long body at the same place. Colors resolve through
// --shiki-*/--dsw-* tokens.
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import {
grammarLoadCount,
highlightLines,
subscribeGrammarLoaded,
type HighlightSpan,
} from './markdown/highlight.ts'
import css from './ReadBlock.module.css'
/**
* Content lines shown before the height cap collapses the middle. Matches
* TerminalBlock's default so a long read and a long command output cut at the
* same place in the same flow.
*/
export const DEFAULT_READ_MAX_LINES = 16
/** One line of the read window: its file line number and its text (no trailing newline). */
export interface ReadBlockLine {
/** 1-based line number in the file (a window past an offset keeps the file's own numbering). */
number: number
/** The line's text, already truncated to the read tool's per-line cap. */
text: string
}
export interface ReadBlockProps {
/** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */
label?: string | undefined
/** The returned window's lines, in file order, each keeping its file line number. */
lines: readonly ReadBlockLine[]
/** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
totalLines: number
/** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
lang?: string | undefined
/** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/**
* Render one line's highlighted runs. The css-variables theme colors every run,
* so each run is a styled span; a line with no highlighting at all takes the
* bare-text path in the caller instead (an unknown or absent language).
* @param spans - the line's styled runs.
* @returns the line's children.
*/
function renderSpans(spans: readonly HighlightSpan[]) {
return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
}
/**
* Render a read tool result as a line-numbered, optionally syntax-highlighted
* file view.
* @param props - see {@link ReadBlockProps}.
* @returns the read block element.
*/
export function ReadBlock({
label,
lines,
totalLines,
lang,
maxLines = DEFAULT_READ_MAX_LINES,
className,
}: ReadBlockProps) {
// The raw text the copy control writes and the highlighter tokenizes: the
// window's lines joined by newlines, without the file numbers or any chrome.
// Highlighting the whole window in one call (not line by line) keeps grammar
// context across lines — a multi-line string or comment stays one construct.
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
// Re-render when a lazy grammar finishes loading, so a read card that showed
// plain text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
// unknown/absent (or not-yet-loaded) language, when every line renders as
// bare text.
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The window's raw text, never the rendered tree: the gutter numbers and the
// banner are chrome the file does not contain.
void writeClipboard(raw).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, raw])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock's height cap, so a long read and a
// long command output slice their head and tail at the same place.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
// A read is a window when its returned lines are fewer than the file's total;
// the note states that so a reader is not misled that the file ends here.
const windowed = lines.length < totalLines
/**
* Render a slice of the line array as gutter-numbered rows.
* @param slice - the lines to draw, each with its aligned run array.
* @returns the row elements.
*/
const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
slice.map(([line, spans]) => (
<div key={line.number} className={css.line}>
<span className={css.gutter} aria-hidden>{line.number}</span>
<span className={css.content}>{spans === undefined ? line.text : renderSpans(spans)}</span>
</div>
))
// Pair each line with its aligned run array up front, so head/tail slicing
// keeps the two in step without re-indexing.
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
[line, highlighted?.[index]])
return (
<div className={clsx(css.block, className)} data-read="">
<div className={css.banner}>
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{`显示 ${lines.length} / ${totalLines}`}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Hide copy on an empty window, matching TerminalBlock's empty-output
guard: a successful read of an empty file returns lines: [] with
card:'read', so this branch is reachable, and copying then would
wipe the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
</div>
<div className={css.body}>
{rows(capped ? paired.slice(0, headLines) : paired)}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起内容' : `展开其余 ${hidden}`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{capped && rows(paired.slice(paired.length - tailLines))}
</div>
</div>
)
}
@@ -24,6 +24,8 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
@@ -4,10 +4,10 @@
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { highlightToHtml } from './highlight.ts'
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
@@ -25,7 +25,11 @@ export interface CodeBlockProps {
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
@@ -5,10 +5,17 @@
* theme package's token sheets as `--shiki-*` custom properties (light and
* dark blocks), never here — the repo's tokens-only styling rule.
*
* Grammars are the set the harness actually renders: TypeScript programs
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
* and JSON payloads. An unknown or absent language falls back to plain text
* (no highlighting, still monospace) — never an error.
* Only the three markdown-fence and `run_code` grammars (TypeScript, shell,
* JSON) load into the singleton at boot — the set every session renders. The
* read card's wider extension set (the file-extension language hints the read
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
* markup, …) is imported lazily and registered the first time such a language
* is requested, so a session that never opens a read card in one of those
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
* init. The first render of a lazy language falls back to plain text while its
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
* with highlighting. An unknown or absent language falls back to plain text (no
* highlighting, still monospace) — never an error.
*/
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
@@ -17,12 +24,69 @@ import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
import type { HighlighterCore } from 'shiki/core'
import type { CSSProperties } from 'react'
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
* from a boot grammar so no direct `@shikijs/types` dependency is needed. */
type LangModule = { default: typeof langTs }
/**
* Language ids (and aliases) the singleton registers; everything else renders
* Grammars the singleton loads at boot; each entry's own `name` is the id
* `codeToTokens`/`codeToHtml` resolve. The JS-family aliases (js/jsx/ts/tsx)
* resolve to the TypeScript grammar rather than a separate one: it tokenizes
* plain TS/JS exactly, and JSX/TSX approximately (shiki's TS grammar is not the
* dedicated TSX grammar, so JSX elements tokenize imperfectly) — an accepted
* trade to keep the boot set to one JS-family grammar. The read card's wider
* set loads lazily through {@link LAZY_GRAMMARS}.
*/
const LANGS = [langTs, langBash, langJson]
/**
* The read card's extension grammars, each behind a dynamic import so its
* module stays out of the boot chunk until a read of that language renders.
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
* hands the whole array to `loadLanguageSync`, which registers each entry
* (including embedded sub-grammars). The three boot grammars are absent —
* already loaded, so no alias value ever points at a missing entry here.
*/
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
['python', () => import('@shikijs/langs/python')],
['ruby', () => import('@shikijs/langs/ruby')],
['go', () => import('@shikijs/langs/go')],
['rust', () => import('@shikijs/langs/rust')],
['java', () => import('@shikijs/langs/java')],
['c', () => import('@shikijs/langs/c')],
['cpp', () => import('@shikijs/langs/cpp')],
['csharp', () => import('@shikijs/langs/csharp')],
['kotlin', () => import('@shikijs/langs/kotlin')],
['swift', () => import('@shikijs/langs/swift')],
['php', () => import('@shikijs/langs/php')],
['yaml', () => import('@shikijs/langs/yaml')],
['toml', () => import('@shikijs/langs/toml')],
['ini', () => import('@shikijs/langs/ini')],
['markdown', () => import('@shikijs/langs/markdown')],
['mdx', () => import('@shikijs/langs/mdx')],
['html', () => import('@shikijs/langs/html')],
['css', () => import('@shikijs/langs/css')],
['scss', () => import('@shikijs/langs/scss')],
['less', () => import('@shikijs/langs/less')],
['sql', () => import('@shikijs/langs/sql')],
['xml', () => import('@shikijs/langs/xml')],
['lua', () => import('@shikijs/langs/lua')],
])
/**
* Language ids (and aliases) the highlighter accepts; everything else renders
* plain. A Map, not an object: fence info strings are assistant-authored, so
* a label like `constructor` or `__proto__` must miss instead of resolving an
* inherited property and crashing the renderer inside shiki.
* inherited property and crashing the renderer inside shiki. Keys cover both
* the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids
* the read tool's `langFromPath` emits, so both callers resolve the same
* grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for
* the JSX/TSX approximation), unchanged from when this was the only
* non-shell/JSON grammar. A value not in {@link LANGS} names a
* {@link LAZY_GRAMMARS} entry loaded on first use.
*/
const LANG_ALIASES = new Map<string, string>([
['typescript', 'typescript'],
@@ -30,6 +94,7 @@ const LANG_ALIASES = new Map<string, string>([
['tsx', 'typescript'],
['javascript', 'typescript'],
['js', 'typescript'],
['jsx', 'typescript'],
['shellscript', 'shellscript'],
['bash', 'shellscript'],
['sh', 'shellscript'],
@@ -37,6 +102,35 @@ const LANG_ALIASES = new Map<string, string>([
['zsh', 'shellscript'],
['json', 'json'],
['jsonc', 'json'],
['py', 'python'],
['python', 'python'],
['rb', 'ruby'],
['ruby', 'ruby'],
['go', 'go'],
['rs', 'rust'],
['rust', 'rust'],
['java', 'java'],
['c', 'c'],
['cpp', 'cpp'],
['cs', 'csharp'],
['csharp', 'csharp'],
['kotlin', 'kotlin'],
['swift', 'swift'],
['php', 'php'],
['yaml', 'yaml'],
['yml', 'yaml'],
['toml', 'toml'],
['ini', 'ini'],
['md', 'markdown'],
['markdown', 'markdown'],
['mdx', 'mdx'],
['html', 'html'],
['css', 'css'],
['scss', 'scss'],
['less', 'less'],
['sql', 'sql'],
['xml', 'xml'],
['lua', 'lua'],
])
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
@@ -52,12 +146,68 @@ let singleton: HighlighterCore | undefined
function highlighter(): HighlighterCore {
singleton ??= createHighlighterCoreSync({
themes: [cssVariablesTheme],
langs: [langTs, langBash, langJson],
langs: LANGS,
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
return singleton
}
/** Grammar ids whose lazy import is in flight or done, so it is requested once. */
const requested = new Set<string>()
/** Subscribers re-rendered after a lazy grammar registers (React callers). */
const listeners = new Set<() => void>()
/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */
let loadCount = 0
/**
* Subscribe to lazy-grammar load completions; `listener` fires after a
* {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
* caller that rendered its plain fallback while the grammar loaded can
* re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with
* {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function.
* @param listener - invoked (no args) on each grammar-load completion.
* @returns a disposer that removes the listener.
*/
export function subscribeGrammarLoaded(listener: () => void): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* The lazy-grammar load counter — a value that changes on every load, so a
* `useSyncExternalStore` snapshot re-renders the subscriber when a grammar
* registers. Opaque: only its identity across renders matters.
* @returns the current load count.
*/
export function grammarLoadCount(): number {
return loadCount
}
/**
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
* synchronously; a lazy grammar not yet loaded starts its import (once) and
* reports not-ready, so the caller renders plain until a
* {@link subscribeGrammarLoaded} listener fires.
* @param resolved - the grammar id an alias resolved to.
* @returns whether the grammar is registered and ready to tokenize now.
*/
function ensureGrammar(resolved: string): boolean {
const load = LAZY_GRAMMARS.get(resolved)
// A boot grammar (already registered) has no lazy loader; it is always ready.
if (load === undefined) return true
if (highlighter().getLoadedLanguages().includes(resolved)) return true
if (!requested.has(resolved)) {
requested.add(resolved)
void load().then((mod) => {
highlighter().loadLanguageSync(mod.default)
loadCount += 1
for (const listener of listeners) listener()
})
}
return false
}
// Engine + grammar construction costs a long task (~120-175ms); building it
// during the first finalized fence's render would jank exactly when a stream
// completes. Warm the singleton in a deferred task at module load (= plugin
@@ -70,13 +220,59 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0)
/**
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
* when `lang` maps to a registered grammar; `undefined` means the caller
* renders its plain fallback.
* renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
* for this call and loads in the background; subscribe with
* {@link onGrammarLoaded} to re-highlight once it registers.
* @param code - the source text.
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
* @returns the highlighted HTML, or `undefined` for unknown languages.
* @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}
/**
* One highlighted run of a line: the text and the inline style shiki assigned
* it. The css-variables theme colors every run through a `--shiki-*` custom
* property, so `style.color` is always present; it is held as a style object
* rather than a bare color so a run spreads onto a `<span style>` uniformly.
*/
export interface HighlightSpan {
text: string
style: CSSProperties
}
/**
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
* registered grammar; `undefined` means the caller renders its plain fallback.
* A line-numbered view needs the token runs split per line (one gutter number
* per line), which the single-`<pre>` {@link highlightToHtml} does not expose,
* so this returns shiki's own 2D line/token structure narrowed to what a run
* renders. Each run's color is a `--shiki-*` custom property, keeping token
* colors on the theme package's sheets exactly as the HTML path does; the
* css-variables theme carries no font-style bits, matching that path's
* color-only output. The trailing newline shiki appends as a final empty line
* is dropped so the run count matches the caller's own line array.
* @param code - the source text.
* @param lang - the language hint (a file-extension-derived language id).
* @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
// third, empty line the caller's own line array does not carry. Drop that
// one terminator line so the two structures stay in step. The explicit
// `last !== undefined` (over `tokens[...]?.length`) keeps a single branch for
// per-file coverage, matching TerminalBlock's terminator check.
const last = tokens[tokens.length - 1]
const lines = tokens.length > 1 && last !== undefined && last.length === 0
? tokens.slice(0, -1)
: tokens
return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
}