feat: background blocking tools

This commit is contained in:
Dax Raad 2026-07-01 12:17:30 -04:00
parent a10733dbf4
commit e2bca216a2
15 changed files with 397 additions and 71 deletions

View file

@ -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<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly messageID: Endpoint4_19Request["params"]["messageID"]
}
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[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<RawClient["server.session"]["session.message"]>[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<RawClient["server.message"]["session.messages"]>[0]

View file

@ -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<SessionBackgroundOutput>(
{
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 }>(
{

View file

@ -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"]

View file

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

View file

@ -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<Job.Info[], Error> =>
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<string, unknown> | 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 () => {

View file

@ -182,13 +182,18 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_19Input = {
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
readonly messageID: Endpoint4_19Request["params"]["messageID"]
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly messageID: Endpoint4_20Request["params"]["messageID"]
}
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@ -210,6 +215,7 @@ export interface SessionApi<E = never> {
readonly history: SessionHistoryOperation<E>
readonly events: SessionEventsOperation<E>
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E>
}

View file

@ -411,6 +411,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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 },

View file

@ -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",

View file

@ -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<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2PluginListResponses, V2PluginListErrors, ThrowOnError>({
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<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
return (options?.client ?? this.client).post<V2SessionBackgroundResponses, V2SessionBackgroundErrors, ThrowOnError>(
{
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 }))

View file

@ -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<PluginInfo>
}
}
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 = {
/**
* <No Content>
*/
204: void
}
export type V2SessionBackgroundResponse = V2SessionBackgroundResponses[keyof V2SessionBackgroundResponses]
export type V2SessionMessageData = {
body?: never
path: {

View file

@ -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) {

View file

@ -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,

View file

@ -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",
]),

View file

@ -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("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),

View file

@ -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() {
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
@ -1037,6 +1041,34 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
)
}
function BackgroundToolHint(props: { messages: SessionMessage[] }) {
const { theme } = useTheme()
const shortcut = useCommandShortcut("session.background")
const visible = createMemo(() => {
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 (
<Show when={visible() && shortcut()}>
{(value) => (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<text fg={theme.textMuted}>
Press <span style={{ fg: theme.text }}>{value()}</span> to move running work to the background
</text>
</box>
)}
</Show>
)
}
function SessionMessageView(props: { message: SessionMessage }) {
return (
<Switch>