feat(acp): render bash as a terminal card via the _meta convention

When the client advertises clientCapabilities._meta.terminal_output (Zed), a
bash tool call now renders as a real TERMINAL card — a cwd header + the command
+ its output — instead of the plain ```console text block. Keeps agent-side
dsh-bash execution; rejects the spec's client-side terminal/create (which would
bypass sandbox/env-scrub/ownership/cwd). Matches what claude-agent-acp and
codex-acp do; wire contract verified against Zed's source.

- dsh-tools: a provider-neutral ToolTerminal shape ({ cwd?, output? }) on
  ToolCallPresentation/ToolResultPresentation — a tool asks "render me as a
  terminal"; no ACP types leak in.
- dsh-tool-bash: bash presentCall marks terminal (cwd from an explicit absolute
  workdir, else left for the bridge to fill from the session cwd); presentResult
  carries the output alongside the ```console fallback.
- dsh-acp: initialize reads/remembers the _meta.terminal_output capability;
  streamSessionEventUpdate maps a terminal presentation to
  content:[{type:'terminal',terminalId}] + _meta.terminal_info on the call and
  _meta.terminal_output on the update WHEN capable — else the unchanged text
  path. terminalId is the callId; cwd defaults to the session header. The pure
  translator gained a TerminalRendering {enabled,cwd} param (off by default).

Tests via the REAL tool-bash + bash-local: capability ON -> terminal content +
_meta; OFF -> no _meta (text path). The with-key e2e adds a real-model terminal
card case (echo over ACP with the capability on). 773 tests, 100% coverage.

The exit-status pill (_meta.terminal_exit), live streaming
(_meta.terminal_output_delta), and command classification are RFC follow-ups.
This commit is contained in:
Tianyi Cui
2026-06-18 17:25:09 +08:00
parent 386ee14af3
commit 149ab1bba4
10 changed files with 224 additions and 30 deletions

View File

@@ -25,8 +25,8 @@ Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` c
1. **Capability.** `initialize` reads `clientCapabilities._meta.terminal_output` and the bridge remembers it per connection.
2. **Neutral presentation vocabulary.** `dsh-tools` gains a terminal-shaped presentation a tool can return — provider-neutral (`cwd`, the output `data`, an `exitCode`/`signal`), NO ACP types. `dsh-tool-bash` returns it for `bash` (cwd from the resolved workdir; output + exit parsed from the run result).
3. **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}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` + `_meta.terminal_exit.{terminal_id,exit_code,signal}`. `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge uses the existing ` ```console ` text content — unchanged.
4. **No new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal.
3. **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}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge uses the existing ` ```console ` text content — unchanged.
4. **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 pure `presentResult(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
@@ -37,4 +37,4 @@ Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` c
## 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`). 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).
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).