Skip to content

Commit 848d763

Browse files
authored
Prepare TUI lifecycle for scenario tests (#28258)
1 parent fdfd0af commit 848d763

9 files changed

Lines changed: 595 additions & 279 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 196 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
33
import * as Clipboard from "@tui/util/clipboard"
44
import * as Selection from "@tui/util/selection"
55
import * as TuiAudio from "@tui/util/audio"
6-
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
6+
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
77
import { RouteProvider, useRoute } from "@tui/context/route"
88
import {
99
Switch,
@@ -18,7 +18,7 @@ import {
1818
Show,
1919
on,
2020
} from "solid-js"
21-
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
21+
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
2222
import { Flag } from "@opencode-ai/core/flag/flag"
2323
import semver from "semver"
2424
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -51,7 +51,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
5151
import { DialogAlert } from "./ui/dialog-alert"
5252
import { DialogConfirm } from "./ui/dialog-confirm"
5353
import { ToastProvider, useToast } from "./ui/toast"
54-
import { ExitProvider, useExit } from "./context/exit"
54+
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
5555
import { Session as SessionApi } from "@/session/session"
5656
import { TuiEvent } from "./event"
5757
import { KVProvider, useKV } from "./context/kv"
@@ -123,7 +123,7 @@ const appBindingCommands = [
123123
"app.toggle.session_directory_filter",
124124
] as const
125125

126-
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
126+
export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
127127
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
128128

129129
return {
@@ -146,6 +146,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
146146
}
147147
}
148148

149+
export function createTuiRenderer(config: TuiConfig.Resolved) {
150+
return createCliRenderer(tuiRendererConfig(config))
151+
}
152+
153+
export type TuiHandle = {
154+
ready: Promise<void>
155+
done: Promise<void>
156+
exit: Exit
157+
}
158+
159+
type TuiInput = {
160+
url: string
161+
args: Args
162+
config: TuiConfig.Resolved
163+
renderer: CliRenderer
164+
onSnapshot?: () => Promise<string[]>
165+
directory?: string
166+
fetch?: typeof fetch
167+
headers?: RequestInit["headers"]
168+
events?: EventSource
169+
}
170+
171+
type TuiLifecycle = {
172+
exit: Exit
173+
exited: Promise<void>
174+
fail(error: unknown): Promise<never>
175+
}
176+
149177
function errorMessage(error: unknown) {
150178
const formatted = FormatError(error)
151179
if (formatted !== undefined) return formatted
@@ -163,105 +191,175 @@ function errorMessage(error: unknown) {
163191
return FormatUnknownError(error)
164192
}
165193

166-
export function tui(input: {
167-
url: string
168-
args: Args
169-
config: TuiConfig.Resolved
170-
onSnapshot?: () => Promise<string[]>
171-
directory?: string
172-
fetch?: typeof fetch
173-
headers?: RequestInit["headers"]
174-
events?: EventSource
175-
}) {
176-
// promise to prevent immediate exit
177-
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
178-
return new Promise<void>(async (resolve) => {
179-
const unguard = win32InstallCtrlCGuard()
180-
win32DisableProcessedInput()
181-
182-
const onExit = async () => {
183-
unguard?.()
184-
resolve()
185-
}
186-
const onBeforeExit = async () => {
187-
offKeymap()
194+
export function tui(input: TuiInput): TuiHandle {
195+
const unguard = win32InstallCtrlCGuard()
196+
win32DisableProcessedInput()
197+
198+
const renderer = input.renderer
199+
const keymap = createDefaultOpenTuiKeymap(renderer)
200+
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
201+
const lifecycle = createTuiLifecycle({
202+
renderer,
203+
unguard,
204+
cleanup: async () => {
205+
unregisterKeymap()
188206
await TuiPluginRuntime.dispose()
189207
TuiAudio.dispose()
208+
},
209+
})
210+
const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
211+
const done = waitUntilDone(ready, lifecycle.exited)
212+
213+
return { ready, done, exit: lifecycle.exit }
214+
}
215+
216+
async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; exit: Exit }) {
217+
const renderer = input.renderer
218+
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
219+
void renderer.getPalette({ size: 16 }).catch(() => undefined)
220+
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
221+
if (renderer.isDestroyed) return
222+
223+
await render(() => {
224+
return (
225+
<ErrorBoundary
226+
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} exit={input.exit} mode={mode} />}
227+
>
228+
<OpencodeKeymapProvider keymap={input.keymap}>
229+
<ArgsProvider {...input.args}>
230+
<ExitProvider exit={input.exit}>
231+
<KVProvider>
232+
<ToastProvider>
233+
<RouteProvider
234+
initialRoute={
235+
input.args.continue
236+
? {
237+
type: "session",
238+
sessionID: "dummy",
239+
}
240+
: undefined
241+
}
242+
>
243+
<TuiConfigProvider config={input.config}>
244+
<SDKProvider
245+
url={input.url}
246+
directory={input.directory}
247+
fetch={input.fetch}
248+
headers={input.headers}
249+
events={input.events}
250+
>
251+
<ProjectProvider>
252+
<SyncProvider>
253+
<SyncProviderV2>
254+
<ThemeProvider mode={mode}>
255+
<LocalProvider>
256+
<PromptStashProvider>
257+
<DialogProvider>
258+
<FrecencyProvider>
259+
<PromptHistoryProvider>
260+
<PromptRefProvider>
261+
<EditorContextProvider>
262+
<App onSnapshot={input.onSnapshot} />
263+
</EditorContextProvider>
264+
</PromptRefProvider>
265+
</PromptHistoryProvider>
266+
</FrecencyProvider>
267+
</DialogProvider>
268+
</PromptStashProvider>
269+
</LocalProvider>
270+
</ThemeProvider>
271+
</SyncProviderV2>
272+
</SyncProvider>
273+
</ProjectProvider>
274+
</SDKProvider>
275+
</TuiConfigProvider>
276+
</RouteProvider>
277+
</ToastProvider>
278+
</KVProvider>
279+
</ExitProvider>
280+
</ArgsProvider>
281+
</OpencodeKeymapProvider>
282+
</ErrorBoundary>
283+
)
284+
}, renderer)
285+
}
286+
287+
function createTuiLifecycle(input: {
288+
renderer: CliRenderer
289+
unguard?: () => void
290+
cleanup: () => Promise<void>
291+
}): TuiLifecycle {
292+
let resolveExited!: () => void
293+
const exited = new Promise<void>((resolve) => {
294+
resolveExited = resolve
295+
})
296+
let exitCompleted = false
297+
let exiting = false
298+
let cleanupTask: Promise<void> | undefined
299+
300+
const completeExit = () => {
301+
if (exitCompleted) return
302+
exitCompleted = true
303+
resolveExited()
304+
}
305+
306+
const cleanup = () => {
307+
cleanupTask ??= (async () => {
308+
process.off("SIGHUP", onSighup)
309+
try {
310+
await input.cleanup()
311+
} finally {
312+
input.unguard?.()
313+
}
314+
})()
315+
return cleanupTask
316+
}
317+
318+
const exit = createExit(async (reason, message) => {
319+
exiting = true
320+
await cleanup()
321+
if (!input.renderer.isDestroyed) {
322+
input.renderer.setTerminalTitle("")
323+
input.renderer.destroy()
190324
}
325+
win32FlushInputBuffer()
326+
if (reason) {
327+
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
328+
if (formatted) process.stderr.write(formatted + "\n")
329+
}
330+
const text = message()
331+
if (text) process.stdout.write(text + "\n")
332+
completeExit()
333+
})
334+
const onSighup = () => {
335+
void exit()
336+
}
191337

192-
const renderer = await createCliRenderer(rendererConfig(input.config))
193-
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
194-
void renderer.getPalette({ size: 16 }).catch(() => undefined)
195-
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
196-
197-
const keymap = createDefaultOpenTuiKeymap(renderer)
198-
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
199-
200-
await render(() => {
201-
return (
202-
<ErrorBoundary
203-
fallback={(error, reset) => (
204-
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
205-
)}
206-
>
207-
<OpencodeKeymapProvider keymap={keymap}>
208-
<ArgsProvider {...input.args}>
209-
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
210-
<KVProvider>
211-
<ToastProvider>
212-
<RouteProvider
213-
initialRoute={
214-
input.args.continue
215-
? {
216-
type: "session",
217-
sessionID: "dummy",
218-
}
219-
: undefined
220-
}
221-
>
222-
<TuiConfigProvider config={input.config}>
223-
<SDKProvider
224-
url={input.url}
225-
directory={input.directory}
226-
fetch={input.fetch}
227-
headers={input.headers}
228-
events={input.events}
229-
>
230-
<ProjectProvider>
231-
<SyncProvider>
232-
<SyncProviderV2>
233-
<ThemeProvider mode={mode}>
234-
<LocalProvider>
235-
<PromptStashProvider>
236-
<DialogProvider>
237-
<FrecencyProvider>
238-
<PromptHistoryProvider>
239-
<PromptRefProvider>
240-
<EditorContextProvider>
241-
<App onSnapshot={input.onSnapshot} />
242-
</EditorContextProvider>
243-
</PromptRefProvider>
244-
</PromptHistoryProvider>
245-
</FrecencyProvider>
246-
</DialogProvider>
247-
</PromptStashProvider>
248-
</LocalProvider>
249-
</ThemeProvider>
250-
</SyncProviderV2>
251-
</SyncProvider>
252-
</ProjectProvider>
253-
</SDKProvider>
254-
</TuiConfigProvider>
255-
</RouteProvider>
256-
</ToastProvider>
257-
</KVProvider>
258-
</ExitProvider>
259-
</ArgsProvider>
260-
</OpencodeKeymapProvider>
261-
</ErrorBoundary>
262-
)
263-
}, renderer)
338+
input.renderer.once("destroy", () => {
339+
if (exiting) return
340+
void cleanup().finally(() => {
341+
win32FlushInputBuffer()
342+
completeExit()
343+
})
264344
})
345+
process.on("SIGHUP", onSighup)
346+
347+
return {
348+
exit,
349+
exited,
350+
async fail(error) {
351+
exiting = true
352+
await cleanup().catch(() => {})
353+
if (!input.renderer.isDestroyed) input.renderer.destroy()
354+
completeExit()
355+
throw error
356+
},
357+
}
358+
}
359+
360+
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
361+
await ready
362+
await exited
265363
}
266364

267365
function App(props: { onSnapshot?: () => Promise<string[]> }) {

packages/opencode/src/cli/cmd/tui/attach.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ export const AttachCommand = cmd({
6767
})()
6868
const headers = ServerAuth.headers({ password: args.password, username: args.username })
6969
const config = await TuiConfig.get()
70-
const { tui } = await import("./app")
7170

7271
try {
7372
await validateSession({
@@ -82,9 +81,12 @@ export const AttachCommand = cmd({
8281
return
8382
}
8483

85-
await tui({
84+
const { createTuiRenderer, tui } = await import("./app")
85+
const renderer = await createTuiRenderer(config)
86+
const handle = tui({
8687
url: args.url,
8788
config,
89+
renderer,
8890
args: {
8991
continue: args.continue,
9092
sessionID: args.session,
@@ -93,6 +95,7 @@ export const AttachCommand = cmd({
9395
directory,
9496
headers,
9597
})
98+
await handle.done
9699
} finally {
97100
unguard?.()
98101
}

0 commit comments

Comments
 (0)