mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -40,7 +40,6 @@ import type { Context } from 'cordis'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } 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 { 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
|
||||
* 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.
|
||||
*
|
||||
* `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 {
|
||||
const block = result.content.length === 1 ? result.content[0] : 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). */
|
||||
|
||||
Reference in New Issue
Block a user