pull/14293/merge
Reage 2026-04-08 05:34:19 +00:00 committed by GitHub
commit 110663531b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 253 additions and 7 deletions

View File

@ -7,5 +7,6 @@
"jsx": "preserve",
"jsxImportSource": "react",
"types": ["@cloudflare/workers-types", "node"]
}
},
"exclude": ["drizzle.config.ts"]
}

View File

@ -903,6 +903,29 @@ 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 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 (
<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

@ -140,9 +140,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,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<string, AckHandler>
subscriptionsInitialized: boolean
}
const state = Instance.state(() => ({
pendingAcks: new Map<string, AckHandler>(),
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<void> {
return new Promise<void>((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<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 = 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 })
}
}

View File

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

View File

@ -19,5 +19,6 @@
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
},
"exclude": ["drizzle.config.ts"]
}

View File

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