Parcourir la source

refactor(core): remove shell description input (#32823)

Aiden Cline il y a 2 mois
Parent
commit
d29f5eba92
27 fichiers modifiés avec 124 ajouts et 161 suppressions
  1. 3 2
      packages/app/e2e/smoke/session-timeline.fixture.ts
  2. 12 0
      packages/app/e2e/smoke/session-timeline.spec.ts
  3. 0 3
      packages/core/src/tool/bash.ts
  4. 2 3
      packages/core/test/tool-bash.test.ts
  5. 1 1
      packages/opencode/src/acp/tool.ts
  6. 6 9
      packages/opencode/src/cli/cmd/run/tool.ts
  7. 2 2
      packages/opencode/src/session/prompt.ts
  8. 2 10
      packages/opencode/src/tool/shell.ts
  9. 3 17
      packages/opencode/src/tool/shell/prompt.ts
  10. 33 6
      packages/opencode/test/cli/run/scrollback.surface.test.ts
  11. 0 2
      packages/opencode/test/cli/run/session-data.test.ts
  12. 0 1
      packages/opencode/test/cli/run/session-replay.test.ts
  13. 0 1
      packages/opencode/test/session/prompt.test.ts
  14. 0 1
      packages/opencode/test/session/snapshot-tool-race.test.ts
  15. 0 18
      packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap
  16. 4 7
      packages/opencode/test/tool/parameters.test.ts
  17. 2 43
      packages/opencode/test/tool/shell.test.ts
  18. 25 17
      packages/tui/src/routes/session/index.tsx
  19. 1 3
      packages/tui/src/routes/session/permission.tsx
  20. 0 2
      packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap
  21. 0 1
      packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx
  22. 4 2
      packages/ui/src/components/basic-tool.tsx
  23. 5 5
      packages/ui/src/components/message-part.tsx
  24. 2 2
      packages/ui/src/components/timeline-playground.stories.tsx
  25. 1 2
      packages/web/src/components/share/content-bash.tsx
  26. 0 1
      packages/web/src/components/share/part.tsx
  27. 16 0
      specs/v2/schema-changelog.md

+ 3 - 2
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,
   },
 }
 

+ 12 - 0
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)
   })
 })
 

+ 0 - 3
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({

+ 2 - 3
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: {

+ 1 - 1
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
 }
 

+ 6 - 9
packages/opencode/src/cli/cmd/run/tool.ts

@@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps<typeof QuestionTool>): ToolSnapshot {
 
 function scrollBashStart(p: ToolProps<typeof BashTool>): 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<typeof BashTool>): string {
@@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo {
 }
 
 function permBash(p: ToolPermissionProps<typeof BashTool>): 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}`),
   }
 }

+ 2 - 2
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)
                   }
                 }),

+ 2 - 10
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<shell_metadata>\n" + meta.join("\n") + "\n</shell_metadata>"
       }
       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,
               )

+ 3 - 17
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<typeof Parameters>
 
 function renderPrompt(template: string, values: Record<string, string>) {
@@ -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(),
   }
 }
 

+ 33 - 6
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",

+ 0 - 2
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 },
         },

+ 0 - 1
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,

+ 0 - 1
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),
       })

+ 0 - 1
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")
 

+ 0 - 18
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",
 }

+ 4 - 7
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)
     })
   })
 

+ 2 - 43
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<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
           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<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
-          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)
 

+ 25 - 17
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?.()
       }}
     >
-      <Show
-        when={props.spinner}
-        fallback={
-          <text paddingLeft={3} fg={theme.textMuted}>
-            {props.title}
-          </text>
-        }
-      >
-        <Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
+      <Show when={props.title}>
+        {(title) => (
+          <Show
+            when={props.spinner}
+            fallback={
+              <text paddingLeft={3} fg={theme.textMuted}>
+                {title()}
+              </text>
+            }
+          >
+            <Spinner color={theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
+          </Show>
+        )}
       </Show>
       {props.children}
       <Show when={error()}>
@@ -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) {
         <BlockTool
           title={title()}
           part={props.part}
-          spinner={isRunning()}
           onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
         >
           <box gap={1}>
-            <text fg={theme.text}>$ {stringValue(props.input.command)}</text>
+            <Show
+              when={isRunning()}
+              fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}
+            >
+              <Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
+            </Show>
             <Show when={output()}>
               <text fg={theme.text}>{limited()}</text>
             </Show>

+ 1 - 3
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: (
                   <Show when={command}>
                     <box paddingLeft={1}>

+ 0 - 2
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

+ 0 - 1
packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx

@@ -62,7 +62,6 @@ function ShellOutput() {
       paddingLeft={2}
       gap={1}
     >
-      <text paddingLeft={3}># List files</text>
       <box gap={1}>
         <text>$ ls</text>
         <text>file.ts</text>

+ 4 - 2
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<boolean>) => 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) {
       <div data-slot="basic-tool-tool-trigger-content">
         <div data-slot="basic-tool-tool-info">
           <Switch>
+            <Match when={dynamicTrigger !== undefined}>{dynamicTrigger}</Match>
             <Match when={isTriggerTitle(props.trigger) && props.trigger}>
               {(title) => (
                 <div data-slot="basic-tool-tool-info-structured">

+ 5 - 5
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({
       <BasicTool
         {...props}
         icon="console"
-        trigger={
+        trigger={(open) => (
           <div data-slot="basic-tool-tool-info-structured">
             <div data-slot="basic-tool-tool-info-main">
               <span data-slot="basic-tool-tool-title">
                 <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
               </span>
-              <Show when={!pending() && props.input.description}>
-                <ShellSubmessage text={props.input.description} animate={sawPending} />
+              <Show when={!pending() && !open() && props.input.command}>
+                <ShellSubmessage text={props.input.command} animate={sawPending} />
               </Show>
             </div>
           </div>
-        }
+        )}
       >
         <div data-component="bash-output">
           <div data-slot="bash-copy">

+ 2 - 2
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: {

+ 1 - 2
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) {
     <div class={style.root} data-expanded={expanded() || props.expand === true ? true : undefined}>
       <div data-slot="body">
         <div data-slot="header">
-          <span>{props.description}</span>
+          <span>Shell</span>
         </div>
         <div data-slot="content">
           <div innerHTML={commandHtml()} />

+ 0 - 1
packages/web/src/components/share/part.tsx

@@ -616,7 +616,6 @@ export function BashTool(props: ToolProps) {
     <ContentBash
       command={props.state.input.command}
       output={props.state.metadata.output ?? props.state.metadata?.stdout}
-      description={props.state.metadata.description}
     />
   )
 }

+ 16 - 0
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: