The Note's pre-release-stance link used the wrong depth and target (../../../CLAUDE.md); point it at ../../../../AGENTS.md with the section anchor so verify-md-links passes. The presentResult decline test's meta lacked the now-required offset, so it declined at meta narrowing instead of exercising the content-shape decline (read.ts:181-183); add offset back.
12 KiB
Agent Note: Read card — the read tool's structured line window reaches the client
Status: implemented
English | 中文
Problem
The read tool returns a canonical output object { path, offset, lines: [{ number, text }], totalLines }, but its presentation collapsed that structure. presentCall declared a GenericCallView (kind: 'read', a follow-along location) and presentResult returned a GenericResultView whose only content was the model-facing text with its <path>…</path><type>file</type><content>…</content> envelope stripped. A UI receiving that view saw one flattened text block: the line numbers were baked into the text as N: prefixes, the file's language was unknown, and totalLines was gone. There was no way for a capable client to render a read the way it renders a diff — a line-numbered, syntax-highlighted code view with the line-number gutter separate from the content.
The structured data cannot be recovered downstream. A tool result on the wire carries only the model-facing ContentBlock[] (the rendered text) plus an opaque meta; the canonical output object stays in the tool and never reaches the client or the session log. So a client that wants the line array, the total, and a language hint cannot parse them back out of the N: text text — the tool has to project them onto a channel that persists.
Decision
Add a fourth card tag, read, to the render-intent union — result-side only. ToolResultView gains ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }; ReadFileLine { number; text } is the shared line unit. ToolCallView is untouched: the pending state stays a GenericCallView (kind: 'read') because a call carries no file content until execute returns, so there is nothing structured to show at call time. This diverges from the bash terminal card, which tags both sides — a terminal call already carries its command and cwd at call time, a read call carries neither content nor total, so tagging the call side would add an empty variant.
The read tool projects the structured window through output.presentationMeta, the same persisted channel write/edit use for their applied-diff hunks (canonical tool output contract). presentationMeta runs once for a top-level surface call, returns { path, offset, lines, totalLines, lang? } as JSON the session validates and stores on the result's meta, and presentResult narrows that meta back into the ReadResultView on both live and replay paths. offset (the 1-based first line the window requested) rides along because a byte cap below the first selected line yields an empty lines array with a positive totalLines; without the persisted offset a replayed card of such a window could not report where it starts or where a continuation resumes, and the last-line and re-parse fallbacks are both lossy. Without this channel the line array and total would be unreachable: the raw output object is not on the wire, and re-parsing the N: text text is lossy and fragile against the truncation footer.
presentResult returns undefined — the generic fallback — whenever the meta is absent or malformed (readMetaFromMeta narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted meta, recorded before this card existed — takes that same undefined path deliberately: the client falls back to the raw result.content, so it shows the enveloped <path>/<type>/<content> text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the pre-release stance: reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path presentResult carries content (the envelope-stripped text) alongside the structured fields, so a UI without the read capability, including the current TUI, renders the file text through the generic/default card arm exactly as before. The TUI's renderBody switch (packages/ui/tui/src/components/transcript.ts) is not assertNever-exhaustive: terminal and diff have arms and everything else falls through to the generic arm, which reads view.content. That default arm alone was not enough: render() sets genericContent — and with it the dim-Markdown dimBody treatment — on a separate gate that was card === 'generic' only, so a read card would have kept the text but lost its dim styling. The gate now admits card: 'read' too, taking content down the same dim-Markdown path, so a read renders in the TUI exactly as it did before the read card existed. Beyond that one gate the TUI needs no read-specific code.
Language hint derivation
langFromPath (in read-render.ts) maps a file extension to a syntax-highlighting language id through a small fixed table (LANG_BY_EXTENSION) covering common source, config, and markup extensions. It reads the extension after the last path segment and last dot, is case-insensitive, and returns undefined for a dotfile (.gitignore), an extensionless name (/etc/hosts), a trailing dot, and any unknown extension — the card then omits lang and a UI renders plain text. The table is not a tunable: it is a display hint a UI may ignore, not a deployment-varying choice, and an unknown extension degrades to plain text rather than failing. It is deliberately small rather than an exhaustive language registry; extending it is a one-line table addition.
Alternatives considered
Re-parse the N: text model-facing text in presentResult. Rejected: the structured line array would have to be reconstructed by splitting each line on the first : , which is ambiguous (a line whose own text contains : ), loses the exact totalLines (the footer only states it in some branches), and breaks the moment the render format changes. presentationMeta carries the already-structured data with no re-parse.
Tag the call side too (ReadCallView), mirroring the terminal card's both-sides symmetry. Rejected: a read call has no content, no line array, and no total until it executes — a call-side read card would be an empty variant duplicating what GenericCallView (kind: 'read', follow-along location) already expresses. The terminal card tags both sides because a terminal call genuinely carries call-time data (command, cwd); a read call does not.
Put the structured window in a new service or a side channel instead of meta. Rejected: meta is the established persisted presentation channel (write/edit's applied diffs ride it), it replays for free with the session log, and it needs no new plumbing. A service would reinvent persistence and replay that the event log already provides.
A merge-extensible union instead of a closed tag. Rejected for the same reason the render-intent union closed: a new card needs consuming code to render it, so a variant a consumer silently drops is worse than a compile error. Adding read to the closed union is the sanctioned way to extend it — each consumer that switches on card keeps compiling because the new member falls through its generic default, and a consumer that wants the rich view adds its own arm.
Consequences
ToolResultView has a fourth member. Every consumer that switches on card keeps compiling: the TUI and the current Web client route an unknown card to their generic path, and the read card carries content so that path shows the file text. The Web frontend that renders the line-numbered, syntax-highlighted view from lines/lang/totalLines is a separate follow-up PR; this PR is the backend that makes the data reachable. Until that lands, a read renders exactly as it did before (the generic text card) everywhere.
The read tool now computes presentationMeta for every top-level read, a small per-call projection (a lines.map and one langFromPath call) on data already in hand. The meta is persisted with the session log, so a read result is slightly larger on disk — the line array it already rendered as text, now also structured.
Testing
packages/fs/tool-fs/tests/read-render.spec.ts unit-tests langFromPath (known extensions case-insensitively, extension read after the last segment and last dot, and the undefined cases: dotfile, extensionless, trailing dot, unknown) and readMetaFromMeta (a well-formed narrow with and without lang, and every rejection: non-object, array, missing or wrong-typed path/totalLines/lines, a malformed line entry, a non-string lang, and — because the function narrows the opaque persisted meta boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an offset that is not a 1-based integer, a first line number below offset, a line number that is not a 1-based integer (0, 1.5, NaN, Infinity), a totalLines that is not a non-negative integer (-1, 1.5, NaN), and lines whose numbers duplicate, decrease, or exceed totalLines; it also narrows an empty window at a positive offset (a byte cap below the first selected line). packages/fs/tool-fs/tests/tools.spec.ts pins the tool wiring: execute attaches the structured window (with and without a lang hint) as meta, presentResult narrows it into a card: 'read' view carrying the envelope-stripped content, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to undefined. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (fs-read, fs-read-window, fs-edit, fs-policy-reject, fs-write-overwrite, parallel-tool-calls, workspace-context, workspace-edit) pin the persisted read meta (with {{cwd}}-tokenized paths), and cordis-inspect-jsdoc pins the four-member ToolResultView union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (transcript.ts treats card: 'read' like card: 'generic'), so its output is unchanged. The apps/cli parallel-file-reads terminal golden (apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt) pins exactly that: a real replay executes the read tool, renders it through the new card: 'read' gate, and the golden's dim-Markdown rows are byte-for-byte what a generic read produced before this card existed.
Related
- Tagged render-intent union for tool-call presentation — the
card-tagged vocabulary this extends with thereadresult arm. - Canonical tool output contract — owns the
presentationMetapersisted channel this projects the read window onto. - Web terminal card — the precedent for a client consuming a structured card; the read card follows the same producer pattern, result-side only.