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
+18 -6
View File
@@ -564,20 +564,32 @@ describe('status lines', () => {
})
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentCall: title is "description — command" (execute cards hide rawInput), command also in rawInput', async () => {
it('bash presentCall: title is "description — command", marks a terminal; explicit absolute workdir → cwd header', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' })
expect(present).toEqual({ title: 'List files in src — ls -la src', kind: 'execute', rawInput: 'ls -la src' })
// No explicit workdir → the call still flags a terminal, but with no cwd (the
// UI bridge fills the session cwd it owns; the pure presenter can't see it).
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
.toEqual({ title: 'List files in src — ls -la src', kind: 'execute', rawInput: 'ls -la src', terminal: {} })
// An explicit ABSOLUTE workdir is surfaced as the terminal cwd header.
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
.toEqual({ title: 'Print dir — pwd', kind: 'execute', rawInput: 'pwd', terminal: { cwd: '/tmp/x' } })
// A RELATIVE workdir is not an absolute cwd → omitted (terminal still flagged).
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
.toEqual({ title: 'Print dir — pwd', kind: 'execute', rawInput: 'pwd', terminal: {} })
})
it('bash presentResult: wraps the model-facing text in a fenced console block', async () => {
it('bash presentResult: console-block content AND terminal.output (both renderings of the run)', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')!.presentResult!(
{ command: 'echo hi', description: 'echo' },
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
)
// Trailing blank lines are trimmed; the body is fenced as ```console.
expect(present).toEqual({ content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }] })
// Trailing blank lines trimmed; content is the fenced ```console fallback,
// terminal.output is the same text for a capable terminal card.
expect(present).toEqual({
content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
terminal: { output: 'hi\n[exit code: 0]' },
})
})
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {