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
parent
c8ecd64022
commit
5ecf732bf5
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export namespace Identifier {
|
|||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
terminal: "trm",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue