diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 9747762001e..d05a82cb207 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -237,12 +237,17 @@ type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["se const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly messageID: Endpoint4_19Request["params"]["messageID"] -} +type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => + raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly messageID: Endpoint4_20Request["params"]["messageID"] +} +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -268,7 +273,8 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ history: Endpoint4_16(raw), events: Endpoint4_17(raw), interrupt: Endpoint4_18(raw), - message: Endpoint4_19(raw), + background: Endpoint4_19(raw), + message: Endpoint4_20(raw), }) type Endpoint5_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 716fec0cd8b..9f03e0be4f7 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -43,6 +43,8 @@ import type { SessionEventsOutput, SessionInterruptInput, SessionInterruptOutput, + SessionBackgroundInput, + SessionBackgroundOutput, SessionMessageInput, SessionMessageOutput, MessageListInput, @@ -561,6 +563,17 @@ export function make(options: ClientOptions) { }, requestOptions, ), + background: (input: SessionBackgroundInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/background`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), message: (input: SessionMessageInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionMessageOutput }>( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 4ce3845cba3..21ee2cffddb 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -1775,6 +1775,10 @@ export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: export type SessionInterruptOutput = void +export type SessionBackgroundInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionBackgroundOutput = void + export type SessionMessageInput = { readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"] readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"] diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 5b439f9abee..e86f960ba55 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -19,7 +19,7 @@ export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_CAPTURE_BYTES = 1024 * 1024 const BACKGROUND_STARTED = - "The command is running in the background. You will be notified automatically when it completes. DO NOT sleep, poll, or proactively check on its progress." + "The command has not completed; it is now running in the background." export const Input = Schema.Struct({ command: Schema.String.annotate({ description: "Shell command string to execute" }), @@ -185,75 +185,80 @@ export const Plugin = { return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) 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* () { - return yield* Effect.gen(function* () { - 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.` - - const truncated = page.size > page.cursor - 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(background.id).pipe(Effect.ignore))) - }) - - const info = yield* runtime.job.start({ - id: context.toolCallID, - type: name, - title: input.command, - metadata: { sessionID: context.sessionID }, - run: run(), - }) - yield* runtime.job.background(info.id) - yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) - return { - output: BACKGROUND_STARTED, - shellID: background.id, - truncated: false, - status: "running" as const, - ...(warnings.length ? { warnings } : {}), - } - } - const info = yield* shell.create({ command: input.command, cwd: target.canonical, timeout, metadata: { sessionID: context.sessionID }, }) - const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) - if (final.status === "timeout") { + const settleShell = Effect.fn("ShellTool.settleShell")(function* () { + const final = yield* shell.wait(info.id) + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + + if (final.status === "timeout") { + return { + exit: final.exit, + output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, + truncated: false, + timeout: true, + status: "completed" as const, + } + } + + const truncated = page.size > page.cursor + const body = page.output || "(no output)" + const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" return { exit: final.exit, - output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, - truncated: false, - timeout: true, + output: `${body}${notice}`, + truncated, status: "completed" as const, + } + }) + + const run = settleShell().pipe( + Effect.map((output) => output.output), + Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)), + ) + const job = yield* runtime.job.start({ + id: context.toolCallID, + type: name, + title: input.command, + metadata: { sessionID: context.sessionID, shellID: info.id }, + run, + }) + + if (input.background === true) { + yield* runtime.job.background(job.id) + yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) + return { + output: BACKGROUND_STARTED, + shellID: info.id, + truncated: false, + status: "running" as const, ...(warnings.length ? { warnings } : {}), } } - const truncated = page.size > page.cursor - const body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe( + Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)), + ) + if (result?.type === "backgrounded") { + yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) + return { + output: BACKGROUND_STARTED, + shellID: info.id, + truncated: false, + status: "running" as const, + ...(warnings.length ? { warnings } : {}), + } + } + if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed")) + if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled")) + return { - exit: final.exit, - output: `${body}${notice}`, - truncated, - status: "completed" as const, + ...(yield* settleShell()), ...(warnings.length ? { warnings } : {}), } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 83c94a5c7be..f95d8377ea7 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" import path from "path" import { describe, expect, test } from "bun:test" -import { DateTime, Effect, Layer } from "effect" +import { DateTime, Effect, Fiber, Layer, Scope } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -454,6 +454,51 @@ describe("ShellTool", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) + + it.live("backgrounds a foreground command when the session is signaled", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const jobs = yield* Job.Service + const scope = yield* Scope.Scope + const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe( + Effect.forkIn(scope, { startImmediately: true }), + ) + + const backgroundWhenReady = (remaining = 1000): Effect.Effect => + Effect.gen(function* () { + const backgrounded = yield* jobs.backgroundAll({ sessionID }) + if (backgrounded.length > 0) return backgrounded + if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell job")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* backgroundWhenReady(remaining - 1) + }) + expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }]) + + const settled = yield* Fiber.join(waiting) + 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(settled.output?.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("running in the background"), + }) + 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/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index 5a2bb465b01..86a7eb60d82 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -182,13 +182,18 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param export type Endpoint4_18Output = EffectValue> export type SessionInterruptOperation = (input: Endpoint4_18Input) => Effect.Effect -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly messageID: Endpoint4_19Request["params"]["messageID"] +type Endpoint4_19Request = Parameters[0] +export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } +export type Endpoint4_19Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_19Input) => Effect.Effect + +type Endpoint4_20Request = Parameters[0] +export type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly messageID: Endpoint4_20Request["params"]["messageID"] } -export type Endpoint4_19Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_19Input) => Effect.Effect +export type Endpoint4_20Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_20Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -210,6 +215,7 @@ export interface SessionApi { readonly history: SessionHistoryOperation readonly events: SessionEventsOperation readonly interrupt: SessionInterruptOperation + readonly background: SessionBackgroundOperation readonly message: SessionMessageOperation } diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 291a75f7f03..fb812973de0 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -411,6 +411,22 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.background", "/api/session/:sessionID/background", { + params: { sessionID: Session.ID }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.background", + summary: "Background blocking session tools", + description: + "Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.", + }), + ), + ) .add( HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { params: { sessionID: Session.ID, messageID: SessionMessage.ID }, diff --git a/packages/schema/src/tui-event.ts b/packages/schema/src/tui-event.ts index 800094e61e8..d4680503465 100644 --- a/packages/schema/src/tui-event.ts +++ b/packages/schema/src/tui-event.ts @@ -19,6 +19,7 @@ export const CommandExecute = Event.define({ "session.new", "session.share", "session.interrupt", + "session.background", "session.compact", "session.page.up", "session.page.down", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 3039b1f82d1..145ae005b3a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -309,6 +309,8 @@ import type { V2PermissionSavedListResponses, V2PermissionSavedRemoveErrors, V2PermissionSavedRemoveResponses, + V2PluginListErrors, + V2PluginListResponses, V2ProjectCopyCreateErrors, V2ProjectCopyCreateResponses, V2ProjectCopyRefreshErrors, @@ -343,6 +345,8 @@ import type { V2ReferenceListResponses, V2SessionActiveErrors, V2SessionActiveResponses, + V2SessionBackgroundErrors, + V2SessionBackgroundResponses, V2SessionCompactErrors, V2SessionCompactResponses, V2SessionContextErrors, @@ -5107,6 +5111,30 @@ export class Agent extends HeyApiClient { } } +export class Plugin extends HeyApiClient { + /** + * List plugins + * + * Retrieve currently loaded plugins. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/plugin", + ...options, + ...params, + }) + } +} + export class Revert extends HeyApiClient { /** * Stage session revert @@ -5926,6 +5954,27 @@ export class Session3 extends HeyApiClient { }) } + /** + * Background blocking session tools + * + * Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op. + */ + public background( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post( + { + url: "/api/session/{sessionID}/background", + ...options, + ...params, + }, + ) + } + /** * Get session message * @@ -7436,6 +7485,11 @@ export class V2 extends HeyApiClient { return (this._agent ??= new Agent({ client: this.client })) } + private _plugin?: Plugin + get plugin(): Plugin { + return (this._plugin ??= new Plugin({ client: this.client })) + } + private _session?: Session3 get session(): Session3 { return (this._session ??= new Session3({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index dbc173c829c..938f916ca2c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1502,6 +1502,7 @@ export type GlobalEvent = { | "session.new" | "session.share" | "session.interrupt" + | "session.background" | "session.compact" | "session.page.up" | "session.page.down" @@ -2707,6 +2708,7 @@ export type EventTuiCommandExecute = { | "session.new" | "session.share" | "session.interrupt" + | "session.background" | "session.compact" | "session.page.up" | "session.page.down" @@ -3134,6 +3136,7 @@ export type EventTuiCommandExecute2 = { | "session.new" | "session.share" | "session.interrupt" + | "session.background" | "session.compact" | "session.page.up" | "session.page.down" @@ -4111,6 +4114,10 @@ export type AgentV2Info = { permissions: PermissionV2Ruleset } +export type PluginInfo = { + id: string +} + export type SessionV2Info = { id: string parentID?: string @@ -6171,6 +6178,7 @@ export type TuiCommandExecute = { | "session.new" | "session.share" | "session.interrupt" + | "session.background" | "session.compact" | "session.page.up" | "session.page.down" @@ -11817,6 +11825,43 @@ export type V2AgentListResponses = { export type V2AgentListResponse = V2AgentListResponses[keyof V2AgentListResponses] +export type V2PluginListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/plugin" +} + +export type V2PluginListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PluginListError = V2PluginListErrors[keyof V2PluginListErrors] + +export type V2PluginListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2PluginListResponse = V2PluginListResponses[keyof V2PluginListResponses] + export type V2SessionListData = { body?: never path?: never @@ -12570,6 +12615,41 @@ export type V2SessionInterruptResponses = { export type V2SessionInterruptResponse = V2SessionInterruptResponses[keyof V2SessionInterruptResponses] +export type V2SessionBackgroundData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/background" +} + +export type V2SessionBackgroundErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionBackgroundError = V2SessionBackgroundErrors[keyof V2SessionBackgroundErrors] + +export type V2SessionBackgroundResponses = { + /** + * + */ + 204: void +} + +export type V2SessionBackgroundResponse = V2SessionBackgroundResponses[keyof V2SessionBackgroundResponses] + export type V2SessionMessageData = { body?: never path: { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index d026ca8d16c..c41374522bc 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -3,6 +3,7 @@ import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" +import { Job } from "@opencode-ai/core/job" import { ConflictError, InvalidCursorError, @@ -21,6 +22,7 @@ const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service + const jobs = yield* Job.Service return handlers .handle( @@ -481,6 +483,48 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.background", + Effect.fn(function* (ctx) { + yield* session.get(ctx.params.sessionID).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + const backgrounded = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID }) + if (backgrounded.length > 0) + yield* session + .synthetic({ + sessionID: ctx.params.sessionID, + text: [ + "User requested that active blocking work be moved to the background.", + "", + "Backgrounded work:", + ...backgrounded.map( + (job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`, + ), + "", + "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.", + ].join("\n"), + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.message", Effect.fn(function* (ctx) { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 625c5567a8a..db096396df6 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -8,6 +8,7 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { Job } from "@opencode-ai/core/job" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" @@ -30,6 +31,7 @@ const applicationServices = LayerNode.group([ EventV2.node, httpClient, ToolOutputStore.cleanupNode, + Job.node, SessionV2.node, PluginRuntime.providerNode, PermissionSaved.node, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 53c6a520cb5..46324e06d6f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -435,6 +435,23 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + title: "Background blocking tools", + name: "session.background", + category: "Session", + hidden: true, + enabled: status() === "running", + run: () => { + if (auto()?.visible) return + if (!input.focused) return + if (!props.sessionID) return + + void sdk.api.session.background({ + sessionID: props.sessionID, + }) + dialog.clear() + }, + }, { title: "Open editor", category: "Session", @@ -590,6 +607,7 @@ export function Prompt(props: PromptProps) { "prompt.stash.list", "prompt.skills", "session.interrupt", + "session.background", "workspace.set", "session.move", ]), diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 878ed340937..af1ff0f5a7e 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -94,7 +94,7 @@ export const Definitions = { session_share: keybind("none", "Share current session"), session_unshare: keybind("none", "Unshare current session"), session_interrupt: keybind("escape", "Interrupt current session"), - session_background: keybind("ctrl+b", "Background synchronous subagents"), + session_background: keybind("ctrl+b", "Background blocking session tools"), session_compact: keybind("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 04b28465e72..61f03288754 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -782,11 +782,14 @@ export function Session() { }, }, { - title: "Background subagents", + title: "Background blocking tools", value: "session.background", category: "Session", hidden: true, - run: () => unavailable("Backgrounding subagents"), + run: () => { + void sdk.api.session.background({ sessionID: route.sessionID }) + dialog.clear() + }, }, { title: "Toggle subagent picker", @@ -920,6 +923,7 @@ export function Session() { /> )} + { + const current = props.messages.findLast( + (message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, + ) + return ( + current?.content.some((part) => { + if (part.type !== "tool" || part.state.status !== "running") return false + const display = toolDisplay(part.name) + return display === "shell" || display === "subagent" + }) ?? false + ) + }) + return ( + + {(value) => ( + + + Press {value()} to move running work to the background + + + )} + + ) +} + function SessionMessageView(props: { message: SessionMessage }) { return (