fix(web-read-card): align highlight grammars with read hints, fix fixture schema, restore running sweep

Register the full grammar set the read tool's langFromPath emits (python,
go, rust, yaml, markdown, html, and the rest) so a read card highlights the
same extensions the backend recognizes instead of returning undefined for
them. Rewrite highlightLines' terminator-line check to the explicit
last !== undefined form to keep a single branch for per-file coverage.

Add the running-state sweep animation to ReadRow, matching BashRow/ToolRow,
so a running read row shows executing feedback.

Use file_path (the real read tool schema field) in the turn 66 read fixture
sample, its presentCall branch, and the turn 64 run_code read sub-dispatches,
so the built-boot snapshot replays a production-shaped call and the details
panel shows the correct Input JSON.

Document why ReadBlock omits TerminalBlock's empty-window copy guard, and
correct the read-card-model {@link} and the turn 66 fixture comment.
This commit is contained in:
Chinesezjc
2026-07-30 19:56:06 +08:00
parent d34211d781
commit 69a84e1403
5 changed files with 132 additions and 19 deletions
@@ -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':
@@ -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
@@ -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;
@@ -131,6 +131,13 @@ export function ReadBlock({
<span className={css.count}>{`显示 ${lines.length} / ${totalLines}`}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* 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. */}
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
@@ -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<string, string>([
['typescript', 'typescript'],
@@ -31,6 +74,7 @@ const LANG_ALIASES = new Map<string, string>([
['tsx', 'typescript'],
['javascript', 'typescript'],
['js', 'typescript'],
['jsx', 'typescript'],
['shellscript', 'shellscript'],
['bash', 'shellscript'],
['sh', 'shellscript'],
@@ -38,6 +82,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). */
@@ -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 } })))