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 } })))