diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 1fc8571db..5a9933e99 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -139,7 +139,7 @@ function toolPart( status: "completed", input, output: lorem(index * 23 + partIndex, outputLength), - title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", + title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed", metadata, time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, }, @@ -201,7 +201,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 - ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] + ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), @@ -295,6 +295,7 @@ export const fixture = { .filter(renderable) .map((part) => part.id), ), + expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 925614cc2..a03a75074 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => { const expectedMessageIDs = fixture.expected.targetMessageIDs await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) + + const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`) + const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]') + const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(shellSubtitle).toHaveCount(0) + await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "false") + await expect(shellSubtitle).toHaveText("bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") + await expect(shellSubtitle).toHaveCount(0) }) }) diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index bd6f175ad..f86cbdd69 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -28,9 +28,6 @@ export const Input = Schema.Struct({ .annotate({ description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, }), - description: Schema.String.pipe(Schema.optional).annotate({ - description: "Concise description of the command's purpose", - }), }) const Output = Schema.Struct({ diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 0fb2cd735..9bbea5f0c 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -134,10 +134,9 @@ describe("BashTool", () => { const definitions = yield* toolDefinitions(registry) expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") + expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) - expect( - yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })), - ).toEqual({ + expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, output: { structured: { diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index d0e57cc2e..84ad2fdb7 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -266,7 +266,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { // For shell tools, surface the actual command as the title so it stays visible // before output lands; non-shell tools keep their model-provided title. function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { - if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName + if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName return fallback || toolName } diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 52b29528b..9a717ba4e 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps): ToolSnapshot { function scrollBashStart(p: ToolProps): string { const cmd = p.input.command ?? "" - const desc = p.input.description || "Shell" const wd = p.input.workdir ?? "" - const dir = wd && wd !== "." ? toolPath(wd) : "" - if (cmd && desc === "Shell" && !dir) { + const formatted = wd && wd !== "." ? toolPath(wd) : "" + const dir = formatted === "." ? "" : formatted + if (cmd && !dir) { return `$ ${cmd}` } - const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc - if (!cmd) { - return `# ${title}` + return dir ? `# Running in ${dir}` : "" } - return `# ${title}\n$ ${cmd}` + return `# Running in ${dir}\n$ ${cmd}` } function scrollBashProgress(p: ToolProps): string { @@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo { } function permBash(p: ToolPermissionProps): ToolPermissionInfo { - const title = p.input.description || "Shell command" const cmd = p.input.command || "" return { icon: "#", - title, + title: "Shell command", lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`), } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dad796c99..a1f4d95c3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -542,7 +542,7 @@ export const layer = Layer.effect( time: { ...part.state.time, end: completed }, input: part.state.input, title: "", - metadata: { output, description: "" }, + metadata: { output }, output, } yield* sessions.updatePart(part) @@ -569,7 +569,7 @@ export const layer = Layer.effect( Effect.gen(function* () { output += chunk if (part.state.status === "running") { - part.state.metadata = { output, description: "" } + part.state.metadata = { output } yield* sessions.updatePart(part) } }), diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 620378dc1..1d3fb075f 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -263,7 +263,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole const ask = Effect.fn("ShellTool.ask")(function* ( ctx: Tool.Context, scan: Scan, - input: { command: string; description: string }, + input: { command: string }, ) { if (scan.dirs.size > 0) { const directories = Array.from(scan.dirs) @@ -277,7 +277,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: globs, metadata: { command: input.command, - description: input.description, directories, patterns: globs, }, @@ -291,7 +290,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: Array.from(scan.always), metadata: { command: input.command, - description: input.description, }, }) }) @@ -438,7 +436,6 @@ export const ShellTool = Tool.define( cwd: string env: NodeJS.ProcessEnv timeout: number - description: string }, ctx: Tool.Context, ) { @@ -482,7 +479,6 @@ export const ShellTool = Tool.define( yield* ctx.metadata({ metadata: { output: "", - description: input.description, }, }) @@ -523,7 +519,6 @@ export const ShellTool = Tool.define( ctx.metadata({ metadata: { output: last, - description: input.description, }, }), ), @@ -534,7 +529,6 @@ export const ShellTool = Tool.define( return ctx.metadata({ metadata: { output: last, - description: input.description, }, }) }), @@ -593,11 +587,10 @@ export const ShellTool = Tool.define( output += "\n\n\n" + meta.join("\n") + "\n" } return { - title: input.description, + title: input.command, metadata: { output: last || preview(output), exit: code, - description: input.description, truncated: cut, ...(cut && file ? { outputPath: file } : {}), }, @@ -646,7 +639,6 @@ export const ShellTool = Tool.define( cwd, env: yield* shellEnv(ctx, cwd), timeout, - description: params.description, }, ctx, ) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index bec50d98d..b576b7729 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -7,30 +7,22 @@ import { ShellID } from "./id" const PS = new Set(["powershell", "pwsh"]) const CMD = new Set(["cmd"]) -const descriptions = { - bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - powershell: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', -} - export type Limits = { maxLines: number maxBytes: number } -export function parameterSchema(description: string) { +export function parameterSchema() { return Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), workdir: Schema.optional(Schema.String).annotate({ description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, }), - description: Schema.String.annotate({ description }), }) } -export const Parameters = parameterSchema(descriptions.bash) +export const Parameters = parameterSchema() export type Parameters = Schema.Schema.Type function renderPrompt(template: string, values: Record) { @@ -103,7 +95,6 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -155,7 +146,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -205,7 +195,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -242,7 +231,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul gitCommandRestriction: "git commands", createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, - parameterDescription: descriptions.cmd, } } if (isPowerShell) { @@ -264,7 +252,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul ## Summary - <1-3 bullet points> '@`, - parameterDescription: descriptions.powershell, } } return { @@ -280,7 +267,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Summary <1-3 bullet points>`, - parameterDescription: descriptions.bash, } } @@ -300,7 +286,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits, createPrInstruction: selected.createPrInstruction, createPrExample: selected.createPrExample, }), - parameters: parameterSchema(selected.parameterDescription), + parameters: parameterSchema(), } } diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index f1500ec44..52ff5a354 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => { } }) +test("omits the current directory from bash titles", async () => { + const out = await setup() + + try { + await out.scrollback.append( + toolCommit({ + tool: "bash", + phase: "start", + toolState: "running", + state: { + status: "running", + input: { + command: "pwd", + workdir: process.cwd(), + }, + time: { start: 1 }, + }, + }), + ) + + const commits = claim(out.renderer) + try { + expect(render(commits)).toContain("$ pwd") + expect(render(commits)).not.toContain("Running in .") + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + test("renders completed bash output with one blank line after the command and before the next group", async () => { const out = await setup() @@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1 }, }, @@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1, end: 2 }, }, @@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be take() const output = lines.join("\n") + expect(output).toContain("# Running in /tmp/demo\n$ git status") expect(output).toContain("$ git status\n\nOn branch demo") expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1") expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1") @@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, time: { start: 1 }, }, @@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"), title: "pwd; ls -la", @@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, time: { start: 1 }, }, @@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"), title: "ls", diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 705e0250d..b685fb679 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -435,7 +435,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, @@ -490,7 +489,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index 18ae82d4d..e3356d183 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu title: "", metadata: { output: "account.ts\n", - description: "", }, time: { start: 200, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9c22e0041..d049041fa 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1811,7 +1811,6 @@ unix( yield* llm.tool("bash", { command: 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30', - description: "Print many lines", timeout: 30_000, workdir: path.resolve(dir), }) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 8a3701e12..98233f352 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { command, - description: "create test file", }) yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index b187b191c..51ff867ea 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -24,23 +24,6 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = ` "description": "The command to execute", "type": "string", }, - "description": { - "description": -"Clear, concise description of what this command does in 5-10 words. Examples: -Input: ls -Output: Lists files in current directory - -Input: git status -Output: Shows working tree status - -Input: npm install -Output: Installs package dependencies - -Input: mkdir foo -Output: Creates directory 'foo'" -, - "type": "string", - }, "timeout": { "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, @@ -55,7 +38,6 @@ Output: Creates directory 'foo'" }, "required": [ "command", - "description", ], "type": "object", } diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 4e56c61d2..9c540daad 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -106,19 +106,16 @@ describe("tool parameters", () => { }) describe("shell", () => { - test("accepts minimum: command + description", () => { - expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + test("accepts command", () => { + expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description", () => { - expect(accepts(Shell, { command: "ls" })).toBe(false) - }) test("rejects missing command", () => { - expect(accepts(Shell, { description: "list" })).toBe(false) + expect(accepts(Shell, {})).toBe(false) }) }) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 2ea1d1458..a3c6ca27b 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -182,7 +182,6 @@ describe("tool.shell", () => { Effect.gen(function* () { const result = yield* run({ command: "echo test", - description: "Echo test message", }) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") @@ -204,7 +203,6 @@ describe("tool.shell", () => { const result = yield* bash.execute( { command: "echo fallback", - description: "Echo fallback text", }, ctx, ) @@ -227,7 +225,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo hello", - description: "Echo hello", }, capture(requests), ) @@ -249,7 +246,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo foo && echo bar", - description: "Echo twice", }, capture(requests), ) @@ -273,7 +269,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", }, capture(requests), ) @@ -303,7 +298,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Remove-Item -Recurse tmp", - description: "Remove a temp directory", }, capture(requests, err), ), @@ -331,7 +325,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${file}`, - description: "Read wildcard path", }, capture(requests, err), ), @@ -359,7 +352,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `echo $(cat "${file}")`, - description: "Read nested bash file", }, capture(requests), ) @@ -389,7 +381,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", }, capture(requests, err), ), @@ -415,7 +406,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", }, capture(requests), ) @@ -446,7 +436,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "C:../outside.txt"', - description: "Read drive-relative file", }, capture(requests, err), ), @@ -474,7 +463,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", }, capture(requests, err), ), @@ -503,7 +491,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PWD/../outside.txt"', - description: "Read pwd-relative file", }, capture(requests, err), ), @@ -531,7 +518,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", }, capture(requests, err), ), @@ -567,7 +553,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, - description: "Read Windows ini with missing env", }, capture(requests, err), ), @@ -598,7 +583,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", }, capture(requests), ) @@ -626,7 +610,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", }, capture(requests, err), ), @@ -655,7 +638,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", }, capture(requests, err), ), @@ -682,7 +664,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Set-Location C:/Windows", - description: "Change location", }, capture(requests), ) @@ -710,7 +691,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Output ('a' * 3)", - description: "Write repeated text", }, capture(requests), ) @@ -736,7 +716,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, - description: "Read Windows ini with cmd", }, capture(requests), ) @@ -761,7 +740,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cd ../", - description: "Change to parent directory", }, capture(requests, err), ), @@ -786,7 +764,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: os.tmpdir(), - description: "Echo from temp dir", }, capture(requests, err), ), @@ -817,7 +794,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: dir, - description: "Echo from external dir", }, capture(requests, err), ), @@ -850,7 +826,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: "/tmp", - description: "Echo from Git Bash tmp", }, capture(requests, err), ), @@ -878,7 +853,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", }, capture(requests, err), ), @@ -910,7 +884,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${filepath}`, - description: "Read external file", }, capture(requests, err), ), @@ -922,7 +895,6 @@ describe("tool.shell permissions", () => { expect(extDirReq!.always).toContain(expected) expect(extDirReq!.metadata).toMatchObject({ command: `cat ${filepath}`, - description: "Read external file", directories: [outerTmp], patterns: [expected], }) @@ -942,7 +914,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `rm -rf ${path.join(tmp, "nested")}`, - description: "Remove nested dir", }, capture(requests), ) @@ -963,7 +934,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "git log --oneline -5", - description: "Git log", }, capture(requests), ) @@ -985,7 +955,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "cd .", - description: "Stay in current directory", }, capture(requests), ) @@ -1006,7 +975,7 @@ describe("tool.shell permissions", () => { const requests: Array> = [] expect( yield* fail( - { command: "echo test > output.txt", description: "Redirect test output" }, + { command: "echo test > output.txt" }, capture(requests, err), ), ).toMatchObject({ message: err.message }) @@ -1025,7 +994,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const requests: Array> = [] - yield* run({ command: "ls -la", description: "List" }, capture(requests)) + yield* run({ command: "ls -la" }, capture(requests)) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") @@ -1047,7 +1016,6 @@ describe("tool.shell abort", () => { const res = yield* run( { command: `echo before && sleep 30`, - description: "Long running command", }, { ...ctx, @@ -1078,7 +1046,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `sleep 60`, - description: "Timeout test", timeout: 500, }) expect(result.output).toContain("shell tool terminated command after exceeding timeout") @@ -1099,7 +1066,6 @@ describe("tool.shell abort", () => { const result = yield* tool.execute( { command: `sleep 60`, - description: "Default timeout test", }, ctx, ) @@ -1116,7 +1082,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", }) expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stderr_msg") @@ -1132,7 +1097,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `exit 42`, - description: "Non-zero exit", }) expect(result.metadata.exit).toBe(42) }), @@ -1147,7 +1111,6 @@ describe("tool.shell abort", () => { const result = yield* run( { command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", }, { ...ctx, @@ -1174,7 +1137,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1190,7 +1152,6 @@ describe("tool.shell truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = yield* run({ command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1205,7 +1166,6 @@ describe("tool.shell truncation", () => { Effect.gen(function* () { const result = yield* run({ command: fill("lines", 1), - description: "Generate one line", }) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect(result.output).toContain("1") @@ -1220,7 +1180,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines for file check", }) mustTruncate(result) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 11d8b4765..b368110f4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1994,7 +1994,7 @@ export function InlineToolRow(props: { } function BlockTool(props: { - title: string + title?: string children: JSX.Element onClick?: () => void part?: ToolPart @@ -2023,15 +2023,19 @@ function BlockTool(props: { props.onClick?.() }} > - - {props.title} - - } - > - {props.title.replace(/^# /, "")} + + {(title) => ( + + {title()} + + } + > + {title().replace(/^# /, "")} + + )} {props.children} @@ -2059,15 +2063,15 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = stringValue(props.input.workdir) if (!workdir || workdir === ".") return undefined - return pathFormatter.format(workdir) + const formatted = pathFormatter.format(workdir) + if (formatted === ".") return undefined + return formatted }) const title = createMemo(() => { - const desc = stringValue(props.input.description) ?? "Shell" const wd = workdirDisplay() - if (!wd) return `# ${desc}` - if (desc.includes(wd)) return `# ${desc}` - return `# ${desc} in ${wd}` + if (!wd) return + return `# Running in ${wd}` }) return ( @@ -2076,11 +2080,15 @@ function Shell(props: ToolProps) { setExpanded((prev) => !prev) : undefined} > - $ {stringValue(props.input.command)} + $ {stringValue(props.input.command)}} + > + {stringValue(props.input.command)} + {limited()} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index c787a84a8..766ed3c11 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -269,12 +269,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "bash") { - const title = - typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" return { icon: "#", - title, + title: "Shell command", body: ( diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 13110be0b..46c48ef32 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -27,8 +27,6 @@ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = ` " - # List files - $ ls file.ts diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 00e4ef5c9..8ba730906 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -62,7 +62,6 @@ function ShellOutput() { paddingLeft={2} gap={1} > - # List files $ ls file.ts diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 213a48e11..2477ff99b 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,4 @@ -import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" +import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls } from "motion" import { useI18n } from "../context/i18n" import { createStore } from "solid-js/store" @@ -24,7 +24,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { export interface BasicToolProps { icon: IconProps["name"] - trigger: TriggerTitle | JSX.Element + trigger: TriggerTitle | JSX.Element | ((open: Accessor) => JSX.Element) children?: JSX.Element status?: string hideDetails?: boolean @@ -89,6 +89,7 @@ export function BasicTool(props: BasicToolProps) { const ready = () => state.ready const pending = () => props.status === "pending" || props.status === "running" const hasChildren = () => (props.defer ? "children" in props : props.children) + const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined let cancelReady: (() => void) | undefined @@ -187,6 +188,7 @@ export function BasicTool(props: BasicToolProps) {
+ {dynamicTrigger} {(title) => (
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index bbc3d2956..47ae745e4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -422,7 +422,7 @@ export function getToolInfo( return { icon: "console", title: i18n.t("ui.tool.shell"), - subtitle: input.description, + subtitle: input.command, } case "edit": return { @@ -1905,18 +1905,18 @@ ToolRegistry.register({ (
- - + +
- } + )} >
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 3a59c839a..a189e3d0f 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -316,10 +316,10 @@ const TOOL_SAMPLES = { }, bash: { tool: "bash", - input: { command: "bun test --filter session", description: "Run session tests" }, + input: { command: "bun test --filter session" }, output: "bun test v1.3.14\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", - title: "Run session tests", + title: "bun test --filter session", metadata: { command: "bun test --filter session" }, }, edit: { diff --git a/packages/web/src/components/share/content-bash.tsx b/packages/web/src/components/share/content-bash.tsx index f8130ecc6..14fd6df6c 100644 --- a/packages/web/src/components/share/content-bash.tsx +++ b/packages/web/src/components/share/content-bash.tsx @@ -6,7 +6,6 @@ import { codeToHtml } from "shiki" interface Props { command: string output: string - description?: string expand?: boolean } @@ -45,7 +44,7 @@ export function ContentBash(props: Props) {
- {props.description} + Shell
diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 34cfe3f42..67099196a 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -616,7 +616,6 @@ export function BashTool(props: ToolProps) { ) } diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index cb90e305e..bfe7efd89 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -714,6 +714,22 @@ Compatibility: - Foreground V2 bash execution is unchanged. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. +## 2026-06-18: Remove Bash Description Input + +Affected schema: + +- V1 and Core V2 model-facing `bash` tool parameters. + +Change: + +- Remove the V1 required and V2 optional `description` parameter. +- Derive shell presentation from the command or a generic shell label instead of model-authored description metadata. + +Compatibility: + +- Existing persisted tool calls may still contain `description`, but new tool definitions no longer expose or require it. +- Shell command execution behavior is unchanged. + ## 2026-06-04: Add Durable Session Context Snapshots Affected schema: