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.
pull/14293/head
rentiansheng 2026-02-19 22:19:06 +08:00
parent c8ecd64022
commit 5ecf732bf5
No known key found for this signature in database
GPG Key ID: 9C3653AF125722B0
8 changed files with 231 additions and 5 deletions

View File

@ -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<string[]> }) {
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 (
<box
width={dimensions().width}

View File

@ -46,4 +46,31 @@ export const TuiEvent = {
sessionID: SessionID.zod.describe("Session ID to navigate to"),
}),
),
RendererSuspendRequest: BusEvent.define(
"tui.renderer.suspend.request",
z.object({
token: z.string(),
sessionID: z.string().optional(),
callID: z.string().optional(),
reason: z.string().optional(),
}),
),
RendererSuspendAck: BusEvent.define(
"tui.renderer.suspend.ack",
z.object({
token: z.string(),
}),
),
RendererResumeRequest: BusEvent.define(
"tui.renderer.resume.request",
z.object({
token: z.string(),
}),
),
RendererResumeAck: BusEvent.define(
"tui.renderer.resume.ack",
z.object({
token: z.string(),
}),
),
}

View File

@ -131,9 +131,12 @@ export const TuiThreadCommand = cmd({
const cwd = Filesystem.resolve(process.cwd())
const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => 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)

View File

@ -13,6 +13,7 @@ export namespace Identifier {
pty: "pty",
tool: "tool",
workspace: "wrk",
terminal: "trm",
} as const
export function schema(prefix: keyof typeof prefixes) {

View File

@ -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<string, AckHandler>()
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<void> {
return new Promise<void>((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<string> {
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<void> {
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")
}
}

View File

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

View File

@ -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<never, ThrowOnError>,
) {

View File

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