6.8 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/tools). For bash we surface the model's description plus the command as the tool_call title, 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, and the command output rendered as a terminal — 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 our command rides inside the title.)
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}]+_meta.terminal_info.{terminal_id,cwd}; ontool_call_update,_meta.terminal_output.{terminal_id,data}(the captured output).terminalIdis derived from the harnesscallId(stable, unique per call). When the capability is absent, the bridge uses the existing```consoletext content — unchanged. - No new execution path, no live streaming, no exit pill yet. 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 NOT emitted: it needs a structured exit code the purepresentResult(args, result)seam doesn't get (the result is content blocks), and the exit is already visible in the output text's[exit code: N]/[killed by signal: …]marker. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal.
Risks / trade-offs
- Zed-convention
_metakeys. The terminal card rides on Zed-specific keys (terminal_info/terminal_output) 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. - 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. Client-side terminal/create execution is explicitly rejected (it bypasses dsh-bash). Three follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: the exit-status pill (_meta.terminal_exit.{exit_code,signal}, which needs the structured exit surfaced from the run rather than parsed out of the rendered output text), 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).