diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 911bd72c13..5b439f9abe 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -39,6 +39,7 @@ export const Input = Schema.Struct({ const StructuredOutput = Schema.Struct({ exit: Schema.Number.pipe(Schema.optional), + shellID: Schema.String.pipe(Schema.optional), truncated: Schema.Boolean, timeout: Schema.Boolean.pipe(Schema.optional), }) @@ -142,6 +143,7 @@ export const Plugin = { toStructuredOutput: ({ output }) => ({ truncated: output.truncated, ...(output.exit === undefined ? {} : { exit: output.exit }), + ...(output.shellID === undefined ? {} : { shellID: output.shellID }), ...(output.timeout === undefined ? {} : { timeout: output.timeout }), }), toModelOutput: ({ output }) => { @@ -185,16 +187,16 @@ export const Plugin = { const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS if (input.background === true) { + const background = yield* shell.create({ + command: input.command, + cwd: target.canonical, + timeout, + metadata: { sessionID: context.sessionID }, + }) const run = Effect.fn("ShellTool.run")(function* () { - const info = yield* shell.create({ - command: input.command, - cwd: target.canonical, - timeout, - metadata: { sessionID: context.sessionID }, - }) return yield* Effect.gen(function* () { - const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + const final = yield* shell.wait(background.id) + const page = yield* shell.output(background.id, { limit: MAX_CAPTURE_BYTES }) if (final.status === "timeout") return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.` @@ -203,7 +205,7 @@ export const Plugin = { const body = page.output || "(no output)" const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" return `${body}${notice}` - }).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore))) + }).pipe(Effect.onInterrupt(() => shell.remove(background.id).pipe(Effect.ignore))) }) const info = yield* runtime.job.start({ @@ -217,6 +219,7 @@ export const Plugin = { yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) return { output: BACKGROUND_STARTED, + shellID: background.id, truncated: false, status: "running" as const, ...(warnings.length ? { warnings } : {}), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index b79638fa41..7bd3e0e339 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -25,6 +25,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionStore } from "@opencode-ai/core/session/store" import { PermissionV2 } from "@opencode-ai/core/permission" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { Shell } from "@opencode-ai/core/shell" +import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { ShellTool } from "@opencode-ai/core/tool/shell" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -398,6 +400,31 @@ describe("ShellTool", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) + + it.live("returns the shell id for a background command", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const settled = yield* settleTool(registry, call({ command: idleCommand, background: true })) + const structured = settled.output?.structured as Record | undefined + const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined + expect(settled.output?.structured).toMatchObject({ truncated: false }) + expect(shellID).toStartWith("sh_") + + const shell = yield* Shell.Service + if (!shellID) return + const id = ShellSchema.ID.make(shellID) + expect((yield* shell.list()).map((info) => info.id)).toContain(id) + yield* shell.remove(id) + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), + ) }) test("keeps locked deferred parity TODOs visible", async () => { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 85fb86cd02..53c6a520cb 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -168,7 +168,7 @@ export function Prompt(props: PromptProps) { .length, ) const runningShells = createMemo( - () => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length, + () => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, ) const history = usePromptHistory() const stash = usePromptStash() diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 6c44d0fc01..6522170845 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -35,6 +35,9 @@ type LocationData = { model?: ModelV2Info[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] + // Currently running shell commands for this location, keyed by shell id. Entries are removed + // once the command exits or is deleted, so this only ever holds in-flight shells. + shell?: Record skill?: SkillV2Info[] } @@ -50,9 +53,6 @@ type Data = { permission: Record } location: Record - // Currently running shell commands, keyed by shell id. Entries are removed once the command - // exits or is deleted, so this only ever holds in-flight shells. - shell: Record } function locationKey(location: LocationRef) { @@ -86,7 +86,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ permission: {}, }, location: {}, - shell: {}, }) const sdk = useSDK() @@ -510,14 +509,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) break case "shell.created": - setStore("shell", event.data.info.id, event.data.info) + setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({ + ...data, + shell: { ...data?.shell, [event.data.info.id]: event.data.info }, + })) break case "shell.exited": case "shell.deleted": + if (event.location) { + setStore("location", locationKey(event.location), (data) => ({ + ...data, + shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)), + })) + break + } setStore( - "shell", + "location", produce((draft) => { - delete draft[event.data.id] + for (const data of Object.values(draft)) delete data.shell?.[event.data.id] }), ) break @@ -621,24 +630,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, }, shell: { - list() { - return Object.values(store.shell) + list(location?: LocationRef) { + return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {}) }, get(id: string) { - return store.shell[id] + return Object.values(store.location) + .map((data) => data.shell?.[id]) + .find((shell) => shell !== undefined) }, async refresh(ref?: LocationRef) { const result = await sdk.api.shell.list({ location: locationQuery(ref) }) - setStore( - "shell", - produce((draft) => { - for (const info of mutable(result.data)) draft[info.id] = info - }), - ) + const key = locationKey(result.location) + setStore("location", key, { + ...store.location[key], + shell: Object.fromEntries(mutable(result.data).map((info) => [info.id, info])), + }) }, async remove(id: string) { await sdk.api.shell.remove({ id }) - setStore("shell", id, undefined!) + setStore( + "location", + produce((draft) => { + for (const data of Object.values(draft)) delete data.shell?.[id] + }), + ) }, }, location: { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d611a31b63..8cc13d76c6 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1672,11 +1672,20 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { function ToolPart(props: { part: SessionMessageAssistantTool }) { const ctx = use() + const data = useData() const display = createMemo(() => toolDisplay(props.part.name)) + const runningShell = createMemo( + () => { + if (display() !== "shell" || props.part.state.status === "pending") return false + const shellID = stringValue(props.part.state.structured.shellID) + return Boolean(shellID && data.shell.get(shellID)) + }, + ) // Hide tool if showDetails is false and tool completed successfully const shouldHide = createMemo(() => { if (ctx.showDetails()) return false + if (runningShell()) return false if (props.part.state.status !== "completed") return false return true }) @@ -1700,6 +1709,9 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { get part() { return props.part }, + get runningShell() { + return runningShell() + }, } return ( @@ -1758,6 +1770,7 @@ type ToolProps = { tool: string output?: string part: SessionMessageAssistantTool + runningShell?: boolean } function GenericTool(props: ToolProps) { const { theme } = useTheme() @@ -2003,7 +2016,7 @@ function Shell(props: ToolProps) { return request?.source?.type === "tool" && request.source.callID === props.part.id }) const color = createMemo(() => (permission() ? theme.warning : theme.text)) - const isRunning = createMemo(() => props.part.state.status === "running") + const isRunning = createMemo(() => props.part.state.status === "running" || props.runningShell === true) const command = createMemo(() => stringValue(props.input.command)) const output = createMemo(() => { if (props.part.state.status === "pending") return "" diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index e8750e79f5..0893003964 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -449,6 +449,78 @@ test("refreshes references after updates", async () => { } }) +test("keeps shell state scoped to location", async () => { + const events = createEventStream() + const other = "/tmp/opencode/other" + const calls = createFetch((url) => { + if (url.pathname !== "/api/shell") return + const requestDirectory = url.searchParams.get("location[directory]") + return json({ + location: { directory: requestDirectory ?? directory, project: { id: "proj_test", directory: requestDirectory ?? directory } }, + data: [ + { + id: requestDirectory === other ? "sh_other" : "sh_default", + status: "running", + command: requestDirectory === other ? "pnpm dev" : "bun test", + cwd: requestDirectory ?? directory, + shell: "/bin/sh", + file: "/tmp/opencode-shell", + metadata: { sessionID: requestDirectory === other ? "ses_other" : "ses_default" }, + time: { started: 1 }, + }, + ], + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.shell.list().some((shell) => shell.id === "sh_default")) + await data.shell.refresh({ directory: other }) + + expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"]) + expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"]) + + events.emit({ + id: "evt_shell_created", + type: "shell.created", + location: { directory: other }, + data: { + info: { + id: "sh_live_other", + status: "running", + command: "npm run watch", + cwd: other, + shell: "/bin/sh", + file: "/tmp/opencode-shell-live", + metadata: { sessionID: "ses_other" }, + time: { started: 2 }, + }, + }, + }) + await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other")) + expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"]) + } finally { + app.renderer.destroy() + } +}) + test("adds and dismisses permission requests from live events", async () => { const events = createEventStream() const calls = createFetch(undefined, events) diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 079617db6f..52d50bf3ac 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -95,7 +95,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType