From 7a60a236bc3a01c175ec5df61733cd33f84cb6cb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:15:38 +0800 Subject: [PATCH] feat(web): render read tool output as a line-numbered code card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the card:'read' result view (path, numbered lines, totalLines, lang) the read backend PR added. ReadBlock (ui-primitives) draws a per-line gutter with each line's own file number, shiki highlighting via a new highlightLines returning per-line token arrays, a 显示 X / Y 行 window note, a height cap matching TerminalBlock, and a copy control. read-card-model is the single resultView derivation; a keyed ReadRow registers under read with the card resident under its path-link summary. The generic fallback and the details panel are read-aware. Fixture gains a windowed read turn for the built-boot snapshot. --- ...026-07-30-web-read-card-frontend.i18n.yaml | 6 + .../2026-07-30-web-read-card-frontend.md | 48 +++ .../2026-07-30-web-read-card-frontend.zh.md | 48 +++ .../client/connection/src/client/fixture.ts | 58 +++- .../ui-conversation/src/client/apply.ts | 5 + .../client/chat/GenericToolCard.module.css | 12 + .../src/client/chat/GenericToolCard.tsx | 18 +- .../src/client/contract/read-card-model.ts | 75 +++++ .../src/client/contract/tool-call-model.ts | 9 +- .../client/skeleton/DetailsPanel.module.css | 6 + .../src/client/skeleton/DetailsPanel.tsx | 13 +- .../src/client/toolviews/read-row.module.css | 93 ++++++ .../src/client/toolviews/read-row.tsx | 100 +++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 6 +- .../ui-conversation/tests/read-card.spec.tsx | 282 ++++++++++++++++++ .../ui-primitives/src/ReadBlock.module.css | 117 ++++++++ .../client/ui-primitives/src/ReadBlock.tsx | 156 ++++++++++ packages/client/ui-primitives/src/index.ts | 2 + .../ui-primitives/src/markdown/highlight.ts | 40 +++ .../ui-primitives/tests/read-block.spec.tsx | 215 +++++++++++++ 20 files changed, 1297 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md create mode 100644 packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css create mode 100644 packages/client/ui-conversation/src/client/contract/read-card-model.ts create mode 100644 packages/client/ui-conversation/src/client/toolviews/read-row.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/read-row.tsx create mode 100644 packages/client/ui-conversation/tests/read-card.spec.tsx create mode 100644 packages/client/ui-primitives/src/ReadBlock.module.css create mode 100644 packages/client/ui-primitives/src/ReadBlock.tsx create mode 100644 packages/client/ui-primitives/tests/read-block.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml new file mode 100644 index 0000000000..9ed8258368 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md +2026-07-30-web-read-card-frontend.md: 3d1893b72dc9ef8fed3ee3d92e52980b3eb06743 +2026-07-30-web-read-card-frontend.zh.md: acfb31a8e6306a90f634075754074e163d2431e1 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md new file mode 100644 index 0000000000..3d1893b72d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md @@ -0,0 +1,48 @@ +# Agent Note: Web read card frontend — the read tool's line window renders line-numbered and highlighted + +Status: implemented + +English | [中文](2026-07-30-web-read-card-frontend.zh.md) + +## Problem + +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 blocks into one `
`, 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](2026-07-28-web-terminal-card.md) 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-conversation/src/client/contract/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 `
` 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-`
` 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` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` 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 a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
+
+## 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-`
` 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.
+
+## 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.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, and its `undefined` for an unknown/absent language; 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, and the copy control writing the window's raw text on both the accepted and refused clipboard paths. Both `ReadBlock.tsx` and `highlight.ts` hold per-file 100% coverage (the latter over this spec plus `code-block.spec.tsx`, which covers `highlightToHtml`).
+
+`packages/client/ui-conversation/tests/read-card.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-conversation/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 render-site fallback row is already covered by the read sub-dispatches in the turn 64 `run_code` sample. It 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
+
+- [Read card backend](2026-07-30-web-read-card.md) — adds the `card: 'read'` result view this consumes; produces the `lines`/`totalLines`/`lang` this renders.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this follows: a `ui-primitives` block, a `contract/*-card-model.ts` derivation, a keyed row, and making `GenericToolCard`/`DetailsPanel` card-aware.
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and the shiki `highlight.ts` singleton this extends with a per-line token path.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `read` arm.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
new file mode 100644
index 0000000000..acfb31a8e6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
@@ -0,0 +1,48 @@
+# Agent Note: Web 读取卡片前端 —— 读取工具的行窗口以带行号、语法高亮的形式渲染
+
+Status: implemented
+
+[English](2026-07-30-web-read-card-frontend.md) | 中文
+
+## Problem
+
+[读取后端](2026-07-30-web-read-card.md)给 `ToolResultView` 增加了第四种渲染意图卡片 `card: 'read'`:一次已结算的读取现在会把 `{ path, lines: [{ number, text }], totalLines, lang? }` 作为 `resultView` 带到会话快照上。这份数据能到达浏览器,但 Web 客户端没有消费者。每个读取行都仅从参数派生,详情面板把结果的 content block 摊平进一个 `
`,于是读取显示为带 `N: text` 前缀的纯文本,没有行号栏、没有语法高亮,也没有窗口读取的"显示 N / M"提示。[web 终端卡片](2026-07-28-web-terminal-card.md)确立了消费一个结构化卡片的模式;读取卡片沿用它,只在结果侧。
+
+## Decision
+
+`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-conversation/src/client/contract/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
+
+**新建一个 `ReadBlock` primitive,而不是扩展 `CodeBlock`。** `CodeBlock` 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 `CodeBlock` 把内容渲染为单个 `
` 树、没有逐行结构。给 `CodeBlock` 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 `run_code` 程序体。`ReadBlock` 转而复用真正共享的部分:`markdown/highlight.ts` 里的 shiki 语法单例。那里新增的 `highlightLines(code, lang)` 把代码切成 shiki 自己的逐行 token 数组(`codeToTokens`),而不是 `highlightToHtml` 产出的单 `
` HTML,于是该 block 能每行放一个行号、同时用同一套 `--shiki-*` 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 `TerminalBlock`(`ceil(max/2)` 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。
+
+`readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView`(`kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null,包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。
+
+聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`(8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow`(在 `apply.ts` 里以 `read` 键注册,加载顺序接缝为 `inject: ['slots', 'conversation']`,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。
+
+整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。
+
+## Alternatives considered
+
+**给 `CodeBlock` 加一个可选行号栏和 `startLine`。** 拒绝:这会把读取专属的行号栏、窗口计数提示和高度上限强加给共享 `CodeBlock` 的每个 markdown 围栏和 `run_code` 程序体,对那些调用者毫无好处。真正共享的界面是 shiki 语法单例,两个 block 都通过 `highlight.ts` 复用它;围绕它的外壳各不相同(读取有行号栏和窗口提示,围栏两者都没有),因此第二个小 primitive 是正确的切分 —— 正如 `TerminalBlock` 是基于同一套 token 的第二个 primitive,而不是 `CodeBlock` 的一种模式。
+
+**复用 `highlightToHtml`,用 CSS counter 注入行号。** 拒绝:shiki 产出的单 `
` HTML 没有可供行号栏挂上文件行号的逐行边界(窗口读取的行号从大于 1 处开始,不是简单的 CSS counter 自增),而从 HTML 里把行号解析回来又很脆弱。`codeToTokens` 直接给出逐行 token 结构。
+
+## Consequences
+
+`ui-primitives` 增加 `ReadBlock` 和 `highlightLines`;没有新的运行时依赖(shiki 已因 `CodeBlock` 存在)。`ReadBlock` 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 `content` 回退(剥掉外壳的文本),保持不变。
+
+Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。`run_code` 子派发在已发布的线路上到不了读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。
+
+## Testing
+
+`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、以及它对未知/缺省语言返回 `undefined`;还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本。`ReadBlock.tsx` 与 `highlight.ts` 均保持每文件 100% 覆盖(后者由本 spec 加上覆盖 `highlightToHtml` 的 `code-block.spec.tsx` 共同达成)。
+
+`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-conversation/src/*`),因此不承受门槛压力。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`;渲染点回退行已由 turn 64 的 `run_code` 样例中的读取子派发覆盖。它排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。
+
+## Related
+
+- [读取卡片后端](2026-07-30-web-read-card.md) —— 增加本文消费的 `card: 'read'` 结果视图;产出本文渲染的 `lines`/`totalLines`/`lang`。
+- [Web 终端卡片](2026-07-28-web-terminal-card.md) —— 本文遵循的先例:一个 `ui-primitives` block、一个 `contract/*-card-model.ts` 派生、一个 keyed 行,以及让 `GenericToolCard`/`DetailsPanel` 感知卡片。
+- [Web 客户端语法高亮](../process/2026-07-26-web-syntax-highlighting-shiki.md) —— 拥有 `CodeBlock` 与 shiki `highlight.ts` 单例,本文以逐行 token 路径扩展它。
+- [工具调用呈现的标签式渲染意图联合](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇表;Web 客户端现在是 `read` 分支的完整消费者。
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index cb79a6b9a2..6c177e6c70 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -136,6 +136,33 @@ const TERMINAL_EXIT_STATUS: Record ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
+const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
+const READ_SAMPLE_TOTAL = 180
+const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
+
 const DEEPSEEK_REASONING = {
   efforts: [
     { id: 'off', name: 'Off' },
@@ -275,7 +302,7 @@ function buildAlphaLog(): SessionEvent[] {
     push({ type: 'step/end', data: { turn, step: 0 } })
     push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
   }
-  // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
+  // Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
   // todo/write snapshot event feeding the TodoPanel plan strip.
   const fixtureTodos = [
     { content: '梳理需求', status: 'completed' },
@@ -296,8 +323,19 @@ function buildAlphaLog(): SessionEvent[] {
   // strip empty and take the todo surfaces' own coverage with it.
   toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
 
+  // 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
+  // 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)
+
   const todoArgs = JSON.stringify({ todos: fixtureTodos })
-  toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
+  toolTurn(67, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
   // The real tool appends the snapshot mid-execution — between tool/call and
   // tool/result — so the fixture reproduces that exact ordering (the last
   // toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -332,6 +370,12 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
         card: 'diff', title: `Write ${str(args.path)}`,
         diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
       }
+    // A read pending call is a GENERIC card (kind: 'read', a follow-along
+    // location): the read render intent is result-side only, because a call
+    // 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) }] }
     case 'edit':
       return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
     case 'write':
@@ -344,6 +388,16 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
 function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
   const call = presentCall(name, argsRaw)
   if (call === undefined) return undefined
+  // The read result is the structured window the tool projects through
+  // `presentationMeta`; the fixture authors it inline (it cannot import the
+  // tool). Keyed on the name because the read pending call is a generic card,
+  // so `call.card` alone does not distinguish it from edit/write.
+  if (name === 'read') {
+    return {
+      card: 'read', path: READ_SAMPLE_PATH, lines: READ_SAMPLE_LINES,
+      totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
+    }
+  }
   switch (call.card) {
     case 'terminal':
       // The sample's own exit status, authored beside it: re-parsing the
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 7f3aeb38cc..e0027d2a86 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -19,6 +19,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
 import { ChatView } from './chat/ChatView.tsx'
 import { StatsLine } from './chat/StatsLine.tsx'
 import { bashToolviewSample } from './toolviews/bash-sample.tsx'
+import { readToolview } from './toolviews/read-row.tsx'
 import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
 import { todoToolview } from './toolviews/todo-row.tsx'
 import { todoDockEntry } from './skeleton/TodoPanel.tsx'
@@ -254,6 +255,10 @@ export function apply(ctx: Context): void {
   // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
   ctx.plugin(bashToolviewSample)
 
+  // The read row rides the same seam (a product registration, not a sample):
+  // Read · {path} chrome with the file's read card resident below it.
+  ctx.plugin(readToolview)
+
   // The todo_write row rides the same seam (a product registration, not a sample).
   ctx.plugin(todoToolview)
 
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
new file mode 100644
index 0000000000..5c2c6f2509
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
@@ -0,0 +1,12 @@
+/* GenericToolCard read card: same column-around-summary layout and indentation
+   the keyed ReadRow uses, so a read-declaring tool without its own row (e.g.
+   web_fetch) renders its read card identically. */
+
+.card {
+  display: flex;
+  flex-direction: column;
+}
+
+.read {
+  margin: 4px 0 4px 22px;
+}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index ce55d84f57..b96a14a62a 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -7,12 +7,14 @@
 import type { ReactNode } from 'react'
 import {
   IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
-  IconThinkOutline14,
+  IconThinkOutline14, ReadBlock,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowOwnerProps } from '../contract/slots.ts'
+import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
 import { terminalCardModel } from '../contract/terminal-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
 import { ToolRow } from './ToolRow.tsx'
+import css from './GenericToolCard.module.css'
 
 /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
 const VARIANT_ICONS: Record = {
@@ -29,8 +31,9 @@ const VARIANT_ICONS: Record = {
 export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
   const model = toolRowModel(toolName, block, cwd)
   const terminal = terminalCardModel(block, cwd)
+  const read = readCardModel(block, cwd)
   const singleFile = model.filePath !== undefined
-  return (
+  const row = (
     
   )
+  // A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
+  // so the file's read card is resident below the summary row exactly as the
+  // keyed ReadRow draws it. Only wrap when a card is present, so every other
+  // tool keeps the bare ToolRow.
+  if (read === null) return row
+  return (
+    
+ {row} + +
+ ) } 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 new file mode 100644 index 0000000000..83779722f8 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/read-card-model.ts @@ -0,0 +1,75 @@ +/** + * Pure derivation of the read-card props from a frozen call slice: the + * `card:'read'` render intent the read tool declares arrives on the snapshot as + * the settled result node's `resultView`, and this is the one place that turns + * it into what {@link ReadBlock} draws. Both conversation render sites (the chat + * tool row's resident body and the details panel's Output section) call this, so + * the path, lines, total, and language they show are derived once. + * + * The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)): + * a call carries no file content until `execute` returns, so the pending call + * stays a generic card (`kind: 'read'`). A running read therefore has no read + * card, and this returns null for it — the row keeps its args-derived summary + * until the result arrives. + * @module + */ +import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' + +/** + * Content lines the chat row's resident read body shows before collapsing the + * middle — half the primitive's own default, which the details panel keeps. A + * chat row is a summary surface inside the message flow: the flow must stay + * 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. + */ +export const CHAT_READ_MAX_LINES = 8 + +/** + * The {@link ReadBlock} props this derivation owns. Picked off the primitive's + * props so the two stay in step; `maxLines`/`className` belong to each render + * site. + */ +export type ReadCardModel = Pick + +/** + * Derive the read-card props for a tool call, or null when this call is not a + * read card and belongs on the generic path. + * + * The read card is result-side only, so only a settled call whose result view + * declares `card:'read'` produces one. Every other case is null — the + * documented generic-card default: + * + * - A running call: it has no result view yet, and a read carries no content at + * call time. + * - 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 one of the compiled variants, and the read tool's own + * generic fallback for an error result or a non-envelope body. + * + * The label is the read view's `title` when the tool supplied one (the + * presentation contract's replacement-title rule), otherwise the file path + * relativized to the session workspace so a workspace-rooted absolute path + * displays the same short form the row summary shows. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @param sessionCwd - the session workspace root; a workspace-rooted absolute + * path label displays relative to it. Absent leaves the path as authored. + * @returns the read-card props, or null for the generic path. + */ +export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null { + // Running has no result view; a read carries no content until execute returns. + if (!('kind' in block)) return null + const result = block.resultView?.card === 'read' ? block.resultView : null + if (result === null) return null + // Lines arrive frozen off the snapshot; copy into the primitive's own line + // shape so the card never holds a reference into the runtime's cache. + const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text })) + return { + label: result.title ?? relativizeToCwd(result.path, sessionCwd), + lines, + totalLines: result.totalLines, + lang: result.lang, + } +} diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index b53ef95c01..7043846f5b 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -109,8 +109,13 @@ const SUMMARY_KEYS: Record = { others: [], } -/** Strip the workspace root from workspace-rooted absolute paths (display only). */ -function relativizeToCwd(text: string, cwd: string | undefined): string { +/** + * Strip the workspace root from a workspace-rooted absolute path (display only). + * @param text - the path to shorten. + * @param cwd - session workspace root; absent or empty leaves the path unchanged. + * @returns the path relative to the workspace root, or unchanged when it is not rooted there. + */ +export function relativizeToCwd(text: string, cwd: string | undefined): string { if (cwd === undefined || cwd === '') return text const root = cwd.replace(/[/\\]+$/, '') if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1) diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 143174fe42..c513b9d976 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -106,3 +106,9 @@ .terminal { margin: 0; } + +/* The read card sits directly under its section label, same as the terminal + card: drop the primitive's standalone vertical margin. */ +.read { + margin: 0; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..642eec8946 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,10 +7,11 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, ReadBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { readCardModel } from '../contract/read-card-model.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. Every other call, and - * a running call with no terminal card yet, keeps the flattened text form. + * its alignment and scrolls sideways instead of folding. A read-card call + * renders through the shared ReadBlock at that same full height, so the whole + * returned window is line-numbered and highlighted. Every other call, and a + * running call with no card yet, keeps the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -147,6 +150,10 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } + const read = readCardModel(material.block, cwd) + // The panel takes the primitive's own default cap, not the row's tighter one: + // it is the single-call reading surface, so the whole window is available. + if (read !== null) return // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
运行中…
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 new file mode 100644 index 0000000000..b83b395f1a --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/read-row.module.css @@ -0,0 +1,93 @@ +/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus + the read card the row stacks under its summary line. */ + +/* Summary line over the read card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.read { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +/* File path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/read-row.tsx b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx new file mode 100644 index 0000000000..28151ed602 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx @@ -0,0 +1,100 @@ +// Read toolview registrant: the keyed toolview hole for the read tool +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Product chrome matches ToolRow (figma: Read · {path}); the summary is the +// file path as an openable link, exactly as the generic read row draws it. +// +// A read RESULT declares the read render intent, so this row renders the file's +// own line-numbered, syntax-highlighted content through ReadBlock resident +// below its summary line — the same posture BashRow gives a terminal card. The +// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the +// block's own default of 16) with the block's internal expander keeping a long +// read from taking over the message flow. A running read (no result yet) and a +// non-read result both render the summary row alone. The read intent is +// result-side only, so there is no running-state read card to draw. + +import type { Context } from 'cordis' +import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './read-row.module.css' + +/** Leading-slot state substitution: the tool icon yields to the state dot + * (error = red, interrupted = amber). Running keeps the icon. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's + * read card resident below it. The summary path is an openable host link when + * the row names a single file; the card's copy and expand controls plus that + * link are the row's only interactions (tool rows are not details-panel + * targets). + */ +export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) { + // Session workspace root: the read view's path relativizes against it (a + // workspace-rooted absolute path shows its short form), which the pure + // presenter cannot do. + const cwd = useSessions(list => list.byId[sessionId]?.cwd) + const model = toolRowModel(toolName, block, cwd) + const read = readCardModel(block, cwd) + const status = stateStatus(model.state) + const filePath = model.filePath + return ( +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {filePath !== undefined ? ( + + ) : ( + {model.summary} + )} +
+ {read !== null && ( + + )} +
+ ) +} + +/** + * The read row as a plain registrant plugin. `inject` carries the load-order + * seam: requiring the conversation service guarantees the chat entry (and with + * it the 'conversation.chat.toolview' declaration) is registered — + * ui-conversation's apply mounts the service after the chat entry. + */ +export const readToolview = { + name: 'read-toolview', + inject: ['slots', 'conversation'], + /** + * Register the read row into the chat view's keyed toolview hole. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d7b9125b34..57512c4d0d 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -80,12 +80,12 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the read row, and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the + // All registrant plugins' inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'todo_write']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx new file mode 100644 index 0000000000..a4ead14748 --- /dev/null +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -0,0 +1,282 @@ +// @vitest-environment jsdom +// The read render intent on the web side: the pure readCardModel derivation +// over the settled result view, and both conversation render sites that consume +// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback, +// each with the read card resident under the summary) and the details panel's +// Output section. Also pins the keyed 'read' toolview registration. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { Context } from 'cordis' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +const ARGS = '{"path":"src/a.ts","offset":41}' + +/** The read block's rendered content cells, one string per row (highlighting + * breaks a line across token spans, so match on the row's textContent). */ +function contentTexts(container: HTMLElement): string[] { + return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '') +} + +/** Three windowed lines starting at file line 41 (a read past an offset). */ +const sampleLines = [ + { number: 41, text: 'export const a = 1' }, + { number: 42, text: 'export const b = 2' }, + { number: 43, text: 'export const c = 3' }, +] + +/** The read tool's own result view for a settled file read. */ +const resultRead = (over?: Partial>): ToolResultView => ({ + card: 'read', path: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts', ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'read', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'read', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: '41: export const a = 1' }], isError: false, + callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over, +}) + +describe('readCardModel', () => { + it('derives the card from a settled read result view', () => { + expect(readCardModel(settled())).toEqual({ + label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts', + }) + }) + + it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => { + const model = readCardModel(settled()) + expect(model?.lines).toEqual(sampleLines) + expect(model?.lines).not.toBe(sampleLines) + expect(model?.lines[0]).not.toBe(sampleLines[0]) + }) + + it('takes the result view\'s replacement title over the relativized path', () => { + // The presentation contract defines a result title as REPLACING the pending + // one, so a tool that supplies a label wins over the path here. + expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label) + .toBe('Read (head) src/a.ts') + }) + + it('relativizes a workspace-rooted path label, and leaves others as authored', () => { + // A workspace-rooted absolute path shows its short form. + expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label) + .toBe('src/a.ts') + // A path outside the workspace stays as authored. + expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label) + .toBe('/srv/other.ts') + // With no session cwd there is nothing to relativize against. + expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label) + .toBe('/w/app/src/a.ts') + }) + + it('carries an omitted language through as undefined', () => { + const noLang = resultRead() + delete (noLang as { lang?: string }).lang + expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined() + }) + + it('returns null for a running read: the read intent is result-side only', () => { + // A read carries no content until execute returns, so the pending call is a + // generic card and there is no read card to draw yet. + expect(readCardModel(running())).toBeNull() + }) + + it('returns null for every non-read settled call: no view, generic view, unknown card', () => { + expect(readCardModel(settled({ resultView: null }))).toBeNull() + expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart' } as unknown as ToolResultView + expect(readCardModel(settled({ resultView: future }))).toBeNull() + }) +}) + +describe('GenericToolCard read body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({ + callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), + }) + + it('renders the read card resident under the summary, capped tighter than the panel', () => { + expect(CHAT_READ_MAX_LINES).toBeLessThan(16) + // web_fetch lands on the read variant without its own keyed row, so the + // fallback card owns the resident read block. + const view = render() + expect(view.container.querySelector('[data-read]')).not.toBeNull() + expect(contentTexts(view.container)).toContain('export const a = 1') + // The gutter keeps the file's own line numbers. + expect(view.getByText('41')).toBeTruthy() + }) + + it('a non-read tool renders the bare row with no read card', () => { + const view = render() + expect(view.container.querySelector('[data-read]')).toBeNull() + }) + + it('a running read renders the summary row alone (no result view yet)', () => { + const view = render() + expect(view.container.querySelector('[data-read]')).toBeNull() + }) +}) + +describe('ReadRow keyed toolview', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + current: SID, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({ + callId: 'c1', toolName: 'read', block, openFile: vi.fn(), + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the file path summary and the resident read card', () => { + const view = render() + expect(view.getByText('Read')).toBeTruthy() + // The path appears twice: the row summary link and the card's banner label. + expect(view.getAllByText('src/a.ts').length).toBe(2) + expect(view.container.querySelector('[data-read]')).not.toBeNull() + expect(contentTexts(view.container)).toContain('export const a = 1') + expect(view.getByText('显示 3 / 180 行')).toBeTruthy() + }) + + it('the path summary opens the file through the host', () => { + const openFile = vi.fn() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'src/a.ts' })) + // The row derives the file path from args; the chat view resolves it against + // the cwd before this callback opens it, so the arg path is what arrives. + expect(openFile).toHaveBeenCalledWith('src/a.ts') + }) + + it('a running read renders the summary row alone, and its state', () => { + const view = render() + expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running') + expect(view.container.querySelector('[data-read]')).toBeNull() + }) + + it('an error read result shows the error state and no read card', () => { + const view = render() + expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error') + expect(view.container.querySelector('[data-read]')).toBeNull() + }) + + it('an interrupted read shows the stopped state', () => { + const view = render() + expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped') + }) + + it('registers under the read key of the keyed toolview slot', () => { + const registered: { name: unknown; key?: unknown }[] = [] + const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context + readToolview.apply(ctx) + expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }]) + expect(readToolview.inject).toContain('conversation') + }) +}) + +describe('DetailsPanel Output section (read)', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore(cwd === undefined + ? { ids: [], byId: {}, current: undefined, phase: 'ready' } + : { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + current: SID, + phase: 'ready', + }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' } + + it('renders the read card at full height, keeping the JSON Input section', () => { + const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` })) + const view = mount(snapshot({ + nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })], + }), target) + expect(view.getByText(/"path"/)).toBeTruthy() + expect(view.container.querySelector('[data-read]')).not.toBeNull() + // The panel takes the primitive's own default cap (16), not the row's. + expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy() + expect(contentTexts(view.container)).toContain('row-0') + }) + + it('a non-read result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, + content: [{ type: 'text', text: 'plain result' }], + })], + }), target) + expect(view.container.querySelector('[data-read]')).toBeNull() + expect(view.getByText('Output').closest('section')?.querySelector('pre')?.textContent).toBe('plain result') + }) + + it('a running read keeps the 运行中… placeholder (no result view)', () => { + const view = mount(snapshot({ runningCalls: [running()] }), target) + expect(view.getByText('运行中…')).toBeTruthy() + expect(view.container.querySelector('[data-read]')).toBeNull() + }) +}) diff --git a/packages/client/ui-primitives/src/ReadBlock.module.css b/packages/client/ui-primitives/src/ReadBlock.module.css new file mode 100644 index 0000000000..a18afe6152 --- /dev/null +++ b/packages/client/ui-primitives/src/ReadBlock.module.css @@ -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); +} diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx new file mode 100644 index 0000000000..df9658e3dd --- /dev/null +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -0,0 +1,156 @@ +// 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 } from 'react' +import clsx from 'clsx' +import { writeClipboard } from './clipboard.ts' +import { highlightLines, 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.text}) +} + +/** + * 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]) + // Per-line highlighted runs aligned 1:1 with `lines`; undefined for an + // unknown/absent language, when every line renders as bare text. + const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang]) + 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]) => ( +
+ {line.number} + {spans === undefined ? line.text : renderSpans(spans)} +
+ )) + + // 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 ( +
+
+
{label ?? ''}
+
+ {windowed && ( + {`显示 ${lines.length} / ${totalLines} 行`} + )} + {lang ?? ''} + +
+
+
+ {rows(capped ? paired.slice(0, headLines) : paired)} + {hidden > 0 && ( + + )} + {capped && rows(paired.slice(paired.length - tailLines))} +
+
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..362c028a5b 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx' +export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 1fa50f6d2f..74709d4ac2 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -17,6 +17,7 @@ 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' /** * Language ids (and aliases) the singleton registers; everything else renders @@ -80,3 +81,42 @@ export function highlightToHtml(code: string, lang: string | undefined): string if (resolved === undefined) 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 `` 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-`
` {@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 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
+  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
+    ? tokens.slice(0, -1)
+    : tokens
+  return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
+}
diff --git a/packages/client/ui-primitives/tests/read-block.spec.tsx b/packages/client/ui-primitives/tests/read-block.spec.tsx
new file mode 100644
index 0000000000..339fd2d71e
--- /dev/null
+++ b/packages/client/ui-primitives/tests/read-block.spec.tsx
@@ -0,0 +1,215 @@
+// @vitest-environment jsdom
+// ReadBlock + the highlightLines token path: the banner (label, language, the
+// "showing N of M" note only when the read is a window, copy control), the
+// gutter-numbered rows keeping the file's own line numbers, the shiki per-line
+// highlighting resolved to css-variables token spans with an identical-geometry
+// plain fallback for an unknown/absent language, the head/tail height cap and
+// its expand control, and the copy control writing the raw window text on both
+// the accepted and refused clipboard paths.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
+import { highlightLines } from '../src/markdown/highlight.ts'
+
+afterEach(cleanup)
+
+beforeEach(() => {
+  vi.useRealTimers()
+})
+
+/** `count` lines starting at `first`, each with distinct text. */
+function lines(count: number, first = 1): ReadBlockLine[] {
+  return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
+}
+
+/** The rendered rows as `` strings (CSS-module class prefix). */
+function rowTexts(container: HTMLElement): string[] {
+  return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
+}
+
+/** The gutter numbers of the rendered rows, in order. */
+function gutters(container: HTMLElement): string[] {
+  return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
+}
+
+describe('highlightLines', () => {
+  it('tokenizes a registered grammar into per-line css-variables runs', () => {
+    const result = highlightLines('const x = 1\n// c', 'ts')
+    expect(result).not.toBeUndefined()
+    expect(result).toHaveLength(2)
+    // The keyword run carries a color style through a --shiki-* custom property.
+    const keyword = result![0]!.find(span => span.text === 'const')
+    expect(keyword?.style?.color).toContain('var(--shiki-')
+    // Whitespace between tokens is a run of its own; the comment is line two.
+    expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
+    expect(result![1]!.map(span => span.text).join('')).toBe('// c')
+  })
+
+  it('colors every run through a --shiki-* custom property', () => {
+    // The css-variables theme colors even the whitespace run (as the foreground
+    // token), so every run is a styled span; the plain fallback is the whole
+    // unknown-language path, not a per-run one.
+    const result = highlightLines('const x = 1', 'ts')
+    for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
+  })
+
+  it('drops the trailing terminator line so the run count matches the source lines', () => {
+    // `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
+    // line array has one entry, so the terminator line is dropped.
+    const result = highlightLines('const a = 1\n', 'ts')
+    expect(result).toHaveLength(1)
+  })
+
+  it('keeps a genuinely blank final line when the source ends in two newlines', () => {
+    const result = highlightLines('a\n\n', 'ts')
+    expect(result).toHaveLength(2)
+    expect(result![1]).toEqual([])
+  })
+
+  it('returns undefined for an unknown or absent language', () => {
+    expect(highlightLines('x', 'cobol')).toBeUndefined()
+    expect(highlightLines('x', undefined)).toBeUndefined()
+  })
+})
+
+describe('ReadBlock rows', () => {
+  it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
+    const view = render()
+    expect(gutters(view.container)).toEqual(['41', '42', '43'])
+    expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
+  })
+
+  it('highlights the content for a known language into token spans', () => {
+    const view = render(
+      ,
+    )
+    const content = view.container.querySelector('[class^="_content_"]')
+    expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
+    expect(content?.textContent).toBe('const a = 1')
+  })
+
+  it('renders the content as bare text with no span wrappers for an unknown language', () => {
+    const view = render(
+      ,
+    )
+    const content = view.container.querySelector('[class^="_content_"]')
+    expect(content?.querySelectorAll('span').length).toBe(0)
+    expect(content?.textContent).toBe('IDENT DIVISION.')
+  })
+
+  it('renders bare text when no language is given', () => {
+    const view = render()
+    const content = view.container.querySelector('[class^="_content_"]')
+    expect(content?.querySelectorAll('span').length).toBe(0)
+    expect(view.getByText('plain')).toBeTruthy()
+  })
+})
+
+describe('ReadBlock banner', () => {
+  it('shows the label, the language, and the count note when the read is a window', () => {
+    const view = render()
+    expect(view.getByText('src/a.ts')).toBeTruthy()
+    expect(view.getByText('ts')).toBeTruthy()
+    expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
+  })
+
+  it('omits the count note when the window is the whole file', () => {
+    const view = render()
+    expect(view.queryByText(/显示/u)).toBeNull()
+  })
+
+  it('draws an empty label and empty language when neither is given', () => {
+    const view = render()
+    expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
+    expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
+  })
+})
+
+describe('ReadBlock height cap', () => {
+  it('renders every line and no expand control under the cap', () => {
+    const view = render()
+    expect(rowTexts(view.container)).toHaveLength(4)
+    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
+  })
+
+  it('slices head and tail over the cap and expands on click', () => {
+    const view = render()
+    // maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
+    expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
+    const toggle = view.getByRole('button', { name: '展开其余 6 行' })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(toggle.textContent).toBe('… 其余 6 行')
+
+    fireEvent.click(toggle)
+    expect(rowTexts(view.container)).toHaveLength(10)
+    const collapse = view.getByRole('button', { name: '收起内容' })
+    expect(collapse.getAttribute('aria-expanded')).toBe('true')
+    expect(collapse.textContent).toBe('收起')
+
+    fireEvent.click(collapse)
+    expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
+  })
+
+  it('renders the head slice alone when the cap leaves no tail', () => {
+    const view = render()
+    expect(gutters(view.container)).toEqual(['1'])
+    expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
+  })
+
+  it('caps at the documented default when maxLines is absent', () => {
+    const view = render(
+      ,
+    )
+    expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
+    expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
+  })
+})
+
+describe('ReadBlock copy', () => {
+  it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
+    vi.useFakeTimers()
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    render()
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
+    await act(async () => {
+      await Promise.resolve()
+    })
+    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
+    // While the ok label is showing, further clicks are no-ops.
+    fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
+    expect(writeText).toHaveBeenCalledTimes(1)
+    await vi.advanceTimersByTimeAsync(1000)
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
+  })
+
+  it('copies the whole window while the height cap hides its middle', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    render()
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
+    expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
+  })
+
+  it('does not claim success when the host refuses the write', async () => {
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
+    })
+    render()
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    await act(async () => {
+      await Promise.resolve()
+    })
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
+  })
+
+  it('merges className onto the wrapper', () => {
+    const view = render()
+    expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
+  })
+})