fix(tui): keep background shell spinner active

This commit is contained in:
Dax Raad 2026-06-30 21:56:45 -04:00
commit 24ab17e718
7 changed files with 159 additions and 28 deletions

View file

@ -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 } : {}),

View file

@ -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<string, unknown> | 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 () => {