feat(acp): align bash terminal card with reference adapters (command title, description block, exit pill)

Match claude-agent-acp / codex-acp: the bash tool_call title IS the command
(an execute card hides rawInput), the model description rides as a content
text block above the card, and the completed card carries an exit-status pill
via _meta.terminal_exit.

Bridge fixes found in review of the prior terminal-card commit:
- tool_call_update.content is OMITTED in terminal mode (an ACP update.content
  REPLACES the call's content collection in Zed, so the fenced ```console block
  would clobber the terminal content block).
- terminal.output preserves RAW newlines (terminal renderers rely on exact
  bytes); only the fenced fallback trims trailing blank lines.
- a relative workdir is resolved against the session cwd for the card header,
  matching where the command actually ran.
- result-side terminal output is gated on the pending call having registered a
  terminal (no orphan _meta.terminal_output for a terminal Zed never made).

The exit pill is recovered by parsing renderResult's status markers (the pure
presentResult seam sees only content blocks); a round-trip test pins the parse
to the marker emission. Neutral ToolTerminal gains exitCode/signal; widened
ToolCallPresentation with a content block. Docs (RFC + 3 READMEs) updated;
with-key e2e verifies the card + exit pill against the real model.
This commit is contained in:
Tianyi Cui
2026-06-18 18:54:32 +08:00
parent c8dbe6567a
commit e51dabbb8b
12 files changed
+417 -96

No files matched your search

+50 -25
View File
@@ -132,51 +132,76 @@ export function renderResult(result: BashRunResult): string {
// ---------------------------------------------------------------------------
/**
* Pending-state presentation for a `bash` call. The title is the model-written
* `description` followed by the exact `command` ("List files — ls -la src"):
* `kind: 'execute'` gets a terminal/run treatment in a UI, but an execute-kind
* card HIDES `rawInput` (Zed: `should_show_raw_input = !is_terminal_tool`), so
* the command MUST ride in the always-visible title to be seen — the reference
* ACP adapters (claude-agent-acp, codex-acp) likewise put the command in the
* title for execute tools. The description leads (a readable summary the schema
* requires); the command follows so the verbatim text is still there. `rawInput`
* still carries the bare command for non-execute UIs that DO render it.
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
* = !is_terminal_tool`), so the command must BE the title to be seen. This
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
* use the bare command as an execute tool's title. The model-written
* `description` (a readable summary) rides as a `content` text block shown ABOVE
* the card, since a terminal card has no description slot — claude-agent-acp
* likewise surfaces its description as a separate content block. `rawInput` still
* carries the bare command for non-execute UIs that DO render it.
*
* `terminal` marks the call so a capable UI renders a TERMINAL card. The cwd
* header comes from an explicit absolute model `workdir` when given; otherwise
* the call ran in the session workspace, which this PURE presenter (args only,
* no `exec`) can't see — the UI bridge fills that default from the session's own
* cwd. An empty `terminal: {}` still flags "this is a terminal".
* `terminal` marks the call so a capable UI renders a TERMINAL card. Its `cwd`
* (header) is the model `workdir` when given — ABSOLUTE as-is, RELATIVE for the
* UI bridge to resolve against the session cwd; when omitted entirely the bridge
* fills the session workspace cwd (this PURE presenter, args only, can't see it).
*/
function presentBashCall(args: { command: string; description: string; workdir?: string }): ToolCallPresentation {
const cwd = args.workdir !== undefined && isAbsolute(args.workdir) ? args.workdir : undefined
return {
title: `${args.description}${args.command}`,
title: args.command,
kind: 'execute',
rawInput: args.command,
terminal: cwd !== undefined ? { cwd } : {},
content: [{ type: 'text', text: args.description }],
terminal: args.workdir !== undefined ? { cwd: args.workdir } : {},
}
}
/**
* Completed-state presentation for a `bash` call. Two parallel renderings of the
* same output: `terminal.output` for a UI that shows a terminal card (the run's
* stdout/stderr + status markers, exactly as the model sees them — it already
* carries the `[exit code: N]` marker), and a fenced ```console `content` block
* as the fallback for a UI without terminal support (the fences are a UI-only
* affordance, so they live here, not in `renderResult`). A non-text result
* (unexpected for bash) falls through to `undefined` (UI keeps the raw result).
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
* newlines preserved, since a terminal renderer relies on exact bytes), and a
* fenced ```console `content` block as the fallback for a UI without terminal
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended
* (this parse is the exact inverse of those markers — they co-evolve in this
* file and a round-trip test guards the pair). A non-text result (unexpected for
* bash) falls through to `undefined` (UI keeps the raw result).
*/
function presentBashResult(_args: unknown, result: ToolResult): ToolResultPresentation | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
const text = block.text.replace(/\n+$/, '')
const raw = block.text
const fenced = raw.replace(/\n+$/, '')
return {
content: [{ type: 'text', text: `\`\`\`console\n${text}\n\`\`\`` }],
terminal: { output: text },
content: [{ type: 'text', text: `\`\`\`console\n${fenced}\n\`\`\`` }],
terminal: { output: raw, ...parseExitStatus(raw) },
}
}
/**
* Recover the structured exit status from a rendered `renderResult` string — the
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
* a clean run appends neither, so absent both we report `{exitCode:0}`. (A
* trapped-timeout run that exits 0 has no signal/exit marker either and reads as
* exitCode 0, which is accurate — it did exit 0.) `renderResult` always appends
* the exit/signal marker LAST (after any timeout marker) onto a non-empty body,
* so the marker is anchored at end-of-string here — output that merely CONTAINS
* such text earlier is not mistaken for it.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation {
return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }