diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 6c177e6c70..0a2379c70e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -267,8 +267,8 @@ function buildAlphaLog(): SessionEvent[] { const turn = 64 const callId = `fx-call-${turn}` const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n' - + 'const demo = await tools.read({ path: "notes/demo.txt" })\n' - + 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n' + + 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n' + + 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n' + 'return { listing, demo }' const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' }) push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) @@ -293,8 +293,8 @@ function buildAlphaLog(): SessionEvent[] { }) } dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt') - dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n') - dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true) + dispatchPair(2, 'read', { file_path: 'notes/demo.txt' }, 'hello fixture\n') + dispatchPair(3, 'read', { file_path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true) push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) }, @@ -326,13 +326,16 @@ function buildAlphaLog(): SessionEvent[] { // Turn 66: the read sample — a WINDOW past an offset so the card draws file // line numbers starting above 1 and a "showing N of M" note (the window is // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path - // highlights. Named `read`, so it exercises the keyed ReadRow registration - // (the render-site fallback row is covered by the read sub-dispatches in the - // turn 64 run_code sample). The read render intent is result-side only, so its - // pending call stays a generic `kind: 'read'` card; presentResult carries the + // highlights. Named `read`, so it exercises the keyed ReadRow registration. + // The render-site fallback ROW SHAPE (a read call on the generic flattened + // path) is covered by the turn 64 run_code read sub-dispatches, which + // session.ts folds with resultView: null; the fallback-row + read-CARD + // combination is pinned by the web_fetch case in read-card.spec.tsx, not by + // this fixture. The read render intent is result-side only, so its pending + // call stays a generic `kind: 'read'` card; presentResult carries the // structured window. Ordered BEFORE the todo turn for the same reason the // terminal sample is: the standing plan retires at the next `turn/start`. - toolTurn(66, 'read', `{"path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) + toolTurn(66, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) const todoArgs = JSON.stringify({ todos: fixtureTodos }) toolTurn(67, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') @@ -375,7 +378,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { // carries no file content until execute returns. The rich read card arrives // in presentResult. case 'read': - return { card: 'generic', title: `Read ${str(args.path)}`, kind: 'read', locations: [{ path: str(args.path) }] } + return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] } case 'edit': return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } case 'write': diff --git a/packages/client/ui-conversation/src/client/contract/read-card-model.ts b/packages/client/ui-conversation/src/client/contract/read-card-model.ts index 83779722f8..62a591bb17 100644 --- a/packages/client/ui-conversation/src/client/contract/read-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/read-card-model.ts @@ -23,7 +23,8 @@ import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' * scannable across many calls, while the details panel is the single-call * reading surface. A design constant of this UI's row geometry, not a * deployment choice, so it is fixed here rather than a plugin Config field. The - * same split {@link CHAT_TERMINAL_MAX_LINES} draws for terminal output. + * same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for + * terminal output. */ export const CHAT_READ_MAX_LINES = 8 diff --git a/packages/client/ui-conversation/src/client/toolviews/read-row.module.css b/packages/client/ui-conversation/src/client/toolviews/read-row.module.css index b83b395f1a..a03a949451 100644 --- a/packages/client/ui-conversation/src/client/toolviews/read-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/read-row.module.css @@ -15,7 +15,7 @@ } .root { - position: relative; + position: relative; /* sweep-glare overlay anchor */ overflow: hidden; display: flex; align-items: center; @@ -23,6 +23,32 @@ min-width: 0; } +/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row + gives the same executing feedback a running command row does. The leading + read icon stays static (a read has no per-step state to animate); the sweep + is the row-level running signal. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-read-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-read-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + .leading { flex: none; width: 16px; diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index df9658e3dd..bd5ef6f5ac 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -131,6 +131,13 @@ export function ReadBlock({ {`显示 ${lines.length} / ${totalLines} 行`} )} {lang ?? ''} + {/* No empty-window guard around the copy control, unlike TerminalBlock + (which hides copy on empty output): a read card is reached only for + a settled read whose result view declares `card:'read'`, and the + read tool projects that view solely for a parsed envelope with a + line window. An empty or non-envelope result falls back to the + generic card upstream (readCardModel returns null), so `lines` is + never empty here — the branch TerminalBlock needs cannot arise. */} diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 74709d4ac2..cd669b9919 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -5,9 +5,11 @@ * 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 + * Grammars are the set the harness actually renders: the markdown-fence and + * `run_code` languages (TypeScript, shell, JSON) plus the file-extension + * language hints the read tool's `langFromPath` emits (`packages/fs/tool-fs`), + * so a read card highlights the same source, config, and markup extensions the + * backend recognizes. An unknown or absent language falls back to plain text * (no highlighting, still monospace) — never an error. */ @@ -16,14 +18,55 @@ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' import langTs from '@shikijs/langs/typescript' import langBash from '@shikijs/langs/shellscript' import langJson from '@shikijs/langs/json' +import langPython from '@shikijs/langs/python' +import langRuby from '@shikijs/langs/ruby' +import langGo from '@shikijs/langs/go' +import langRust from '@shikijs/langs/rust' +import langJava from '@shikijs/langs/java' +import langC from '@shikijs/langs/c' +import langCpp from '@shikijs/langs/cpp' +import langCsharp from '@shikijs/langs/csharp' +import langKotlin from '@shikijs/langs/kotlin' +import langSwift from '@shikijs/langs/swift' +import langPhp from '@shikijs/langs/php' +import langYaml from '@shikijs/langs/yaml' +import langToml from '@shikijs/langs/toml' +import langIni from '@shikijs/langs/ini' +import langMarkdown from '@shikijs/langs/markdown' +import langMdx from '@shikijs/langs/mdx' +import langHtml from '@shikijs/langs/html' +import langCss from '@shikijs/langs/css' +import langScss from '@shikijs/langs/scss' +import langLess from '@shikijs/langs/less' +import langSql from '@shikijs/langs/sql' +import langXml from '@shikijs/langs/xml' +import langLua from '@shikijs/langs/lua' import type { HighlighterCore } from 'shiki/core' import type { CSSProperties } from 'react' +/** + * Grammars the singleton registers; each entry's own `name` is the id + * `codeToTokens`/`codeToHtml` resolve. The TypeScript grammar embeds JS/JSX/TSX, + * so the JS-family fence aliases resolve to it rather than a separate grammar. + */ +const LANGS = [ + langTs, langBash, langJson, + langPython, langRuby, langGo, langRust, langJava, + langC, langCpp, langCsharp, langKotlin, langSwift, langPhp, + langYaml, langToml, langIni, + langMarkdown, langMdx, langHtml, langCss, langScss, langLess, + langSql, langXml, langLua, +] + /** * Language ids (and aliases) the singleton registers; 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 (which embeds it), + * unchanged from when this was the only non-shell/JSON grammar. */ const LANG_ALIASES = new Map([ ['typescript', 'typescript'], @@ -31,6 +74,7 @@ const LANG_ALIASES = new Map([ ['tsx', 'typescript'], ['javascript', 'typescript'], ['js', 'typescript'], + ['jsx', 'typescript'], ['shellscript', 'shellscript'], ['bash', 'shellscript'], ['sh', 'shellscript'], @@ -38,6 +82,35 @@ const LANG_ALIASES = new Map([ ['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). */ @@ -53,7 +126,7 @@ let singleton: HighlighterCore | undefined function highlighter(): HighlighterCore { singleton ??= createHighlighterCoreSync({ themes: [cssVariablesTheme], - langs: [langTs, langBash, langJson], + langs: LANGS, engine: createJavaScriptRegexEngine({ forgiving: true }), }) return singleton @@ -114,8 +187,11 @@ export function highlightLines(code: string, lang: string | undefined): Highligh 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. - const lines = tokens.length > 1 && tokens[tokens.length - 1]?.length === 0 + // 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 } })))