fix(tui): keep background shell spinner active
This commit is contained in:
@@ -39,6 +39,7 @@ export const Input = Schema.Struct({
|
|||||||
|
|
||||||
const StructuredOutput = Schema.Struct({
|
const StructuredOutput = Schema.Struct({
|
||||||
exit: Schema.Number.pipe(Schema.optional),
|
exit: Schema.Number.pipe(Schema.optional),
|
||||||
|
shellID: Schema.String.pipe(Schema.optional),
|
||||||
truncated: Schema.Boolean,
|
truncated: Schema.Boolean,
|
||||||
timeout: Schema.Boolean.pipe(Schema.optional),
|
timeout: Schema.Boolean.pipe(Schema.optional),
|
||||||
})
|
})
|
||||||
@@ -142,6 +143,7 @@ export const Plugin = {
|
|||||||
toStructuredOutput: ({ output }) => ({
|
toStructuredOutput: ({ output }) => ({
|
||||||
truncated: output.truncated,
|
truncated: output.truncated,
|
||||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||||
|
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
|
||||||
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
|
||||||
}),
|
}),
|
||||||
toModelOutput: ({ output }) => {
|
toModelOutput: ({ output }) => {
|
||||||
@@ -185,16 +187,16 @@ export const Plugin = {
|
|||||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||||
|
|
||||||
if (input.background === true) {
|
if (input.background === true) {
|
||||||
const run = Effect.fn("ShellTool.run")(function* () {
|
const background = yield* shell.create({
|
||||||
const info = yield* shell.create({
|
|
||||||
command: input.command,
|
command: input.command,
|
||||||
cwd: target.canonical,
|
cwd: target.canonical,
|
||||||
timeout,
|
timeout,
|
||||||
metadata: { sessionID: context.sessionID },
|
metadata: { sessionID: context.sessionID },
|
||||||
})
|
})
|
||||||
|
const run = Effect.fn("ShellTool.run")(function* () {
|
||||||
return yield* Effect.gen(function* () {
|
return yield* Effect.gen(function* () {
|
||||||
const final = yield* shell.wait(info.id)
|
const final = yield* shell.wait(background.id)
|
||||||
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
|
const page = yield* shell.output(background.id, { limit: MAX_CAPTURE_BYTES })
|
||||||
|
|
||||||
if (final.status === "timeout")
|
if (final.status === "timeout")
|
||||||
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
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 body = page.output || "(no output)"
|
||||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
|
||||||
return `${body}${notice}`
|
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({
|
const info = yield* runtime.job.start({
|
||||||
@@ -217,6 +219,7 @@ export const Plugin = {
|
|||||||
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
|
||||||
return {
|
return {
|
||||||
output: BACKGROUND_STARTED,
|
output: BACKGROUND_STARTED,
|
||||||
|
shellID: background.id,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
status: "running" as const,
|
status: "running" as const,
|
||||||
...(warnings.length ? { warnings } : {}),
|
...(warnings.length ? { warnings } : {}),
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
|||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
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 { ShellTool } from "@opencode-ai/core/tool/shell"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
@@ -398,6 +400,31 @@ describe("ShellTool", () => {
|
|||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
(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 () => {
|
test("keeps locked deferred parity TODOs visible", async () => {
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
.length,
|
.length,
|
||||||
)
|
)
|
||||||
const runningShells = createMemo(
|
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 history = usePromptHistory()
|
||||||
const stash = usePromptStash()
|
const stash = usePromptStash()
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ type LocationData = {
|
|||||||
model?: ModelV2Info[]
|
model?: ModelV2Info[]
|
||||||
provider?: ProviderV2Info[]
|
provider?: ProviderV2Info[]
|
||||||
reference?: ReferenceInfo[]
|
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<string, Shell>
|
||||||
skill?: SkillV2Info[]
|
skill?: SkillV2Info[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +53,6 @@ type Data = {
|
|||||||
permission: Record<string, PermissionSavedInfo[]>
|
permission: Record<string, PermissionSavedInfo[]>
|
||||||
}
|
}
|
||||||
location: Record<string, LocationData>
|
location: Record<string, LocationData>
|
||||||
// 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<string, Shell>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function locationKey(location: LocationRef) {
|
function locationKey(location: LocationRef) {
|
||||||
@@ -86,7 +86,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
permission: {},
|
permission: {},
|
||||||
},
|
},
|
||||||
location: {},
|
location: {},
|
||||||
shell: {},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -510,14 +509,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
case "shell.created":
|
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
|
break
|
||||||
case "shell.exited":
|
case "shell.exited":
|
||||||
case "shell.deleted":
|
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(
|
setStore(
|
||||||
"shell",
|
"location",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
delete draft[event.data.id]
|
for (const data of Object.values(draft)) delete data.shell?.[event.data.id]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
@@ -621,24 +630,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
shell: {
|
shell: {
|
||||||
list() {
|
list(location?: LocationRef) {
|
||||||
return Object.values(store.shell)
|
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
|
||||||
},
|
},
|
||||||
get(id: string) {
|
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) {
|
async refresh(ref?: LocationRef) {
|
||||||
const result = await sdk.api.shell.list({ location: locationQuery(ref) })
|
const result = await sdk.api.shell.list({ location: locationQuery(ref) })
|
||||||
setStore(
|
const key = locationKey(result.location)
|
||||||
"shell",
|
setStore("location", key, {
|
||||||
produce((draft) => {
|
...store.location[key],
|
||||||
for (const info of mutable(result.data)) draft[info.id] = info
|
shell: Object.fromEntries(mutable(result.data).map((info) => [info.id, info])),
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
},
|
},
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
await sdk.api.shell.remove({ id })
|
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: {
|
location: {
|
||||||
|
|||||||
@@ -1672,11 +1672,20 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
|||||||
|
|
||||||
function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
|
const data = useData()
|
||||||
const display = createMemo(() => toolDisplay(props.part.name))
|
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
|
// Hide tool if showDetails is false and tool completed successfully
|
||||||
const shouldHide = createMemo(() => {
|
const shouldHide = createMemo(() => {
|
||||||
if (ctx.showDetails()) return false
|
if (ctx.showDetails()) return false
|
||||||
|
if (runningShell()) return false
|
||||||
if (props.part.state.status !== "completed") return false
|
if (props.part.state.status !== "completed") return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -1700,6 +1709,9 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||||||
get part() {
|
get part() {
|
||||||
return props.part
|
return props.part
|
||||||
},
|
},
|
||||||
|
get runningShell() {
|
||||||
|
return runningShell()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1758,6 +1770,7 @@ type ToolProps = {
|
|||||||
tool: string
|
tool: string
|
||||||
output?: string
|
output?: string
|
||||||
part: SessionMessageAssistantTool
|
part: SessionMessageAssistantTool
|
||||||
|
runningShell?: boolean
|
||||||
}
|
}
|
||||||
function GenericTool(props: ToolProps) {
|
function GenericTool(props: ToolProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -2003,7 +2016,7 @@ function Shell(props: ToolProps) {
|
|||||||
return request?.source?.type === "tool" && request.source.callID === props.part.id
|
return request?.source?.type === "tool" && request.source.callID === props.part.id
|
||||||
})
|
})
|
||||||
const color = createMemo(() => (permission() ? theme.warning : theme.text))
|
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 command = createMemo(() => stringValue(props.input.command))
|
||||||
const output = createMemo(() => {
|
const output = createMemo(() => {
|
||||||
if (props.part.state.status === "pending") return ""
|
if (props.part.state.status === "pending") return ""
|
||||||
|
|||||||
@@ -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<typeof useData>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
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 () => {
|
test("adds and dismisses permission requests from live events", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||||||
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||||
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
|
if (url.pathname === "/api/project/proj_test/directories") return json([{ directory: worktree }])
|
||||||
if (url.pathname === "/api/shell") return json({ data: [] })
|
if (url.pathname === "/api/shell") return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||||
|
if (url.pathname === "/api/mcp") return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in New Issue
Block a user