diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
new file mode 100644
index 0000000000..baf06160ca
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.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.md
+2026-07-30-web-read-card.md: 48cd317c3a90580c63e3162810de6ca38552ca21
+2026-07-30-web-read-card.zh.md: a7246be272cbbecfa71b0f4958ef0c858ca6d976
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md
new file mode 100644
index 0000000000..48cd317c3a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md
@@ -0,0 +1,49 @@
+# Agent Note: Read card — the read tool's structured line window reaches the client
+
+Status: implemented
+
+English | [中文](2026-07-30-web-read-card.zh.md)
+
+## 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 `…file…` 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](../architecture/2026-07-02-tool-render-intent-union.md) — 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](../architecture/2026-07-20-canonical-tool-output-contract.md)). `presentationMeta` runs once for a top-level surface call, returns `{ path, 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. 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. On the success path it 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` and the optional `title`. A `ReadResultView` satisfies that arm unchanged, so the TUI needs no new code and its output is unchanged.
+
+### 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](../architecture/2026-07-02-tool-render-intent-union.md) 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, and a non-string `lang`). `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. A keyless snapshot and the assembled-application transcript for the rendered card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering.
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `read` result arm.
+- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — owns the `presentationMeta` persisted channel this projects the read window onto.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent for a client consuming a structured card; the read card follows the same producer pattern, result-side only.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
new file mode 100644
index 0000000000..a7246be272
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: Read card — the read tool's structured line window reaches the client
+
+Status: implemented
+
+[English](2026-07-30-web-read-card.md) | 中文
+
+## 问题
+
+`read` 工具返回规范化输出对象 `{ path, offset, lines: [{ number, text }], totalLines }`,但它的展示层把这个结构压平了。`presentCall` 声明为 `GenericCallView`(`kind: 'read'`,一个跟随定位),`presentResult` 返回 `GenericResultView`,其唯一内容是剥掉 `…file…` 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 `N: ` 前缀烘焙进文本、文件语言未知、`totalLines` 丢失。capable 客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号槽与内容分离的代码视图。
+
+结构化数据在下游无法恢复。线上(wire)的工具结果只携带面向模型的 `ContentBlock[]`(已渲染文本)加上一个不透明的 `meta`;规范化输出对象留在工具内,从不到达客户端或会话日志。因此想要行数组、总数和语言提示的客户端无法从 `N: text` 文本里解析回它们——工具必须把它们投影到一个会持久化的通道上。
+
+## 决策
+
+给[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 新增第四个 `card` 标签 `read`——仅在结果侧。`ToolResultView` 增加 `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`;`ReadFileLine { number; text }` 是共享的行单元。`ToolCallView` 不动:待定状态仍是 `GenericCallView`(`kind: 'read'`),因为一次调用在 `execute` 返回前不携带文件内容,调用时没有可展示的结构。这与 bash 终端 card 不同——终端 card 两侧都打标签,因为终端调用在调用时已携带命令和 cwd,而 read 调用既无内容也无总数,给调用侧打标签只会新增一个空变体。
+
+read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同([规范化工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用运行一次,返回 `{ path, lines, totalLines, lang? }` 作为会话校验并存储在结果 `meta` 上的 JSON,`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView`。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 `N: text` 文本既有损又对截断脚注脆弱。
+
+`presentResult` 在以下情况返回 `undefined`——即 generic 回退:meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。在成功路径上,它在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI(包括当前的 TUI)通过 generic/default card 分支渲染文件文本,与之前完全一致。TUI 的 `renderBody` switch(`packages/ui/tui/src/components/transcript.ts`)不是 `assertNever` 穷尽的:`terminal` 和 `diff` 有分支,其余都落入 generic 分支,该分支读取 `view.content` 与可选的 `title`。`ReadResultView` 原样满足该分支,因此 TUI 无需新代码、输出不变。
+
+### 语言提示推导
+
+`langFromPath`(在 `read-render.ts` 中)通过一张固定小表(`LANG_BY_EXTENSION`,覆盖常见源码、配置、标记扩展名)把文件扩展名映射到语法高亮语言 id。它读取最后一个路径段与最后一个点之后的扩展名,大小写不敏感,并对以下情况返回 `undefined`:dotfile(`.gitignore`)、无扩展名(`/etc/hosts`)、结尾的点、以及任何未知扩展名——此时 card 省略 `lang`,UI 渲染纯文本。该表不是可调项(tunable):它是 UI 可忽略的展示提示,而非随部署变化的选择,未知扩展名降级为纯文本而非失败。它有意保持小规模而非穷尽的语言注册表;扩展它是一行表项新增。
+
+## Alternatives considered
+
+**在 `presentResult` 中重新解析 `N: text` 面向模型文本。** 已否决:结构化行数组将不得不通过按第一个 `: ` 切分每行来重建,这既有歧义(某行文本自身含 `: `),又丢失精确的 `totalLines`(脚注只在部分分支中陈述它),并在渲染格式变化时立即失效。`presentationMeta` 携带已经结构化的数据,无需重新解析。
+
+**调用侧也打标签(`ReadCallView`),镜像终端 card 的两侧对称。** 已否决:read 调用在执行前没有内容、没有行数组、没有总数——调用侧 read card 会是一个空变体,重复 `GenericCallView`(`kind: 'read'`,跟随定位)已经表达的东西。终端 card 两侧都打标签是因为终端调用确实携带调用时数据(命令、cwd);read 调用没有。
+
+**把结构化窗口放进新服务或旁路通道而非 `meta`。** 已否决:`meta` 是既有的持久化展示通道(write/edit 的应用 diff 就搭它),它随会话日志免费回放,无需新接线。服务会重新发明事件日志已提供的持久化与回放。
+
+**用 merge-extensible union 而非封闭标签。** 出于[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 封闭的相同理由否决:新 card 需要消费代码来渲染它,因此被消费者静默丢弃的变体比编译错误更糟。把 `read` 加入封闭 union 是扩展它的许可方式——每个在 `card` 上 switch 的消费者都继续编译,因为新成员落入其 generic default,而想要富视图的消费者新增自己的分支。
+
+## Consequences
+
+`ToolResultView` 多了第四个成员。每个在 `card` 上 switch 的消费者都继续编译:TUI 和当前 Web 客户端把未知 card 路由到其 generic 路径,而 read card 携带 `content` 使该路径显示文件文本。从 `lines`/`lang`/`totalLines` 渲染带行号、语法高亮视图的 Web 前端是单独的后续 PR;本 PR 是让数据可触及的后端。在它落地前,read 在各处的渲染与之前完全一致(generic 文本 card)。
+
+read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已在手数据的一次小投影(一次 `lines.map` 和一次 `langFromPath` 调用)。meta 随会话日志持久化,因此 read 结果在磁盘上略大——它已渲染为文本的行数组,现在也以结构化形式存在。
+
+## Testing
+
+`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、以及非字符串 `lang`)。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。已渲染 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染。
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 Note 以 `read` 结果分支扩展的 `card` 标签词汇。
+- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 拥有本 Note 用来投影 read 窗口的 `presentationMeta` 持久化通道。
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 客户端消费结构化 card 的先例;read card 遵循相同的生产者模式,仅结果侧。
diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts
index 2caaaa8276..1c6dbf29bc 100644
--- a/packages/core/tools/src/index.ts
+++ b/packages/core/tools/src/index.ts
@@ -74,6 +74,7 @@ export type {
ToolCallKind,
FileLocation,
FileDiff,
+ ReadFileLine,
ToolCallView,
GenericCallView,
TerminalCallView,
@@ -82,6 +83,7 @@ export type {
GenericResultView,
TerminalResultView,
DiffResultView,
+ ReadResultView,
} from './presentation.ts'
declare module 'cordis' {
diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts
index 17b88b822f..f553442d0a 100644
--- a/packages/core/tools/src/presentation.ts
+++ b/packages/core/tools/src/presentation.ts
@@ -117,6 +117,18 @@ export interface DiffCallView {
locations?: FileLocation[]
}
+/**
+ * One numbered line of a file, the unit a {@link ReadResultView} carries so a
+ * capable UI can render a syntax-highlighted, line-numbered code view. `number`
+ * is the 1-based line number in the file (a window past `offset` keeps the file's
+ * own numbering, not a 1-based re-count); `text` is the line without its trailing
+ * newline, already truncated to the read tool's per-line cap.
+ */
+export interface ReadFileLine {
+ number: number
+ text: string
+}
+
/**
* How a tool wants the COMPLETED call shown — the *result* state, after `execute`
* returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
@@ -125,7 +137,7 @@ export interface DiffCallView {
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
-export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
+export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView
/**
* The default completed card: an optional replacement title and reformatted
@@ -176,3 +188,38 @@ export interface DiffResultView {
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
diffs: FileDiff[]
}
+
+/**
+ * A completed file read rendered as a line-numbered, optionally syntax-highlighted
+ * code view by a capable UI. Set by a tool whose call reads file text (e.g.
+ * `read`); the pending state stays a {@link GenericCallView} (`kind: 'read'`)
+ * because a call carries no content until `execute` returns. The structured
+ * `lines`/`path`/`lang`/`totalLines` fields cannot be reconstructed from the
+ * model-facing result text alone, so the read tool projects them through its
+ * `output.presentationMeta` (persisted with the session log) and `presentResult`
+ * narrows that metadata back into this view on live and replay paths alike. A UI
+ * without the read capability falls back to `content` (the model-facing text with
+ * its envelope stripped), so this view degrades to the generic text card.
+ */
+export interface ReadResultView {
+ card: 'read'
+ /** Replacement title for the completed call. Omit to keep the pending-state title. */
+ title?: string
+ /** The read file's path (the model-facing path; the bridge relativizes it). */
+ path: string
+ /** The returned window's lines, in file order, each keeping its file line number. */
+ lines: ReadFileLine[]
+ /** Exact total line count in the file, so a UI can show a "showing N of M" affordance. */
+ totalLines: number
+ /**
+ * A syntax-highlighting language hint derived from the file extension (e.g.
+ * `ts`, `py`), or omitted when the extension maps to no known language so a UI
+ * renders the lines as plain text.
+ */
+ lang?: string
+ /**
+ * The model-facing result content with its envelope stripped, for a UI without
+ * the read capability. Omit to let such a UI render the raw result content.
+ */
+ content?: ContentBlock[]
+}
diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts
index 7e581bb22c..b2cd7cbbe0 100644
--- a/packages/fs/tool-fs/src/read-render.ts
+++ b/packages/fs/tool-fs/src/read-render.ts
@@ -168,3 +168,80 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome):
${body}
`
}
+
+/**
+ * Lowercased file-extension to syntax-highlighting language hint. Keys are the
+ * extension without its dot; a UI treats an absent key as plain text. The map is
+ * intentionally small — common source, config, and markup extensions a
+ * line-numbered code view benefits from highlighting — not an exhaustive registry.
+ */
+const LANG_BY_EXTENSION: Readonly> = {
+ ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts',
+ js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
+ json: 'json', jsonc: 'json',
+ py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java',
+ c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp',
+ cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php',
+ sh: 'sh', bash: 'sh', zsh: 'sh',
+ yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
+ md: 'md', markdown: 'md', mdx: 'mdx',
+ html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
+ sql: 'sql', xml: 'xml', lua: 'lua',
+}
+
+/**
+ * Derive a syntax-highlighting language hint from a read path's file extension.
+ * Pure and case-insensitive on the extension; a dotfile with no extension
+ * (`.gitignore`) and an unknown extension both yield `undefined`.
+ * @param path - the model-facing path the read reported.
+ * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none.
+ */
+export function langFromPath(path: string): string | undefined {
+ const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
+ const dot = base.lastIndexOf('.')
+ // A leading dot is a dotfile (no extension), not an empty extension.
+ if (dot <= 0) return undefined
+ return LANG_BY_EXTENSION[base.slice(dot + 1).toLowerCase()]
+}
+
+/**
+ * The `read` tool's private `tool/result` `meta` payload: the structured
+ * line-numbered window a capable UI renders as a code view. Attached opaquely (as
+ * `unknown`) on the tool result and persisted with the session log — it must be
+ * JSON-serializable (the session validates this at `append`), so `presentResult`
+ * reproduces the read card on replay when the raw structured output is no longer
+ * on the wire. The producing tool owns and narrows this opaque shape.
+ */
+export interface FsReadMeta {
+ /** The read file's model-facing path. */
+ path: string
+ /** The returned window's lines, each keeping its file line number. */
+ lines: FileTextLine[]
+ /** Exact total line count in the file. */
+ totalLines: number
+ /** Syntax-highlighting language hint from the extension, or omitted for plain text. */
+ lang?: string
+}
+
+/** Whether `value` is a valid {@link FileTextLine} (defensive narrowing from opaque `meta`). */
+function isFileTextLine(value: unknown): value is FileTextLine {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+ const { number, text } = value as Record
+ return typeof number === 'number' && typeof text === 'string'
+}
+
+/**
+ * Narrow opaque live or replayed result metadata to a structured read window.
+ * Malformed metadata returns `undefined` so presentation can fall back to the
+ * generic text card instead of throwing during replay.
+ * @param meta - result metadata.
+ * @returns the validated read window, or `undefined` for absent or malformed data.
+ */
+export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined {
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
+ const { path, lines, totalLines, lang } = meta as Record
+ if (typeof path !== 'string' || typeof totalLines !== 'number') return undefined
+ if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined
+ if (lang !== undefined && typeof lang !== 'string') return undefined
+ return { path, lines, totalLines, ...lang === undefined ? {} : { lang } }
+}
diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts
index 05e1b41ae2..2ce98ca86f 100644
--- a/packages/fs/tool-fs/src/read.ts
+++ b/packages/fs/tool-fs/src/read.ts
@@ -6,11 +6,11 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
-import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools'
+import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
-import { buildWindow, formatReadOutput } from './read-render.ts'
+import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts'
import { sessionResolveOptions } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
@@ -118,6 +118,18 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
}),
}]
},
+ // Project the structured window into persisted `meta` so a UI's read card
+ // survives replay: the raw canonical output object is not on the wire, only
+ // the model-facing text, from which the line/lang data cannot be recovered.
+ presentationMeta: (_args, value) => {
+ const lang = langFromPath(value.path)
+ return {
+ path: value.path,
+ lines: value.lines.map(({ number, text }) => ({ number, text })),
+ totalLines: value.totalLines,
+ ...lang === undefined ? {} : { lang },
+ }
+ },
},
// Observation races fail closed because guarded mutations re-check the version in-lock.
isConcurrencySafe: () => true,
@@ -154,15 +166,31 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
ctx.emit('fs/observed', target, info.version, exec)
return outcome
},
- presentResult(_args, result: ToolResult): GenericResultView | undefined {
+ // Result-time display: a `read` card carrying the structured line window a
+ // capable UI renders as a line-numbered, syntax-highlighted view. The
+ // structured data is narrowed from the persisted `meta` (replay-safe); the
+ // envelope-stripped model-facing text rides along as `content` so a UI without
+ // the read capability still shows the file text. A malformed or absent meta,
+ // or a result whose text is not the read envelope, declines to `undefined`
+ // (the generic fallback), never throwing on replay of obsolete logged output.
+ presentResult(_args, result: ToolResult): ReadResultView | undefined {
if (result.isError) return undefined
+ const meta = readMetaFromMeta(result.meta)
+ if (meta === undefined) return undefined
const only = result.content.length === 1 ? result.content[0] : undefined
const text = only?.type === 'text' ? only.text : undefined
if (text === undefined) return undefined
// Group 1 always captures (possibly empty) when the envelope matches.
const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
if (body === undefined) return undefined
- return { card: 'generic', content: [{ type: 'text', text: body }] }
+ return {
+ card: 'read',
+ path: meta.path,
+ lines: meta.lines,
+ totalLines: meta.totalLines,
+ ...meta.lang === undefined ? {} : { lang: meta.lang },
+ content: [{ type: 'text', text: body }],
+ }
},
// Pure display: a generic card titled by the file with the read window appended (`Read
// foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the
diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts
index c2afaf002e..462df231c2 100644
--- a/packages/fs/tool-fs/tests/read-render.spec.ts
+++ b/packages/fs/tool-fs/tests/read-render.spec.ts
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
-import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
+import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import type { ReadWindow } from '../src/read-render.ts'
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
@@ -116,3 +116,54 @@ describe('buildWindow', () => {
})
})
})
+
+describe('langFromPath', () => {
+ it('maps a known extension to its language hint, case-insensitively', () => {
+ expect(langFromPath('src/a.ts')).toBe('ts')
+ expect(langFromPath('src/a.TSX')).toBe('tsx')
+ expect(langFromPath('/abs/module.mjs')).toBe('js')
+ expect(langFromPath('conf.yml')).toBe('yaml')
+ expect(langFromPath('README.md')).toBe('md')
+ })
+
+ it('reads the extension after the last path segment and last dot', () => {
+ expect(langFromPath('a.py.bak')).toBeUndefined()
+ expect(langFromPath('archive.tar.gz')).toBeUndefined()
+ expect(langFromPath('/dir.py/plain')).toBeUndefined()
+ expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
+ })
+
+ it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
+ expect(langFromPath('.gitignore')).toBeUndefined()
+ expect(langFromPath('/etc/hosts')).toBeUndefined()
+ expect(langFromPath('data.unknownext')).toBeUndefined()
+ expect(langFromPath('trailingdot.')).toBeUndefined()
+ })
+})
+
+describe('readMetaFromMeta', () => {
+ const good = { path: '/abs/a.ts', lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
+
+ it('narrows a well-formed read meta, with and without a lang hint', () => {
+ expect(readMetaFromMeta(good)).toEqual(good)
+ const noLang = { path: '/abs/a', lines: [], totalLines: 0 }
+ expect(readMetaFromMeta(noLang)).toEqual(noLang)
+ })
+
+ it('returns undefined for absent, non-object, or array meta', () => {
+ expect(readMetaFromMeta(undefined)).toBeUndefined()
+ expect(readMetaFromMeta(null)).toBeUndefined()
+ expect(readMetaFromMeta('nope')).toBeUndefined()
+ expect(readMetaFromMeta([good])).toBeUndefined()
+ })
+
+ it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
+ expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
+ expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
+ })
+})
diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts
index de844dcaf4..4bf64cb8a5 100644
--- a/packages/fs/tool-fs/tests/tools.spec.ts
+++ b/packages/fs/tool-fs/tests/tools.spec.ts
@@ -320,6 +320,38 @@ describe('read tool', () => {
expect(text(result)).toContain('Output capped.')
})
+ it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => {
+ const { ctx, fs } = await setup()
+ fs.files.set('key:a.ts', 'const x = 1\nconst y = 2')
+ const result = await call(ctx, 'read', { file_path: 'a.ts' })
+ expect(result.isError).toBe(false)
+ if (result.isError) throw new Error('expected read success')
+ // The extension drives the lang hint; the window rides on persisted meta.
+ expect(result.meta).toEqual({
+ path: '/abs/a.ts',
+ lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
+ totalLines: 2,
+ lang: 'ts',
+ })
+ const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
+ expect(view).toEqual({
+ card: 'read',
+ path: '/abs/a.ts',
+ lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
+ totalLines: 2,
+ lang: 'ts',
+ content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
+ })
+ })
+
+ it('omits the lang hint in meta for an extension that maps to no language', async () => {
+ const { ctx, fs } = await setup()
+ fs.files.set('key:notes', 'plain')
+ const result = await call(ctx, 'read', { file_path: 'notes' })
+ if (result.isError) throw new Error('expected read success')
+ expect(result.meta).toEqual({ path: '/abs/notes', lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
+ })
+
})
describe('formatReadOutput footer variants', () => {
@@ -450,33 +482,70 @@ describe('tool-owned presentation (pure presentCall)', () => {
})
})
- it('read: completed presentation removes the model-facing XML envelope', async () => {
- expect(await presentResult('read', { file_path: 'a.txt' }, {
- content: [{ type: 'text', text: '/tmp/a.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }],
+ it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => {
+ // The structured line data rides on persisted meta (the raw output object is
+ // not on the wire); presentResult narrows it and appends the stripped text as
+ // the no-capability `content` fallback.
+ const meta = { path: '/tmp/a.ts', lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
+ content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }],
isError: false,
+ meta,
})).toEqual({
- card: 'generic',
+ card: 'read',
+ path: '/tmp/a.ts',
+ lines: [{ number: 1, text: 'hello' }],
+ totalLines: 1,
+ lang: 'ts',
content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
})
- expect(await presentResult('read', { file_path: 'a.txt' }, {
+ // A window whose extension maps to no language omits `lang` from the card.
+ expect(await presentResult('read', { file_path: 'notes' }, {
+ content: [{ type: 'text', text: '/tmp/notes\nfile\n\nbody\n' }],
+ isError: false,
+ meta: { path: '/tmp/notes', lines: [{ number: 1, text: 'body' }], totalLines: 1 },
+ })).toEqual({
+ card: 'read',
+ path: '/tmp/notes',
+ lines: [{ number: 1, text: 'body' }],
+ totalLines: 1,
+ content: [{ type: 'text', text: 'body' }],
+ })
+ // Malformed envelope text with valid meta still declines (the fallback text is unavailable).
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: 'malformed replay' }],
isError: false,
+ meta,
+ })).toBeUndefined()
+ // Valid envelope but absent/malformed meta declines to the generic fallback.
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
+ content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }],
+ isError: false,
+ })).toBeUndefined()
+ expect(await presentResult('read', { file_path: 'a.ts' }, {
+ content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }],
+ isError: false,
+ meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 },
})).toBeUndefined()
})
it('read: completed presentation declines errors and non-single-text content', async () => {
const envelope = '/tmp/a.txt\nfile\n\nbody\n'
+ const meta = { path: '/tmp/a.txt', lines: [{ number: 1, text: 'body' }], totalLines: 1 }
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }],
isError: true,
+ meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
isError: false,
+ meta,
})).toBeUndefined()
expect(await presentResult('read', { file_path: 'a.txt' }, {
content: [{ type: 'reasoning', text: envelope }],
isError: false,
+ meta,
})).toBeUndefined()
})