Pārlūkot izejas kodu

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.
Tianyi Cui 3 mēneši atpakaļ
vecāks
revīzija
149ab1bba4

+ 3 - 3
docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md

@@ -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.
 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).
 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
 ## 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
 ## 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).

+ 31 - 0
examples/acp-agent/tests/acp.e2e.ts

@@ -190,5 +190,36 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
     expect(bashCall.title.length).toBeGreaterThan(0)
     expect(bashCall.title.length).toBeGreaterThan(0)
     expect(bashCall.title).not.toBe('bash') // the old, unhelpful title
     expect(bashCall.title).not.toBe('bash') // the old, unhelpful title
     expect(typeof bashCall.rawInput).toBe('string') // the exact command
     expect(typeof bashCall.rawInput).toBe('string') // the exact command
+    // Capability OFF: no terminal _meta — the ```console text path renders.
+    expect((bashCall as { _meta?: unknown })._meta).toBeUndefined()
+  }, 180_000)
+
+  it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta)', async () => {
+    workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
+    spawned = spawnAcpAgent(workdir)
+    const { client, updates } = spawned
+
+    // Advertise the Zed `_meta.terminal_output` capability so the bridge emits
+    // the terminal card for the real bash tool.
+    await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
+    const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
+    const res = await client.prompt({
+      sessionId,
+      prompt: [{ type: 'text', text: 'Use the bash tool to run: echo ACP_TERMINAL_OK. Then stop.' }],
+    })
+    expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
+
+    // A bash tool_call now carries a terminal content block + _meta.terminal_info
+    // with the session cwd as the header; the matching update streams the output
+    // on _meta.terminal_output.
+    const bashCall = updates.find(u => u.sessionUpdate === 'tool_call' && u.kind === 'execute')
+    if (bashCall?.sessionUpdate !== 'tool_call') throw new Error('expected an execute tool_call')
+    const block = bashCall.content?.[0] as { type: string; terminalId?: string } | undefined
+    expect(block?.type).toBe('terminal')
+    expect(typeof block?.terminalId).toBe('string')
+    const info = (bashCall._meta as { terminal_info?: { terminal_id: string; cwd?: string } }).terminal_info
+    expect(info?.cwd).toBe(workdir)
+    const updatesForTerminal = updates.filter(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_output?: unknown } | undefined)?.terminal_output !== undefined)
+    expect(updatesForTerminal.length).toBeGreaterThan(0)
   }, 180_000)
   }, 180_000)
 })
 })

+ 8 - 1
packages/acp/README.md

@@ -46,7 +46,14 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t
 
 
 The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
 The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
 
 
-A richer rendering — the ACP **terminal** content type (a live cwd-header terminal card with streaming output) and command classification (a `cat` shown as a `read`, a `grep` as a `search`) — is a capability-gated follow-up; the ` ```console ` text block here is the guaranteed baseline for clients without the terminal capability. See [the terminal-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md).
+## Terminal card (capability-gated)
+
+A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`:
+
+- `tool_call`: `content:[{type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit `terminal.cwd` if it has one, else the session's workspace cwd (the bridge fills that, since the pure tool presenter can't see it).
+- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` — the captured output, attached at completion.
+
+When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted and the ` ```console ` text block (above) is the rendering — so a non-Zed client is never worse off. This is an off-spec Zed `_meta` extension, not the ACP `terminal/create` sub-protocol: that would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd. The exit-status pill, live streaming, and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md).
 
 
 ## Settle-exactly-once
 ## Settle-exactly-once
 
 

+ 70 - 4
packages/acp/src/index.ts

@@ -60,7 +60,7 @@ import {
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
-import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
+import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
 // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
 // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
 // Context (the bridge injects it and reads `list()` for load cwd validation).
 // Context (the bridge injects it and reads `list()` for load cwd validation).
 import type {} from '@deepseek-ai/dsh-session-persistence'
 import type {} from '@deepseek-ai/dsh-session-persistence'
@@ -212,6 +212,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
   // await and NOT install a record (which would resurrect a live agent/listeners
   // await and NOT install a record (which would resurrect a live agent/listeners
   // after the bridge closed). Checked after every load await.
   // after the bridge closed). Checked after every load await.
   let closed = false
   let closed = false
+  // Whether the client advertised the Zed `_meta.terminal_output` capability in
+  // `initialize`. When true, a tool's terminal presentation is rendered as a
+  // terminal card (content + `_meta.terminal_*`); when false, the bridge uses
+  // the tool's text fallback. Set once in `initialize`, read on every tool event.
+  let terminalOutputCap = false
 
 
   // Assigned at the bottom, before any agent event can fire (a session only
   // Assigned at the bottom, before any agent event can fire (a session only
   // exists after `newSession`, which the client calls after construction), so
   // exists after `newSession`, which the client calls after construction), so
@@ -284,7 +289,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
   ctx.on('session/event', (session, event: SessionEvent) => {
   ctx.on('session/event', (session, event: SessionEvent) => {
     const rec = sessions.get(session.header.id)
     const rec = sessions.get(session.header.id)
     if (rec === undefined) return
     if (rec === undefined) return
-    streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter)
+    streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
+      enabled: terminalOutputCap,
+      cwd: session.header.cwd,
+    })
     const inflight = rec.inflight
     const inflight = rec.inflight
     if (inflight === undefined) return
     if (inflight === undefined) return
     if (event.type === 'turn/start') {
     if (event.type === 'turn/start') {
@@ -380,6 +388,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
         // exactly PROTOCOL_VERSION; any other requested version negotiates
         // exactly PROTOCOL_VERSION; any other requested version negotiates
         // down to ours (the client disconnects if it can't speak it).
         // down to ours (the client disconnects if it can't speak it).
         const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
         const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
+        // Remember the Zed terminal-output `_meta` capability: when set, bash and
+        // other shell tools render as a terminal card (see streamSessionEventUpdate
+        // + the terminal-rendering RFC). `_meta` is `{[k]: unknown} | null`, so
+        // narrow defensively to a strict boolean true.
+        terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
         return Promise.resolve({
         return Promise.resolve({
           protocolVersion,
           protocolVersion,
           agentInfo: { name: agentName, version: agentVersion },
           agentInfo: { name: agentName, version: agentVersion },
@@ -478,8 +491,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
           // as the log replays in order (same as live) and is discarded after,
           // as the log replays in order (same as live) and is discarded after,
           // so the record's presenter starts clean for the post-load live stream.
           // so the record's presenter starts clean for the post-load live stream.
           const replayPresenter = makePresenter()
           const replayPresenter = makePresenter()
+          const replayTerminal: TerminalRendering = {
+            enabled: terminalOutputCap,
+            cwd: agent.session.header.cwd,
+          }
           for (const event of agent.session.events) {
           for (const event of agent.session.events) {
-            streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter)
+            streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter, replayTerminal)
           }
           }
           return {}
           return {}
         } finally {
         } finally {
@@ -699,6 +716,7 @@ export function streamSessionEventUpdate(
   event: SessionEvent,
   event: SessionEvent,
   notify: (notification: SessionNotification) => void,
   notify: (notification: SessionNotification) => void,
   presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
   presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
+  terminal: TerminalRendering = noTerminalRendering,
 ): void {
 ): void {
   switch (event.type) {
   switch (event.type) {
     case 'assistant/chunk': {
     case 'assistant/chunk': {
@@ -724,6 +742,11 @@ export function streamSessionEventUpdate(
     }
     }
     case 'tool/call': {
     case 'tool/call': {
       const present = presenter.call(event.data.callId, event.data.name, event.data.arguments)
       const present = presenter.call(event.data.callId, event.data.name, event.data.arguments)
+      // A terminal-rendered call (a shell command) gets a terminal CARD when the
+      // client supports it: a `terminal` content block plus `_meta.terminal_info`
+      // (the cwd header). Otherwise it is an ordinary tool_call and the output
+      // arrives as text on the result. See the terminal-rendering RFC.
+      const asTerminal = present.terminal !== undefined && terminal.enabled
       notify({
       notify({
         sessionId,
         sessionId,
         update: {
         update: {
@@ -733,12 +756,27 @@ export function streamSessionEventUpdate(
           kind: present.kind,
           kind: present.kind,
           status: 'in_progress',
           status: 'in_progress',
           ...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
           ...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
+          ...asTerminal
+            ? {
+              content: [{ type: 'terminal', terminalId: event.data.callId }],
+              _meta: { terminal_info: { terminal_id: event.data.callId, cwd: present.terminal?.cwd ?? terminal.cwd } },
+            }
+            : {},
         },
         },
       })
       })
       return
       return
     }
     }
     case 'tool/result': {
     case 'tool/result': {
       const present = presenter.result(event.data.callId, event.data.content, event.data.isError)
       const present = presenter.result(event.data.callId, event.data.content, event.data.isError)
+      const term = present.terminal
+      // When the call rendered as a terminal AND the client is capable, stream
+      // the output on the update's `_meta.terminal_output` (the terminal card
+      // consumes it). The text `content` is still sent as the record/fallback;
+      // a capable UI shows the terminal card, an incapable one shows the text.
+      // (The exit-status pill via `_meta.terminal_exit` needs a structured exit
+      // code the tool doesn't surface yet — see the RFC follow-up; the exit is
+      // already visible in the output text's `[exit code: N]` marker.)
+      const asTerminal = term?.output !== undefined && terminal.enabled
       notify({
       notify({
         sessionId,
         sessionId,
         update: {
         update: {
@@ -747,6 +785,7 @@ export function streamSessionEventUpdate(
           status: event.data.isError ? 'failed' : 'completed',
           status: event.data.isError ? 'failed' : 'completed',
           content: toolResultContent(present.content),
           content: toolResultContent(present.content),
           ...present.title !== undefined ? { title: present.title } : {},
           ...present.title !== undefined ? { title: present.title } : {},
+          ...asTerminal ? { _meta: { terminal_output: { terminal_id: event.data.callId, data: term.output } } } : {},
         },
         },
       })
       })
       return
       return
@@ -758,6 +797,23 @@ export function streamSessionEventUpdate(
   }
   }
 }
 }
 
 
+/**
+ * Per-connection terminal-rendering context threaded into
+ * {@link streamSessionEventUpdate}: whether the client advertised the
+ * `_meta.terminal_output` capability, and the session's workspace cwd (the
+ * default terminal-card header when a tool doesn't supply its own). Kept out of
+ * the pure translator's required params so the no-capability / no-presenter
+ * tests stay terse.
+ */
+export interface TerminalRendering {
+  enabled: boolean
+  /** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
+  cwd: string | undefined
+}
+
+/** Default: terminal rendering off (the ` ```console ` text fallback path). */
+const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
+
 /**
 /**
  * Resolved pending-state presentation the bridge feeds into a `tool_call`
  * Resolved pending-state presentation the bridge feeds into a `tool_call`
  * update: a title is always present (tool name when the tool gives none), `kind`
  * update: a title is always present (tool name when the tool gives none), `kind`
@@ -767,6 +823,8 @@ interface ResolvedCallPresentation {
   title: string
   title: string
   kind: ToolCallKind
   kind: ToolCallKind
   rawInput?: unknown
   rawInput?: unknown
+  /** Tool's request to render as a terminal (the pending side carries the cwd). */
+  terminal?: ToolTerminal
 }
 }
 
 
 /** Resolved completed-state presentation fed into a `tool_call_update`. */
 /** Resolved completed-state presentation fed into a `tool_call_update`. */
@@ -775,6 +833,8 @@ interface ResolvedResultPresentation {
   content: ContentBlock[]
   content: ContentBlock[]
   /** Optional replacement title for the completed call. */
   /** Optional replacement title for the completed call. */
   title?: string
   title?: string
+  /** Tool's terminal output/exit for a terminal-rendered call (the result side). */
+  terminal?: ToolTerminal
 }
 }
 
 
 /**
 /**
@@ -831,7 +891,12 @@ export class ToolPresenter {
       // the full parsed args as the raw input (the pre-seam behavior).
       // the full parsed args as the raw input (the pre-seam behavior).
       return { title: name, kind: toolKindFor(name), rawInput: args }
       return { title: name, kind: toolKindFor(name), rawInput: args }
     }
     }
-    return { title: present.title, kind: present.kind ?? 'other', rawInput: present.rawInput }
+    return {
+      title: present.title,
+      kind: present.kind ?? 'other',
+      rawInput: present.rawInput,
+      ...present.terminal !== undefined ? { terminal: present.terminal } : {},
+    }
   }
   }
 
 
   /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
   /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
@@ -852,6 +917,7 @@ export class ToolPresenter {
     return {
     return {
       content: present.content ?? content,
       content: present.content ?? content,
       ...present.title !== undefined ? { title: present.title } : {},
       ...present.title !== undefined ? { title: present.title } : {},
+      ...present.terminal !== undefined ? { terminal: present.terminal } : {},
     }
     }
   }
   }
 }
 }

+ 35 - 2
packages/acp/tests/turns.spec.ts

@@ -14,8 +14,8 @@ import {
 } from './harness.ts'
 } from './harness.ts'
 
 
 /** Boilerplate: initialize + create one session, returning its id. */
 /** Boilerplate: initialize + create one session, returning its id. */
-async function newSession(h: BridgeHarness): Promise<string> {
-  await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
+async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
+  await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities })
   const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
   const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
   return sessionId
   return sessionId
 }
 }
@@ -110,6 +110,39 @@ describe('acp bridge — turn outcomes', () => {
     expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
     expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
     const content = update.content as { content: { type: string; text: string } }[]
     const content = update.content as { content: { type: string; text: string } }[]
     expect(content[0]?.content.text).toBe('```console\nhello\n```')
     expect(content[0]?.content.text).toBe('```console\nhello\n```')
+    // Capability OFF (the default newSession): NO terminal _meta on either update.
+    expect((call as { _meta?: unknown })._meta).toBeUndefined()
+    expect((update as { _meta?: unknown })._meta).toBeUndefined()
+  })
+
+  it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta)', async () => {
+    // Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
+    // capability in initialize. The bridge must then emit the terminal CARD: a
+    // terminal content block + `_meta.terminal_info` (cwd header) on the call,
+    // and `_meta.terminal_output`/`terminal_exit` on the result.
+    harness = await makeBridgeHarness({
+      storageDir,
+      withBash: true,
+      script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
+    })
+    // Capability lives under clientCapabilities._meta.terminal_output.
+    const sessionId = await newSession(harness, { _meta: { terminal_output: true } })
+    await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
+
+    const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
+    if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
+    // A terminal content block keyed by the callId, and terminal_info with the
+    // session cwd (the bridge fills it from the session header).
+    expect(call.content).toEqual([{ type: 'terminal', terminalId: 'c1' }])
+    expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
+
+    const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
+    if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
+    // Output rides on _meta.terminal_output; the text content is still present
+    // as the fallback for a UI that ignores the _meta.
+    const meta = update._meta as { terminal_output?: { terminal_id: string; data: string } }
+    expect(meta.terminal_output?.terminal_id).toBe('c1')
+    expect(meta.terminal_output?.data).toBe('hi')
   })
   })
 
 
   it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
   it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {

+ 1 - 1
packages/tool-bash/README.md

@@ -34,7 +34,7 @@ The owning agent is recorded per task id at spawn and kept for the lifetime of t
 
 
 ## UI presentation
 ## UI presentation
 
 
-These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the model-written `description` followed by the exact `command` ("List files in src — ls -la src"), `kind` is `execute` (terminal/run treatment), and the `command` is ALSO the **rawInput**. Why both in the title: an execute-kind card hides `rawInput` (Zed renders it only for non-terminal tools), 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 an execute tool's title. The completed output is wrapped in a fenced ` ```console ` block — a UI-only affordance, so the model-facing result text stays unfenced. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/tools` ("Tool-owned UI presentation") and `packages/acp` ("Tool-call presentation").
+These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the model-written `description` followed by the exact `command` ("List files in src — ls -la src"), `kind` is `execute` (terminal/run treatment), and the `command` is ALSO the **rawInput**. Why both in the title: an execute-kind card hides `rawInput` (Zed renders it only for non-terminal tools), 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 an execute tool's title. The completed output is wrapped in a fenced ` ```console ` block — a UI-only affordance, so the model-facing result text stays unfenced. `bash` also flags itself as a **terminal** (the neutral `terminal` field on its presentation: `presentCall` sets a `cwd` from an explicit absolute `workdir`, else leaves it for the UI bridge to fill from the session cwd; `presentResult` carries the output) so a capable client (Zed) renders a terminal card instead of the text block — see `packages/acp` ("Terminal card"). `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/tools` ("Tool-owned UI presentation") and `packages/acp` ("Terminal card" / "Tool-call presentation").
 
 
 ## Background completion notices
 ## Background completion notices
 
 

+ 26 - 11
packages/tool-bash/src/index.ts

@@ -40,7 +40,6 @@ import type { Context } from 'cordis'
 import { isAbsolute, resolve as resolvePath } from 'node:path'
 import { isAbsolute, resolve as resolvePath } from 'node:path'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
 import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
 import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
 
 
@@ -142,24 +141,40 @@ export function renderResult(result: BashRunResult): string {
  * title for execute tools. The description leads (a readable summary the schema
  * title for execute tools. The description leads (a readable summary the schema
  * requires); the command follows so the verbatim text is still there. `rawInput`
  * 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.
  * 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".
  */
  */
-function presentBashCall(args: { command: string; description: string }): ToolCallPresentation {
-  return { title: `${args.description} — ${args.command}`, kind: 'execute', rawInput: args.command }
+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}`,
+    kind: 'execute',
+    rawInput: args.command,
+    terminal: cwd !== undefined ? { cwd } : {},
+  }
 }
 }
 
 
 /**
 /**
- * Completed-state presentation for a `bash` call: wrap the model-facing result
- * text in a fenced ```console block so a UI renders the output monospaced as a
- * terminal transcript. The model-facing `content` (what `execute` returned) is
- * intentionally NOT fenced — the fences are a UI-only affordance, so they live
- * here, not in `renderResult`. A non-text result (unexpected for bash) is left
- * untouched by falling back to `undefined`.
+ * 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).
  */
  */
 function presentBashResult(_args: unknown, result: ToolResult): ToolResultPresentation | undefined {
 function presentBashResult(_args: unknown, result: ToolResult): ToolResultPresentation | undefined {
   const block = result.content.length === 1 ? result.content[0] : undefined
   const block = result.content.length === 1 ? result.content[0] : undefined
   if (block === undefined || block.type !== 'text') return undefined
   if (block === undefined || block.type !== 'text') return undefined
-  const fenced: ContentBlock = { type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }
-  return { content: [fenced] }
+  const text = block.text.replace(/\n+$/, '')
+  return {
+    content: [{ type: 'text', text: `\`\`\`console\n${text}\n\`\`\`` }],
+    terminal: { output: text },
+  }
 }
 }
 
 
 /** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
 /** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */

+ 18 - 6
packages/tool-bash/tests/tools.spec.ts

@@ -564,20 +564,32 @@ describe('status lines', () => {
 })
 })
 
 
 describe('tool-owned UI presentation (presentCall / presentResult)', () => {
 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 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 ctx = await setup()
     const present = ctx.tools.get('bash')!.presentResult!(
     const present = ctx.tools.get('bash')!.presentResult!(
       { command: 'echo hi', description: 'echo' },
       { command: 'echo hi', description: 'echo' },
       { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
       { 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 () => {
   it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {

+ 2 - 2
packages/tools/README.md

@@ -72,8 +72,8 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
 
 
 A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
 A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
 
 
-- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), and an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object).
-- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title` and reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result).
+- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
+- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card and a UI that can't ignores it and uses `content`.
 
 
 Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
 Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
 
 

+ 30 - 0
packages/tools/src/index.ts

@@ -83,6 +83,29 @@ export interface ToolCallPresentation {
    * unless that is genuinely what a reader wants.
    * unless that is genuinely what a reader wants.
    */
    */
   rawInput?: unknown
   rawInput?: unknown
+  /**
+   * Ask a capable UI to render this call as a TERMINAL (a command running in a
+   * working directory), not a generic tool card — set by a tool whose call IS a
+   * shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its
+   * own terminal affordance and a UI that can't falls back to the normal card.
+   * Pair with {@link ToolResultPresentation.terminal} for the output/exit.
+   */
+  terminal?: ToolTerminal
+}
+
+/**
+ * A request to render a tool call as a terminal. The pending presentation
+ * supplies the working directory; the result presentation (see
+ * {@link ToolResultPresentation.terminal}) supplies the captured output.
+ * Provider-neutral — no client-protocol types. A UI that supports terminals
+ * shows a cwd-headed terminal card with the command and its output; a UI that
+ * does not ignores this and renders the ordinary card/content.
+ */
+export interface ToolTerminal {
+  /** Absolute working directory the command ran in, shown as the terminal header. Omit if unknown. */
+  cwd?: string
+  /** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */
+  output?: string
 }
 }
 
 
 /**
 /**
@@ -102,6 +125,13 @@ export interface ToolResultPresentation {
    * Stays in harness vocabulary; the UI maps these to its own content blocks.
    * Stays in harness vocabulary; the UI maps these to its own content blocks.
    */
    */
   content?: ContentBlock[]
   content?: ContentBlock[]
+  /**
+   * Terminal output/exit for a call the pending presentation marked as a
+   * terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders
+   * `output` in the terminal card and shows the exit status; an incapable UI
+   * uses `content` (the tool should supply a text fallback there too).
+   */
+  terminal?: ToolTerminal
 }
 }
 
 
 /** A registered tool: its schema plus the execution function. */
 /** A registered tool: its schema plus the execution function. */