feat(fs): add a read render-intent card for the read tool result

The read tool's result carries structured numbered lines, but only the
model-facing envelope text reached the client. Add a card:'read' result view
(ReadResultView) projecting {path, lines, totalLines, lang} through the tool's
output.presentationMeta so presentResult reproduces it on live and replay
paths; the pending call stays a generic read card. A UI without the read
capability falls back to the envelope-stripped content, so the TUI is
unchanged. The web consumer that renders the line-numbered view is a follow-up.
This commit is contained in:
Chinesezjc
2026-07-30 17:03:05 +08:00
parent 69214b4708
commit eb4cc8efc5
9 changed files with 389 additions and 11 deletions
+77
View File
@@ -168,3 +168,80 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome):
${body}
</content>`
}
/**
* 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<Record<string, string>> = {
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<string, unknown>
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<string, unknown>
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 } }
}
+32 -4
View File
@@ -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 = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\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