8.7 KiB
RFC: Rich ACP bash rendering — the terminal card via the _meta convention
Status: implemented
Problem
The ACP bridge lets each tool own its call rendering via presentCall/presentResult (see tool-call UI presentation and packages/core/tools). For bash we surface the exact command as the tool_call title, the model's description as a content text block, kind: 'execute', and the completed output wrapped in a fenced ```console text block.
That is a correct, capability-free baseline, but not how the reference editors render a terminal tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the tool_call carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES rawInput for kind: 'execute', which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.)
Key finding: agent-executed terminals use a _meta convention, NOT terminal/create
The ACP spec has a client-side terminal sub-protocol — the agent calls the client's terminal/create with { command, args, cwd, env } and the editor executes the process, then the agent reads terminal/output / wait_for_exit. That model is wrong for us: our harness executes bash itself through dsh-bash (sandboxed env-scrub, background-task ownership, per-session cwd). Routing execution to the editor would bypass all of that and fork execution into two backends.
Studying the two reference agents (2026-06-18) shows neither uses terminal/create for their own shell tool — both keep agent-side execution and emit a _meta convention that Zed special-cases:
claude-agent-acp(tools.ts,acp-agent.ts): gated onclientCapabilities._meta.terminal_output. Thetool_callcarriescontent: [{ type: 'terminal', terminalId }]and_meta.terminal_info.{ terminal_id, cwd }; output/exit arrive on thetool_call_update's_meta.terminal_output.{ terminal_id, data }and_meta.terminal_exit.{ terminal_id, exit_code, signal }.codex-acp(CodexToolCallMapper.ts,TerminalOutputMode.ts): sameterminal_infoon the call; output via_meta.terminal_output(full) or_meta.terminal_output_delta(incremental), selected from the same_meta.terminal_outputcapability.
Zed's side (crates/agent_servers/src/acp.rs, verified): on a ToolCall whose _meta.terminal_info.terminal_id is set, it registers a display-only terminal (header = terminal_info.cwd, label = tool_call.title); on a ToolCallUpdate, _meta.terminal_output.data writes to that terminal and _meta.terminal_exit.{exit_code,signal} sets the status. It advertises the capability as clientCapabilities._meta.terminal_output = true. _meta itself is a spec-blessed ACP extensibility point (typed {[k]: unknown} | null on ToolCall/ToolCallUpdate); the specific keys here (terminal_info/terminal_output/terminal_exit) are a Zed convention, not part of the ACP spec — but they are the de-facto contract for the Zed integration and the only way to get the terminal card while keeping execution agent-side.
Decision
Keep dsh-bash agent-side execution; render the terminal card via the _meta convention, capability-gated, with the ```console text block as the guaranteed fallback.
- Capability.
initializereadsclientCapabilities._meta.terminal_outputand the bridge remembers it per connection. - Neutral presentation vocabulary.
dsh-toolsgains a terminal-shaped presentation a tool can return — provider-neutral (cwd, the outputdata, anexitCode/signal), NO ACP types.dsh-tool-bashreturns it forbash(cwd from the resolved workdir; output + exit parsed from the run result). - Bridge mapping. When the client advertised the capability, the bridge maps that presentation to: on
tool_call,content:[…, {type:'terminal', terminalId}](any toolcontent, e.g. the description, rendered BEFORE the terminal block) +_meta.terminal_info.{terminal_id,cwd}; ontool_call_update,_meta.terminal_output.{terminal_id,data}(the captured output) +_meta.terminal_exit.{terminal_id, exit_code|signal}(the parsed exit), with the update's textcontentOMITTED (an ACPtool_call_update.contentREPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block).terminalIdis derived from the harnesscallId(stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing```consoletext content on the update — unchanged. - The exit pill is parsed from the rendered output; no new execution path, no live streaming. Output is attached at completion (from the agent's own
tool/result), not streamed token-by-token. The exit-status pill (_meta.terminal_exit.{exit_code,signal}) IS emitted: the purepresentResult(args, result)seam sees only content blocks, sodsh-tool-bashrecovers the structured exit by parsing the status markers ([exit code: N]/[killed by signal: …]) thatrenderResultappended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal.
Alternatives considered
- The ACP client-side terminal sub-protocol (
terminal/create) — explicitly rejected: the editor would execute the process, bypassingdsh-bash's env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the_metaconvention is the only shape that yields the terminal card while keeping the harness's execution policy. - Threading a structured exit through the event schema — rejected in favor of the marker round-trip: the pure
presentResult(args, result)seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test.
Consequences
- Zed-convention
_metakeys. The terminal card rides on Zed-specific keys (terminal_info/terminal_output/terminal_exit) inside ACP's spec-blessed_metaextensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via_meta.terminal_output), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys. - Capability honesty. Emit terminal metadata ONLY when the client advertised
_meta.terminal_output; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the```consolepath. - terminalId collisions. Deriving it from the per-call
callIdkeeps it unique within a session and stable across the call/result pair; never reuse one across calls. - Exit parsed from rendered text. The exit pill recovers
exit_code/signalby parsingrenderResult's status markers rather than threading a structured exit through the event schema (which the purepresentResultseam never sees). The parse is the exact inverse of the marker emission and lives in the same file; a round-trip test pins the pair so a marker-format change that breaks the parse fails the suite. If the markers ever need to diverge from what the pill wants, surface a structured exit on the result event instead. - Provider-neutral vocabulary creep. The terminal presentation widens the
dsh-toolssurface; keep it neutral (no ACP types leak intodsh-tools) and only as rich as a second UI consumer would also want.
Out of scope / non-goals
The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: live incremental streaming (_meta.terminal_output_delta as chunks arrive, which needs an incremental-output seam on dsh-bash), and command classification (parsing a cat/sed as a read card with a file location, a grep as a search, etc., falling back to the terminal card — display-only, must never change what executes).