Merge 246490b607 into ae614d919f
commit
110663531b
|
|
@ -7,5 +7,6 @@
|
|||
"jsx": "preserve",
|
||||
"jsxImportSource": "react",
|
||||
"types": ["@cloudflare/workers-types", "node"]
|
||||
}
|
||||
},
|
||||
"exclude": ["drizzle.config.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@
|
|||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": ["drizzle.config.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue