diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ec048f86b2..fc2394779e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -49,6 +49,7 @@ import { ToastProvider, useToast } from "./ui/toast" import { ExitProvider, useExit } from "./context/exit" import { Session as SessionApi } from "@/session" import { TuiEvent } from "./event" +import { Bus } from "@/bus" import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" @@ -877,6 +878,19 @@ function App(props: { onSnapshot?: () => Promise }) { return render({ params: route.data.data }) }) + sdk.event.on(TuiEvent.RendererSuspendRequest.type, async (evt) => { + renderer.suspend() + renderer.currentRenderBuffer.clear() + await Bus.publish(TuiEvent.RendererSuspendAck, { token: evt.properties.token }) + }) + + sdk.event.on(TuiEvent.RendererResumeRequest.type, async (evt) => { + renderer.currentRenderBuffer.clear() + renderer.resume() + renderer.requestRender() + await Bus.publish(TuiEvent.RendererResumeAck, { token: evt.properties.token }) + }) + return ( entry[1] !== undefined), - ), + env: { + ...Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + OPENCODE_TUI: "1", + }, }) worker.onerror = (e) => { Log.Default.error(e) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e324962bf..1d454256eb 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,7 @@ export namespace Identifier { pty: "pty", tool: "tool", workspace: "wrk", + terminal: "trm", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/terminal/control.ts b/packages/opencode/src/terminal/control.ts new file mode 100644 index 0000000000..1657303664 --- /dev/null +++ b/packages/opencode/src/terminal/control.ts @@ -0,0 +1,123 @@ +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { Identifier } from "@/id/id" +import { Log } from "@/util/log" + +const log = Log.create({ service: "terminal-control" }) + +/** + * TerminalControl coordinates TUI renderer suspend/resume with interactive tool execution. + * + * When a tool needs to spawn an interactive subprocess (stdio: "inherit"), it should: + * 1. Call suspend() before spawning + * 2. Run the subprocess + * 3. Call resume() after subprocess exits (use try/finally) + */ +export namespace TerminalControl { + const DEFAULT_SUSPEND_TIMEOUT_MS = 100 + const DEFAULT_RESUME_TIMEOUT_MS = 50 + + interface AckHandler { + readonly token: string + readonly resolve: () => void + readonly timer: NodeJS.Timeout + } + + const pendingAcks = new Map() + + function isTuiMode(): boolean { + return process.env.OPENCODE_TUI === "1" + } + + function generateToken(): string { + return Identifier.ascending("terminal") + } + + function waitForAck(token: string, timeoutMs: number, operation: "suspend" | "resume"): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingAcks.delete(token) + log.warn(`${operation}: timeout waiting for TUI ack`, { token, timeoutMs }) + resolve() + }, timeoutMs) + + pendingAcks.set(token, { + token, + timer, + resolve: () => { + clearTimeout(timer) + pendingAcks.delete(token) + log.debug(`${operation}: received ack`, { token }) + resolve() + }, + }) + }) + } + + function cleanupPendingAck(token: string): void { + const handler = pendingAcks.get(token) + if (handler) { + clearTimeout(handler.timer) + pendingAcks.delete(token) + } + } + + export async function suspend( + options?: { + sessionID?: string + callID?: string + reason?: string + }, + timeout = isTuiMode() ? DEFAULT_SUSPEND_TIMEOUT_MS : 0, + ): Promise { + ensureSubscriptions() + const token = generateToken() + + if (timeout === 0) { + log.debug("suspend: headless mode, skipping wait", { token }) + return token + } + + await Bus.publish(TuiEvent.RendererSuspendRequest, { + token, + sessionID: options?.sessionID, + callID: options?.callID, + reason: options?.reason, + }) + + await waitForAck(token, timeout, "suspend") + return token + } + + export async function resume(token: string, shouldWaitForAck = false): Promise { + ensureSubscriptions() + cleanupPendingAck(token) + + await Bus.publish(TuiEvent.RendererResumeRequest, { token }) + + if (shouldWaitForAck && isTuiMode()) { + await waitForAck(token, DEFAULT_RESUME_TIMEOUT_MS, "resume") + } + } + + function handleAck(token: string): void { + const handler = pendingAcks.get(token) + handler?.resolve() + } + + let subscriptionsInitialized = false + function ensureSubscriptions(): void { + if (subscriptionsInitialized) return + subscriptionsInitialized = true + + Bus.subscribe(TuiEvent.RendererSuspendAck, (event) => { + handleAck(event.properties.token) + }) + + Bus.subscribe(TuiEvent.RendererResumeAck, (event) => { + handleAck(event.properties.token) + }) + + log.debug("Bus subscriptions initialized") + } +} diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 50aa9e14ad..6ad1e56da5 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -14,6 +14,7 @@ import { Process } from "@/util/process" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag" import { Shell } from "@/shell/shell" +import { TerminalControl } from "@/terminal/control" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncate" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 527584e7e2..252b6ce852 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -22,6 +22,10 @@ import type { EventSubscribeResponses, EventTuiCommandExecute, EventTuiPromptAppend, + EventTuiRendererResumeAck, + EventTuiRendererResumeRequest, + EventTuiRendererSuspendAck, + EventTuiRendererSuspendRequest, EventTuiSessionSelect, EventTuiToastShow, ExperimentalResourceListResponses, @@ -3681,7 +3685,15 @@ export class Tui extends HeyApiClient { parameters?: { directory?: string workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventTuiRendererSuspendRequest + | EventTuiRendererSuspendAck + | EventTuiRendererResumeRequest + | EventTuiRendererResumeAck }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 290c6fd5ec..d82d09d0d6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -322,6 +322,37 @@ export type EventTuiSessionSelect = { } } +export type EventTuiRendererSuspendRequest = { + type: "tui.renderer.suspend.request" + properties: { + token: string + sessionID?: string + callID?: string + reason?: string + } +} + +export type EventTuiRendererSuspendAck = { + type: "tui.renderer.suspend.ack" + properties: { + token: string + } +} + +export type EventTuiRendererResumeRequest = { + type: "tui.renderer.resume.request" + properties: { + token: string + } +} + +export type EventTuiRendererResumeAck = { + type: "tui.renderer.resume.ack" + properties: { + token: string + } +} + export type EventMcpToolsChanged = { type: "mcp.tools.changed" properties: { @@ -988,6 +1019,12 @@ export type Event = | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + | EventTuiRendererSuspendRequest + | EventTuiRendererSuspendAck + | EventTuiRendererResumeRequest + | EventTuiRendererResumeAck + | EventFileWatcherUpdated + | EventTodoUpdated | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -4902,7 +4939,15 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventTuiRendererSuspendRequest + | EventTuiRendererSuspendAck + | EventTuiRendererResumeRequest + | EventTuiRendererResumeAck path?: never query?: { directory?: string