Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
2026-08-12 02:16:39 +08:00

12 KiB

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

Status: implemented

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.