Replace the "bag of optional fields" tool-presentation types
(ToolCallPresentation / ToolResultPresentation / ToolTerminal) with a
card-tagged discriminated union — the standing FIXME(tool-presentation).
A tool declares one render intent per call/result and the ACP bridge
switches on `card`:
ToolCallView = generic | terminal | diff
ToolResultView = generic | terminal
The `diff` card is new: fs write/edit now emit an ACP {type:'diff'}
content block (an editor's inline diff), which the old shapes could not
express. The bridge also relativizes a file card's title against the
session cwd (mirroring claude-agent-acp's toDisplayPath) while keeping
locations/diff paths raw, and derives the no-capability fenced console
fallback from a terminal result's output. read gains the window-in-title
(`Read foo.txt (5 - 8)`) and an always-set location line, matching the
reference adapter field-for-field.
Migrates all three producer families (tool-fs, tool-bash, tool-todo) and
the sole consumer (the ACP bridge) together — the source does not compile
piecewise. Adds snapshot coverage for the terminal _meta path (a new
capability-advertising scenario) and re-records the fs goldens to show the
diff cards. Applied-hunk (result-time, context-line) diffs need a new
result/event shape and are a follow-up.
RFC: docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
7.8 KiB
RFC: Tagged render-intent union for tool-call presentation
Status: implemented
Problem
A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, presentCall/presentResult on ToolDefinition, returning ToolCallPresentation / ToolResultPresentation with an optional ToolTerminal sub-shape. These grew incrementally into a bag of optional fields: title, kind, rawInput, content, locations, terminal on the call; title, content, terminal on the result; cwd/output/exitCode/signal on ToolTerminal. The split of responsibility is muddy:
- The call-side and result-side
terminalfields overlap, and the bridge reconciles acontentblock AND aterminalblock ANDrawInputper call, stitching them together with ad-hoc conditionals. - Which combinations are valid is unwritten: a
terminalcall that also setscontentmeans "description above the card"; a generic call that setsterminalis meaningless but representable. The type permits nonsense. - There is no way to express the one file-tool affordance an editor most wants — a diff card (
{path, oldText, newText}, which Zed renders as an inline diff / new-file preview).ToolCallPresentation.contentis the LLMContentBlock[]vocabulary (text/image), so a tool literally cannot ask for a diff.
The existing FIXME(tool-presentation) in packages/core/tools/src/index.ts named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected RFC Collapse tool-owned UI presentation deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (dsh-tool-bash, dsh-tool-fs) and two consumers (the ACP bridge live path + the snapshot-golden replay path).
Decision
Replace the optional-field bag with a card-tagged discriminated union. A tool declares one render intent per call/result; the bridge switches on the tag.
type FileLocation = { path: string; line?: number }
type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file
// presentCall → ToolCallView
type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] }
interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string }
interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] }
// presentResult → ToolResultView
type ToolResultView = GenericResultView | TerminalResultView
interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] }
interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string }
card is required on every variant — a real discriminant, not an optional default. The bridge does switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }. The union is closed (per the switch-exhaustiveness convention): a fourth render intent (a table, a chart) needs new bridge code to render it anyway, so a plugin-added variant that the bridge silently drops would be worse than a compile error. Adding a variant breaks compilation at the bridge switch — exactly the signal we want.
Why a tagged union beats the field-bag
- Invalid states become unrepresentable. A generic card cannot carry terminal output; a terminal card cannot carry a diff. The old bag permitted all of these.
- The bridge switches instead of stitching. One arm per card kind, each producing exactly the wire shape that card needs, rather than reconciling five optional fields whose interactions are undocumented.
diffis a first-class intent.dsh-tool-fswrite/edit declarecard:'diff'; the bridge emits an ACP{type:'diff', path, oldText, newText}ToolCallContent(already in the SDK'sToolCallContentunion, previously unused by the bridge). This is the affordance the redesign unlocks.
Producer mapping
dsh-tool-fsread →generic(kind:'read', a follow-alonglocation); write →diff(oldText:null); edit →diff(oldText:old_string || null,newText:new_string ?? ''). This mirrorsclaude-agent-acp'stoolInfoFromToolUseRead/Write/Edit arms field-for-field.dsh-tool-bashforeground →terminalcall +terminalresult;run_in_backgroundandbash_output/bash_kill→generic.dsh-tool-todo→generic.
Terminal fallback ownership
TerminalResultView carries only output/exitCode/signal. A UI without the terminal capability needs a fenced ```console text fallback; that derivation moves to the bridge (it wraps output in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte.
Purity preserved
presentCall/presentResult remain pure functions of args (+ the result for presentResult) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (oldText:null) because the tool has no old content at call time; edit's diff is old_string→new_string.
Relative-path display titles
claude-agent-acp relativizes a file card's title path against the session cwd (toDisplayPath) — Read src/foo.ts, not /abs/proj/src/foo.ts — while keeping locations[]/diff.path raw (the editor opens the real path). Our presentCall is pure/args-only and cannot see the session cwd, so this relativization happens at the bridge, which already threads the session cwd into tool-call rendering (the same cwd it uses to resolve a terminal card's header). The bridge relativizes the title only, by an exact structured replace of the known locations[0].path/diffs[0].path substring — generic over the file-card kinds, never special-casing tool names.
Non-goals
- Applied-hunk diffs.
claude-agent-acpadditionally rewrites Write/Edit diffs at result time with real structured-patch hunks (via a PostToolUse hook:toolUpdateFromDiffToolResponse). Our diffs are call-time and args-derived (the wholeold_string→new_string, no surrounding context lines), becausepresentResultsees only{content, isError}andFsEditOutcomecarries a replacement count/version, not hunk text. Real hunks would need a new result/event shape carrying the patch — a follow-up, not this change. This is the one remaining representation difference fromclaude-agent-acp, and it is architectural (needs a new event), not cosmetic. - Live incremental
terminal_output_deltastreaming and command classification — the terminal-rendering RFC's own deferred follow-ups, untouched here.
Related
- Supersedes the deferral in Collapse tool-owned UI presentation (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union.
- Folds
ToolTerminalinto theterminalviews described by ACP terminal and tool-call rendering (the_metaterminal-card convention and capability gate are unchanged; only the harness-side presentation type changes). - The ACP SDK's
Diff/ToolCallContenttypes back the newdiffcard.