diff --git a/packages/console/core/tsconfig.json b/packages/console/core/tsconfig.json index 3218dd7e3e..5f523b90eb 100644 --- a/packages/console/core/tsconfig.json +++ b/packages/console/core/tsconfig.json @@ -7,5 +7,6 @@ "jsx": "preserve", "jsxImportSource": "react", "types": ["@cloudflare/workers-types", "node"] - } + }, + "exclude": ["drizzle.config.ts"] } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4161c025c1..a005e27272 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -903,6 +903,29 @@ 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 sdk.client.tui.publish({ + body: { + type: TuiEvent.RendererSuspendAck.type, + properties: { token: evt.properties.token }, + }, + }) + }) + + sdk.event.on(TuiEvent.RendererResumeRequest.type, async (evt) => { + renderer.currentRenderBuffer.clear() + renderer.resume() + renderer.requestRender() + await sdk.client.tui.publish({ + body: { + type: TuiEvent.RendererResumeAck.type, + properties: { 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..c89b3f933e --- /dev/null +++ b/packages/opencode/src/terminal/control.ts @@ -0,0 +1,132 @@ +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" +import { Identifier } from "@/id/id" +import { Instance } from "@/project/instance" +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 + } + + interface State { + pendingAcks: Map + subscriptionsInitialized: boolean + } + + const state = Instance.state(() => ({ + pendingAcks: new Map(), + subscriptionsInitialized: false, + })) + + 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(() => { + state().pendingAcks.delete(token) + log.warn(`${operation}: timeout waiting for TUI ack`, { token, timeoutMs }) + resolve() + }, timeoutMs) + + state().pendingAcks.set(token, { + token, + timer, + resolve: () => { + clearTimeout(timer) + state().pendingAcks.delete(token) + log.debug(`${operation}: received ack`, { token }) + resolve() + }, + }) + }) + } + + function cleanupPendingAck(token: string): void { + const handler = state().pendingAcks.get(token) + if (handler) { + clearTimeout(handler.timer) + state().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 = state().pendingAcks.get(token) + handler?.resolve() + } + + function ensureSubscriptions(): void { + const s = state() + if (s.subscriptionsInitialized) return + s.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", { directory: Instance.directory }) + } +} diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 365fda3296..41878a4609 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -13,6 +13,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/opencode/tsconfig.json b/packages/opencode/tsconfig.json index ff9886313a..875a5382fa 100644 --- a/packages/opencode/tsconfig.json +++ b/packages/opencode/tsconfig.json @@ -19,5 +19,6 @@ "namespaceImportPackages": ["effect", "@effect/*"] } ] - } + }, + "exclude": ["drizzle.config.ts"] } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b2e37db59b..7a742a880c 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, ExperimentalConsoleGetResponses, @@ -3793,7 +3797,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 0a9aa4358e..d2619544a4 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: { @@ -987,6 +1018,12 @@ export type Event = | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + | EventTuiRendererSuspendRequest + | EventTuiRendererSuspendAck + | EventTuiRendererResumeRequest + | EventTuiRendererResumeAck + | EventFileWatcherUpdated + | EventTodoUpdated | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -4989,7 +5026,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