Status: implemented
English | 中文
The render-intent union remains current for UI transports; its ACP mapping is superseded by ACP as an automation-only protocol.
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.
An earlier rejected collapse-tool-owned-presentation proposal deferred rich rendering until it could "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 met by multiple producer families plus the TUI and host/client-runtime (Web) consumers.
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.
- 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.
- Consumers switch instead of stitching. One arm per card kind produces exactly the view that card needs, rather than reconciling five optional fields whose interactions are undocumented.
diffis a first-class intent.dsh-tool-fswrite/edit declarecard:'diff'with{path, oldText, newText}, allowing capable UIs to render an inline change without tool-name special cases.
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_background→generic. The genericjob_*controls own their own generic cards.dsh-tool-todo→generic.
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.
The terminal intent is display-only. The harness still executes the command through its bash service, preserving sandboxing, environment scrubbing, job ownership, and per-session cwd; a UI projects the completed call and never becomes a second execution backend.
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.
- Delete tool-owned presentation entirely — the rejected collapse proposal this note supersedes; its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met.
- Let a UI execute terminal intents — rejected because it would bypass the harness's bash policy and ownership contracts and fork command execution across backends. A terminal card describes harness-owned execution; it never authorizes client-side execution.
- A merge-extensible union (the
ContentBlockMappattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge'sassertNeverswitch. - Keeping the optional-field bag — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all.
A new render intent is a compile-breaking change at the bridge switch — deliberately: rendering code must exist before a card kind does. Invalid card/field combinations are now unrepresentable, and the bash fallback derivation lives in the bridge, so a tool returns one structured shape. The bar for a fourth card (a table, a chart) is writing its bridge arm in the same change.
- Live incremental
terminal_output_deltastreaming and command classification — the terminal-rendering Agent Note's own deferred follow-ups, untouched here.
- Supersedes the deferral in the earlier rejected collapse-tool-owned-presentation proposal (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.
- Extended by Result-time applied-hunk diffs (archived), which added a persisted
metachannel — the value/presentation split and the persistedpresentationMetachannel are now owned by the canonical tool output contract so write/edit emit a result-timeDiffResultView— the applied change (a contextual hunk with context lines / one perreplace_allsite, or a whole-file diff for a create) — on top of this union's call-time diff card. - Folds
ToolTerminalinto the taggedterminalviews used by current UI transports.