From 5ecf732bf5179e1536d5d76a33d61d78d21a6d23 Mon Sep 17 00:00:00 2001 From: rentiansheng Date: Thu, 19 Feb 2026 22:19:06 +0800 Subject: [PATCH] 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