DSH / Atlas
2026-07-30implementedfeature

Web read card frontend — the read tool's line window renders line-numbered and highlighted

Web 读取卡片前端 —— 读取工具的行窗口以带行号、语法高亮的形式渲染

The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent card, `card: 'read'`, to `ToolResultView`: a settled read now carries `{ path, lines: [{ number, text }], totalLines, lang? }` onto the conversation snapshot as `resultView`. That data reaches the browser, but the Web client had no consumer for it. Every read row derived from args alone and the details panel flattened the result's content bl

English

Problem

The read backend added a fourth render-intent card, card: 'read', to ToolResultView: a settled read now carries { path, lines: [{ number, text }], totalLines, lang? } onto the conversation snapshot as resultView. That data reaches the browser, but the Web client had no consumer for it. Every read row derived from args alone and the details panel flattened the result's content blocks into one <pre>, so a read showed as N: text-prefixed plain text with no gutter, no syntax highlighting, and no "showing N of M" affordance for a windowed read. The web terminal card established the pattern for consuming a structured card; the read card follows it, result-side only.

Decision

ReadBlock is a ui-primitives component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. ui-tool/src/client/tool/models/read-card-model.ts is the single place that turns the snapshot's resultView into the component's props, so the two sites cannot disagree.

A new ReadBlock primitive, not an extension of CodeBlock. CodeBlock already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which CodeBlock renders as a single <pre> tree with no per-line structure. Extending CodeBlock with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every run_code body that shares that component. Instead ReadBlock reuses the part that is genuinely shared: the shiki grammar singleton in markdown/highlight.ts. A new highlightLines(code, lang) there tokenizes into shiki's own per-line token arrays (codeToTokens) rather than the single-<pre> HTML highlightToHtml produces, so the block can place one gutter number per line and still color the content through the same --shiki-* custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from TerminalBlock (ceil(max/2) head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.

readCardModel is result-side only, mirroring the backend: a read call carries no content until execute returns, so the pending call stays a GenericCallView (kind: 'read') and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a card value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's title when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache.

The chat row renders the card resident under the summary line, capped at CHAT_READ_MAX_LINES (8, half the primitive's default), the same posture BashRow gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed ReadRow (registered under read through ctx.slots.inject, exactly as the bash sample) whose summary is the file path as an openable host link, and GenericToolCard's fallback for a read-declaring tool without its own keyed row (e.g. web_fetch, which classifies to the read variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.

Whole-row collapse/expand (defaulting every tool call to collapsed) is owned by the unified expand-and-inspect note, which flipped every resident card at once; this note's card is resident, matching the terminal card it sits beside.

Read-card grammars load lazily; only the boot three stay eager. highlight.ts is a platform seed ui-primitives loads on every Web boot, and its warm-up unconditionally builds the shiki singleton. The read card's langFromPath hints span the full source/config/markup extension set (python, rust, yaml, html, …); registering all of them eagerly would add ~1.6 MB of grammar modules to the boot chunk and their synchronous init to every session, including sessions that never open a read card. So only the three grammars every session already renders — TypeScript, shell, JSON (the markdown-fence and run_code languages) — load at boot. Each read-card extension grammar sits behind a dynamic import() in LAZY_GRAMMARS, keyed by the grammar id its aliases resolve to. On the first highlightLines/highlightToHtml call for a lazy language, ensureGrammar starts the import (once) and returns not-ready, so the card renders plain that frame; when the import resolves it registers the grammar with loadLanguageSync, bumps a load counter, and notifies subscribers. ReadBlock and CodeBlock subscribe through useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount), so the card re-renders with highlighting the moment the grammar is ready. An unknown/absent language still returns undefined synchronously (plain, never an error).

The empty-window copy control is hidden, matching TerminalBlock. A successful read of an empty file returns lines: [], totalLines: 0, and presentResult still projects card: 'read', so the empty-window branch is reachable. ReadBlock therefore hides the copy control when lines is empty, exactly as TerminalBlock hides copy on empty output, so the button can never wipe the clipboard with an empty string.

Alternatives considered

Extend CodeBlock with an optional line-number gutter and startLine. Rejected: it imposes a read-specific gutter, a windowed-count note, and a height cap on every markdown fence and run_code body that shares CodeBlock, for no benefit to those callers. The genuinely shared surface is the shiki grammar singleton, which both blocks reuse through highlight.ts; the chrome around it differs (a read has a gutter and a window note, a fence has neither), so a second small primitive is the correct split, exactly as TerminalBlock is a second primitive over the same tokens rather than a CodeBlock mode.

Reuse highlightToHtml and inject gutter numbers with CSS counters. Rejected: the single-<pre> HTML shiki emits has no per-line boundary a gutter can hang a file line number off (a windowed read's numbers start above 1 and are not a simple CSS counter increment), and parsing the numbers back out of the HTML would be fragile. codeToTokens gives the per-line token structure directly.

Register all read-card grammars eagerly in the boot warm-up. Rejected: it puts ~1.6 MB of grammar modules and their synchronous init on every Web boot for a card most sessions never open. The lazy path costs a single plain-first frame the first time a given language is read, then highlights on the grammar-load re-render; the boot cost is paid only for the three grammars every session already renders.

Consequences

ui-primitives gains ReadBlock and highlightLines; no new runtime dependency (shiki was already present for CodeBlock). ReadBlock reads only the read view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the read capability still gets the backend's content fallback (the envelope-stripped text) through the generic card, unchanged.

A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A run_code sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: session.ts folds tool/code-dispatch(-start) with resultView: null, so a nested read keeps the generic flattened form.

Testing

packages/client/ui-primitives/tests/read-block.client.spec.tsx pins the primitive and the token path: highlightLines' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its undefined for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and ReadBlock's gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its aria-expanded toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. code-block.spec.tsx covers highlightToHtml including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both ReadBlock.tsx and highlight.ts (and CodeBlock.tsx) hold per-file 100% coverage across the two specs.

packages/client/ui-tool/tests/read-card.client.spec.tsx pins the wiring at every render site: readCardModel's derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in GenericToolCard's fallback and in the keyed ReadRow (plus its path link opening the host, its running/error/stopped states, and its read-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage exclude list (ui-tool/src/*), so it is written against no gate pressure.

The fixture (packages/client/connection/src/client/fixture.ts) gains turn 66, a read call whose result view is a windowed read (lines starting at file line 41, totalLines 180, a ts hint), so the built-boot snapshot and a live ?fixture server show the read card with its gutter numbers, highlighting, and count note. It is named read to exercise the keyed ReadRow. The turn 64 run_code sample's nested read sub-dispatches do not exercise the render-site fallback read card: session.ts folds them with resultView: null, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by read-card.spec.tsx's web_fetch case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next turn/start.

Related

中文

Problem

读取后端ToolResultView 增加了第四种渲染意图卡片 card: 'read':一次已结算的读取现在会把 { path, lines: [{ number, text }], totalLines, lang? } 作为 resultView 带到会话快照上。这份数据能到达浏览器,但 Web 客户端没有消费者。每个读取行都仅从参数派生,详情面板把结果的 content block 摊平进一个 <pre>,于是读取显示为带 N: text 前缀的纯文本,没有行号栏、没有语法高亮,也没有窗口读取的"显示 N / M"提示。web 终端卡片确立了消费一个结构化卡片的模式;读取卡片沿用它,只在结果侧。

Decision

ReadBlock 是一个 ui-primitives 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。ui-tool/src/client/tool/models/read-card-model.ts 是把快照的 resultView 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。

新建一个 ReadBlock primitive,而不是扩展 CodeBlock CodeBlock 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 CodeBlock 把内容渲染为单个 <pre> 树、没有逐行结构。给 CodeBlock 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 run_code 程序体。ReadBlock 转而复用真正共享的部分:markdown/highlight.ts 里的 shiki 语法单例。那里新增的 highlightLines(code, lang) 把代码切成 shiki 自己的逐行 token 数组(codeToTokens),而不是 highlightToHtml 产出的单 <pre> HTML,于是该 block 能每行放一个行号、同时用同一套 --shiki-* 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 TerminalBlockceil(max/2) 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。

readCardModel 只在结果侧,与后端对称:一次读取调用在 execute 返回前不带任何内容,因此挂起中的调用保持为 GenericCallViewkind: 'read'),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null,包括本 UI 版本不认识的 card 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 title 时取它(约定的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。

聊天行把卡片常驻渲染在摘要行之下,上限 CHAT_READ_MAX_LINES(8,是 primitive 默认值的一半),与 BashRow 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed ReadRow(经 ctx.slots.injectread 键注册,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 GenericToolCard 对没有自己 keyed 行的读取声明工具(例如归到 read 变体的 web_fetch)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。

整行折叠/展开(把每个工具调用默认折叠)归统一展开与检视 note所有,它已一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。

读取卡片的语法按需 lazy 加载,只有 boot 三种保持 eager。 highlight.tsui-primitives 在每次 Web 启动都加载的平台 seed,其预热会无条件构建 shiki 单例。读取卡片的 langFromPath 提示覆盖完整的源码/配置/标记扩展集(python、rust、yaml、html……);把它们全部 eager 注册会给启动 chunk 增加约 1.6 MB 的语法模块、并把它们的同步初始化摊给每个会话,包括从不打开读取卡片的会话。因此只有每个会话本就渲染的三种语法 —— TypeScript、shell、JSON(markdown 围栏与 run_code 语言)—— 在 boot 时加载。每种读取卡片扩展语法置于 LAZY_GRAMMARS 中一个动态 import() 之后,以其别名解析到的语法 id 为键。对某个 lazy 语言首次调用 highlightLines/highlightToHtml 时,ensureGrammar 启动 import(仅一次)并返回未就绪,于是卡片该帧渲染纯文本;import 解析后用 loadLanguageSync 注册该语法、递增一个加载计数、并通知订阅者。ReadBlockCodeBlock 通过 useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount) 订阅,因此语法就绪的那一刻卡片就重渲染带上高亮。未知/缺省语言仍同步返回 undefined(纯文本,绝不报错)。

空窗口的复制控件被隐藏,与 TerminalBlock 对齐。 成功读取一个空文件会返回 lines: []totalLines: 0,且 presentResult 仍投出 card: 'read',因此空窗口分支是可达的。故 ReadBlocklines 为空时隐藏复制控件,正如 TerminalBlock 对空输出隐藏复制,使按钮绝不会用空字符串清空剪贴板。

Alternatives considered

CodeBlock 加一个可选行号栏和 startLine 拒绝:这会把读取专属的行号栏、窗口计数提示和高度上限强加给共享 CodeBlock 的每个 markdown 围栏和 run_code 程序体,对那些调用者毫无好处。真正共享的界面是 shiki 语法单例,两个 block 都通过 highlight.ts 复用它;围绕它的外壳各不相同(读取有行号栏和窗口提示,围栏两者都没有),因此第二个小 primitive 是正确的切分 —— 正如 TerminalBlock 是基于同一套 token 的第二个 primitive,而不是 CodeBlock 的一种模式。

复用 highlightToHtml,用 CSS counter 注入行号。 拒绝:shiki 产出的单 <pre> HTML 没有可供行号栏挂上文件行号的逐行边界(窗口读取的行号从大于 1 处开始,不是简单的 CSS counter 自增),而从 HTML 里把行号解析回来又很脆弱。codeToTokens 直接给出逐行 token 结构。

在 boot 预热里 eager 注册所有读取卡片语法。 拒绝:这会给每次 Web 启动摊上约 1.6 MB 语法模块及其同步初始化,只为一张多数会话从不打开的卡片。lazy 路径的代价是某个语言首次被读取时的一帧纯文本,随后在语法加载的重渲染里高亮;boot 代价只为每个会话本就渲染的三种语法付出。

Consequences

ui-primitives 增加 ReadBlockhighlightLines;没有新的运行时依赖(shiki 已因 CodeBlock 存在)。ReadBlock 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 content 回退(剥掉外壳的文本),保持不变。

Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。按已发布的协议格式,run_code 子派发不会到达读取卡片,与嵌套 bash 调用到不了终端卡片同因:session.tstool/code-dispatch(-start) 折叠为 resultView: null,因此嵌套读取保持通用的摊平形式。

Testing

packages/client/ui-primitives/tests/read-block.client.spec.tsx 固定 primitive 与 token 路径:highlightLines 的逐行 css-variables 片段、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 undefined、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 ReadBlock 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 aria-expanded 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。code-block.spec.tsx 覆盖 highlightToHtml,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。ReadBlock.tsxhighlight.ts(及 CodeBlock.tsx)在这两个 spec 上均保持每文件 100% 覆盖。

packages/client/ui-tool/tests/read-card.client.spec.tsx 固定每个渲染点的接线:readCardModel 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;GenericToolCard 回退中与 keyed ReadRow 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 read 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 exclude 列表(ui-tool/src/*),因此不承受门槛压力。

packages/client/connection/src/client/fixture.ts 中的 fixture(测试前置数据)增加轮次 66,一次 read 调用,其结果视图是窗口读取(行号从文件行 41 起、totalLines 180、ts 提示),使 built-boot 快照和实时 ?fixture 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 read 以驱动 keyed ReadRow。轮次 64 的 run_code 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:session.ts 把它们折叠为 resultView: null,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 read-card.spec.tsxweb_fetch 用例钉住。轮次 66 排在 todo 轮次(现为 67)之前,与终端样例同因:常驻计划在下一次 turn/start 退场。

Related

  • 读取卡片后端 —— 增加本文消费的 card: 'read' 结果视图;产出本文渲染的 lines/totalLines/lang
  • Web 终端卡片 —— 本文遵循的先例:一个 ui-primitives block、一个 contract/*-card-model.ts 派生、一个 keyed 行,以及让 GenericToolCard/DetailsPanel 感知卡片。
  • Web 客户端语法高亮 —— 拥有 CodeBlock 与 shiki highlight.ts 单例,本文以逐行 token 路径扩展它。
  • 工具调用呈现的标签式渲染意图联合 —— card 标签词汇表;Web 客户端现在是 read 分支的完整消费者。