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:
terminal fields overlap, and the bridge reconciles a content block AND a terminal block AND rawInput per call, stitching them together with ad-hoc conditionals.terminal call that also sets content means "description above the card"; a generic call that sets terminal is meaningless but representable. The type permits nonsense.{path, oldText, newText}, which Zed renders as an inline diff / new-file preview). ToolCallPresentation.content is the LLM ContentBlock[] 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.
diff is a first-class intent. dsh-tool-fs write/edit declare card:'diff' with {path, oldText, newText}, allowing capable UIs to render an inline change without tool-name special cases.dsh-tool-fs read → generic (kind:'read', a follow-along location); write → diff (oldText:null); edit → diff (oldText:old_string || null, newText:new_string ?? ''). This mirrors claude-agent-acp's toolInfoFromToolUse Read/Write/Edit arms field-for-field.dsh-tool-bash foreground → terminal call + terminal result; run_in_background → generic. The generic job_* 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.
ContentBlockMap pattern) — 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's assertNever switch.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.
terminal_output_delta streaming and command classification — the terminal-rendering Agent Note's own deferred follow-ups, untouched here.meta channel — the value/presentation split and the persisted presentationMeta channel are now owned by the canonical tool output contract so write/edit emit a result-time DiffResultView — the applied change (a contextual hunk with context lines / one per replace_all site, or a whole-file diff for a create) — on top of this union's call-time diff card.ToolTerminal into the tagged terminal views used by current UI transports.