From 5ecf732bf5179e1536d5d76a33d61d78d21a6d23 Mon Sep 17 00:00:00 2001 From: rentiansheng Date: Thu, 19 Feb 2026 22:19:06 +0800 Subject: [PATCH 1/3] fix(bash): coordinate TUI suspend/resume for interactive commands Implement Bus event-based coordination between bash tool and TUI renderer to properly handle interactive commands like GPG signing, SSH, and git rebase. Prevents terminal corruption, control character leakage, and keyboard protocol issues by suspending TUI rendering before spawning stdio-inherit subprocesses. - Add TerminalControl module with suspend/resume coordination via Bus events - Wrap interactive bash commands with TUI suspend/resume lifecycle - Add TUI event handlers for renderer suspend/resume with ack handshake - Set OPENCODE_TUI=1 environment variable for TUI mode detection - Generate terminal tokens for request tracking with timeout handling Fixes GPG pinentry dialog disappearing, password input routing to wrong location, escape sequences appearing as garbage, and keyboard shortcuts becoming characters. --- packages/opencode/src/cli/cmd/tui/app.tsx | 14 +++ packages/opencode/src/cli/cmd/tui/event.ts | 27 +++++ packages/opencode/src/cli/cmd/tui/thread.ts | 9 +- packages/opencode/src/id/id.ts | 1 + packages/opencode/src/terminal/control.ts | 123 ++++++++++++++++++++ packages/opencode/src/tool/bash.ts | 1 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 14 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 47 +++++++- 8 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/terminal/control.ts 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 From 44d05191374023946198b11559c3d1b006bdc001 Mon Sep 17 00:00:00 2001 From: rentiansheng Date: Thu, 19 Feb 2026 22:57:16 +0800 Subject: [PATCH 2/3] fix(typecheck): exclude drizzle.config.ts from typecheck Pre-existing issue where drizzle-kit types aren't available during typecheck. Drizzle config files are only used by drizzle-kit CLI, not in application code. --- packages/console/core/tsconfig.json | 3 ++- packages/opencode/tsconfig.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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"] } From 31e7749b43d59275f5c32a9097944517c67962cf Mon Sep 17 00:00:00 2001 From: rentiansheng Date: Fri, 20 Feb 2026 08:48:49 +0800 Subject: [PATCH 3/3] fix(tui): use SDK publish for acks and per-instance state tracking - TUI ack events now use sdk.client.tui.publish() instead of Bus.publish() to properly route events from TUI process to backend worker thread - TerminalControl now uses Instance.state() for per-instance subscription tracking instead of global boolean, fixing multi-instance scenarios - Remove unused Bus import from app.tsx --- packages/opencode/src/cli/cmd/tui/app.tsx | 15 ++++++++--- packages/opencode/src/terminal/control.ts | 31 +++++++++++++++-------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index fc2394779e..29836b908f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -49,7 +49,6 @@ 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" @@ -881,14 +880,24 @@ function App(props: { onSnapshot?: () => Promise }) { sdk.event.on(TuiEvent.RendererSuspendRequest.type, async (evt) => { renderer.suspend() renderer.currentRenderBuffer.clear() - await Bus.publish(TuiEvent.RendererSuspendAck, { token: evt.properties.token }) + 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 Bus.publish(TuiEvent.RendererResumeAck, { token: evt.properties.token }) + await sdk.client.tui.publish({ + body: { + type: TuiEvent.RendererResumeAck.type, + properties: { token: evt.properties.token }, + }, + }) }) return ( diff --git a/packages/opencode/src/terminal/control.ts b/packages/opencode/src/terminal/control.ts index 1657303664..c89b3f933e 100644 --- a/packages/opencode/src/terminal/control.ts +++ b/packages/opencode/src/terminal/control.ts @@ -1,6 +1,7 @@ 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" }) @@ -23,7 +24,15 @@ export namespace TerminalControl { readonly timer: NodeJS.Timeout } - const pendingAcks = new Map() + interface State { + pendingAcks: Map + subscriptionsInitialized: boolean + } + + const state = Instance.state(() => ({ + pendingAcks: new Map(), + subscriptionsInitialized: false, + })) function isTuiMode(): boolean { return process.env.OPENCODE_TUI === "1" @@ -36,17 +45,17 @@ export namespace TerminalControl { function waitForAck(token: string, timeoutMs: number, operation: "suspend" | "resume"): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { - pendingAcks.delete(token) + state().pendingAcks.delete(token) log.warn(`${operation}: timeout waiting for TUI ack`, { token, timeoutMs }) resolve() }, timeoutMs) - pendingAcks.set(token, { + state().pendingAcks.set(token, { token, timer, resolve: () => { clearTimeout(timer) - pendingAcks.delete(token) + state().pendingAcks.delete(token) log.debug(`${operation}: received ack`, { token }) resolve() }, @@ -55,10 +64,10 @@ export namespace TerminalControl { } function cleanupPendingAck(token: string): void { - const handler = pendingAcks.get(token) + const handler = state().pendingAcks.get(token) if (handler) { clearTimeout(handler.timer) - pendingAcks.delete(token) + state().pendingAcks.delete(token) } } @@ -101,14 +110,14 @@ export namespace TerminalControl { } function handleAck(token: string): void { - const handler = pendingAcks.get(token) + const handler = state().pendingAcks.get(token) handler?.resolve() } - let subscriptionsInitialized = false function ensureSubscriptions(): void { - if (subscriptionsInitialized) return - subscriptionsInitialized = true + const s = state() + if (s.subscriptionsInitialized) return + s.subscriptionsInitialized = true Bus.subscribe(TuiEvent.RendererSuspendAck, (event) => { handleAck(event.properties.token) @@ -118,6 +127,6 @@ export namespace TerminalControl { handleAck(event.properties.token) }) - log.debug("Bus subscriptions initialized") + log.debug("Bus subscriptions initialized", { directory: Instance.directory }) } }