DSH / Atlas
2026-07-30implementedfeature

Read card — the read tool's structured line window reaches the client

Read card — the read tool's structured line window reaches the client

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

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 change 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 renders the file text through its generic/default card arm. The former TUI established the need for this fallback: its non-exhaustive result switch read view.content, while a separate dim-Markdown gate also had to admit card: 'read'. That frontend has since been removed, but the content fallback remains part of the view contract for any consumer without a structured read card.

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. A consumer may render the structured lines/lang/totalLines shape or route an unsupported card to its generic path; the read card carries content so the latter still shows the file text. This producer change is the backend that makes the structured data reachable without requiring every consumer to implement the richer view at once.

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 change 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, agent-instructions, workspace-edit) pin the persisted read meta (with {{cwd}}-tokenized paths), and cordis-inspect-jsdoc pins the four-member ToolResultView union. The then-current terminal snapshot also pinned that a consumer's generic dim-Markdown fallback stayed byte-identical; the structured card's own assembled-application transcript belonged to its consuming frontend change.

Related

中文

问题

read 工具返回规范化输出对象 { path, offset, lines: [{ number, text }], totalLines },但它的展示层把这个结构压平了。presentCall 声明为 GenericCallViewkind: 'read',一个跟随定位),presentResult 返回 GenericResultView,其唯一内容是剥掉 <path>…</path><type>file</type><content>…</content> 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 N: 前缀烘焙进文本、文件语言未知、totalLines 丢失。有相应能力的客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号栏与内容分离的代码视图。

结构化数据在下游无法恢复。线上(wire)的工具结果只携带面向模型的 ContentBlock[](已渲染文本)加上一个不透明的 meta;规范化输出对象留在工具内,从不到达客户端或会话日志。因此想要行数组、总数和语言提示的客户端无法从 N: text 文本里解析回它们——工具必须把它们投影到一个会持久化的通道上。

决策

渲染意图 union 新增第四个 card 标签 read——仅在结果侧。ToolResultView 增加 ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }ReadFileLine { number; text } 是共享的行单元。ToolCallView 不动:待定状态仍是 GenericCallViewkind: 'read'),因为一次调用在 execute 返回前不携带文件内容,调用时没有可展示的结构。这与 bash 终端 card 不同——终端 card 两侧都打标签,因为终端调用在调用时已携带命令和 cwd,而 read 调用既无内容也无总数,给调用侧打标签只会新增一个空变体。

read 工具通过 output.presentationMeta 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同(规范化工具输出约定)。presentationMeta 对一次顶层 surface 调用运行一次,返回 { path, offset, lines, totalLines, lang? } 作为会话校验并存储在结果 meta 上的 JSON,presentResult 在实时和回放路径上都把该 meta 收窄回 ReadResultViewoffset(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 lines 数组而 totalLines 为正;没有持久化的 offset,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 N: text 文本既有损又对截断尾注脆弱。

presentResult 在以下情况返回 undefined——即 generic 回退:meta 缺失或畸形(readMetaFromMeta 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 meta——有意走同一条 undefined 路径:客户端回退到原始 result.content,因此显示带 <path>/<type>/<content> 信封的原文,而非旧展示器返回的剥信封 generic card。这是 pre-release 立场下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本变更已重录全部已发布 fixture(测试前置数据),且会话格式不承诺向后兼容。在成功路径上,presentResult 在结构化字段之外携带 content(剥信封后的文本),因此不具备 read 能力的 UI 会通过自己的 generic/default card 分支渲染文件文本。原 TUI 证明了这条回退的必要性:它的非穷尽结果 switch 读取 view.content,而另一道 dim-Markdown 门控也必须接纳 card: 'read'。该前端随后被移除,但对任何没有结构化 read 卡片的消费方而言,content 回退仍是视图约定的一部分。

语言提示推导

langFromPath(在 read-render.ts 中)通过一张固定小表(LANG_BY_EXTENSION,覆盖常见源码、配置、标记扩展名)把文件扩展名映射到语法高亮语言 id。它读取最后一个路径段与最后一个点之后的扩展名,大小写不敏感,并对以下情况返回 undefined:dotfile(.gitignore)、无扩展名(/etc/hosts)、结尾的点、以及任何未知扩展名——此时 card 省略 lang,UI 渲染纯文本。该表不是可调项(tunable):它是 UI 可忽略的展示提示,而非随部署变化的选择,未知扩展名降级为纯文本而非失败。它有意保持小规模而非穷尽的语言注册表;扩展它只需新增一行表项。

考虑过的替代方案

presentResult 中重新解析 N: text 面向模型文本。 已否决:结构化行数组将不得不通过按第一个 : 切分每行来重建,这既有歧义(某行文本自身含 : ),又丢失精确的 totalLines(脚注只在部分分支中陈述它),并在渲染格式变化时立即失效。presentationMeta 携带已经结构化的数据,无需重新解析。

调用侧也打标签(ReadCallView),镜像终端 card 的两侧对称。 已否决:read 调用在执行前没有内容、没有行数组、没有总数——调用侧 read card 会是一个空变体,重复 GenericCallViewkind: 'read',跟随定位)已经表达的东西。终端 card 两侧都打标签是因为终端调用确实携带调用时数据(命令、cwd);read 调用没有。

把结构化窗口放进新服务或旁路通道而非 meta 已否决:meta 是既有的持久化展示通道(write/edit 的应用 diff 也经由该通道传递),它随会话日志免费回放,无需新接线。服务会重新发明事件日志已提供的持久化与回放。

用 merge-extensible union 而非封闭标签。 出于渲染意图 union 封闭的相同理由否决:新 card 需要消费代码来渲染它,因此被消费方静默丢弃的变体比编译错误更糟。把 read 加入封闭 union 是扩展它的许可方式——每个在 card 上 switch 的消费方都继续编译,因为新成员落入其 generic default,而想要富视图的消费方新增自己的分支。

影响

ToolResultView 多了第四个成员。消费方可以渲染结构化的 lines/lang/totalLines 形状,也可以将不支持的 card 路由到 generic 路径;read card 携带 content,所以后者仍会显示文件文本。本次生产者变更是让结构化数据可触及的后端,无需每个消费方同时实现更丰富的视图。

read 工具现在为每次顶层 read 计算 presentationMeta,这是对已有数据的一次小投影(一次 lines.map 和一次 langFromPath 调用)。meta 随会话日志持久化,因此 read 结果在磁盘上略大——它已渲染为文本的行数组,现在也以结构化形式存在。

测试

packages/fs/tool-fs/tests/read-render.spec.ts 单测 langFromPath(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 undefined 各情况:dotfile、无扩展名、结尾的点、未知)与 readMetaFromMeta(含与不含 lang 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 path/totalLines/lines、畸形行项、非字符串 lang,以及——因为该函数收窄不透明的持久化 meta 边界——类型正确的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 offset、小于 offset 的首行 number、不是 1-based 整数的行 number01.5NaNInfinity)、不是非负整数的 totalLines-11.5NaN)、以及行号重复、递减或超过 totalLines 的情况;并且收窄正 offset 处的空窗口(字节上限低于首个选中行))。packages/fs/tool-fs/tests/tools.spec.ts 固定工具接线:execute 把结构化窗口(含与不含 lang 提示)作为 meta 附上、presentResult 把它收窄为携带剥信封 contentcard: 'read' 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 undefined。两个改动的源文件保持逐文件 100% 覆盖率。本变更携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP(Agent Client Protocol)会话 fixture(fs-readfs-read-windowfs-editfs-policy-rejectfs-write-overwriteparallel-tool-callsagent-instructionsworkspace-edit)钉住持久化的读取 meta(含 {{cwd}} 令牌化路径),cordis-inspect-jsdoc 钉住四成员的 ToolResultView 联合类型。当时的终端快照还钉住了消费方的 generic dim-Markdown 回退保持逐字节一致;结构化卡片自身的组装应用 transcript(文本记录)则属于消费它的前端变更。

相关文档