Prepare TUI lifecycle for scenario tests (#28258)

This commit is contained in:
Sebastian
2026-05-26 21:02:02 +02:00
committed by GitHub
parent fdfd0afed7
commit 848d763d08
9 changed files with 596 additions and 280 deletions

View File

@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as TuiAudio from "@tui/util/audio"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Switch,
@@ -18,7 +18,7 @@ import {
Show,
on,
} from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@opencode-ai/core/flag/flag"
import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -51,7 +51,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
import { createExit, ExitProvider, useExit, type Exit } from "./context/exit"
import { Session as SessionApi } from "@/session/session"
import { TuiEvent } from "./event"
import { KVProvider, useKV } from "./context/kv"
@@ -123,7 +123,7 @@ const appBindingCommands = [
"app.toggle.session_directory_filter",
] as const
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
export function tuiRendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
return {
@@ -146,6 +146,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
}
}
export function createTuiRenderer(config: TuiConfig.Resolved) {
return createCliRenderer(tuiRendererConfig(config))
}
export type TuiHandle = {
ready: Promise<void>
done: Promise<void>
exit: Exit
}
type TuiInput = {
url: string
args: Args
config: TuiConfig.Resolved
renderer: CliRenderer
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}
type TuiLifecycle = {
exit: Exit
exited: Promise<void>
fail(error: unknown): Promise<never>
}
function errorMessage(error: unknown) {
const formatted = FormatError(error)
if (formatted !== undefined) return formatted
@@ -163,105 +191,175 @@ function errorMessage(error: unknown) {
return FormatUnknownError(error)
}
export function tui(input: {
url: string
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) {
// promise to prevent immediate exit
// oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
export function tui(input: TuiInput): TuiHandle {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const onExit = async () => {
unguard?.()
resolve()
}
const onBeforeExit = async () => {
offKeymap()
const renderer = input.renderer
const keymap = createDefaultOpenTuiKeymap(renderer)
const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
const lifecycle = createTuiLifecycle({
renderer,
unguard,
cleanup: async () => {
unregisterKeymap()
await TuiPluginRuntime.dispose()
TuiAudio.dispose()
}
const renderer = await createCliRenderer(rendererConfig(input.config))
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const keymap = createDefaultOpenTuiKeymap(renderer)
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => (
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)}
>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
},
})
const ready = mountTui({ ...input, keymap, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error))
const done = waitUntilDone(ready, lifecycle.exited)
return { ready, done, exit: lifecycle.exit }
}
async function mountTui(input: TuiInput & { keymap: ReturnType<typeof createDefaultOpenTuiKeymap>; exit: Exit }) {
const renderer = input.renderer
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
if (renderer.isDestroyed) return
await render(() => {
return (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} exit={input.exit} mode={mode} />}
>
<OpencodeKeymapProvider keymap={input.keymap}>
<ArgsProvider {...input.args}>
<ExitProvider exit={input.exit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</DialogProvider>
</PromptStashProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
}
function createTuiLifecycle(input: {
renderer: CliRenderer
unguard?: () => void
cleanup: () => Promise<void>
}): TuiLifecycle {
let resolveExited!: () => void
const exited = new Promise<void>((resolve) => {
resolveExited = resolve
})
let exitCompleted = false
let exiting = false
let cleanupTask: Promise<void> | undefined
const completeExit = () => {
if (exitCompleted) return
exitCompleted = true
resolveExited()
}
const cleanup = () => {
cleanupTask ??= (async () => {
process.off("SIGHUP", onSighup)
try {
await input.cleanup()
} finally {
input.unguard?.()
}
})()
return cleanupTask
}
const exit = createExit(async (reason, message) => {
exiting = true
await cleanup()
if (!input.renderer.isDestroyed) {
input.renderer.setTerminalTitle("")
input.renderer.destroy()
}
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) process.stderr.write(formatted + "\n")
}
const text = message()
if (text) process.stdout.write(text + "\n")
completeExit()
})
const onSighup = () => {
void exit()
}
input.renderer.once("destroy", () => {
if (exiting) return
void cleanup().finally(() => {
win32FlushInputBuffer()
completeExit()
})
})
process.on("SIGHUP", onSighup)
return {
exit,
exited,
async fail(error) {
exiting = true
await cleanup().catch(() => {})
if (!input.renderer.isDestroyed) input.renderer.destroy()
completeExit()
throw error
},
}
}
async function waitUntilDone(ready: Promise<void>, exited: Promise<void>) {
await ready
await exited
}
function App(props: { onSnapshot?: () => Promise<string[]> }) {

View File

@@ -67,7 +67,6 @@ export const AttachCommand = cmd({
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
const { tui } = await import("./app")
try {
await validateSession({
@@ -82,9 +81,12 @@ export const AttachCommand = cmd({
return
}
await tui({
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
const handle = tui({
url: args.url,
config,
renderer,
args: {
continue: args.continue,
sessionID: args.session,
@@ -93,6 +95,7 @@ export const AttachCommand = cmd({
directory,
headers,
})
await handle.done
} finally {
unguard?.()
}

View File

@@ -1,32 +1,21 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
export function ErrorComponent(props: {
error: Error
reset: () => void
onBeforeExit?: () => Promise<void>
onExit: () => Promise<void>
exit: () => Promise<void>
mode?: "dark" | "light"
}) {
const term = useTerminalDimensions()
const renderer = useRenderer()
const handleExit = async () => {
await props.onBeforeExit?.()
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
await props.onExit()
}
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
void handleExit()
void props.exit()
}
})
const [copied, setCopied] = createSignal(false)
@@ -79,7 +68,7 @@ export function ErrorComponent(props: {
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Reset TUI</text>
</box>
<box onMouseUp={handleExit} backgroundColor={colors.primary} padding={1}>
<box onMouseUp={() => void props.exit()} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text>
</box>
</box>

View File

@@ -1,8 +1,6 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { win32FlushInputBuffer } from "../win32"
type Exit = ((reason?: unknown) => Promise<void>) & {
export type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
@@ -10,51 +8,35 @@ type Exit = ((reason?: unknown) => Promise<void>) & {
}
}
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
return Object.assign(
(reason?: unknown) => {
task ??= run(reason, store.get)
return task
},
{
message: store,
},
) satisfies Exit
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onBeforeExit?: () => Promise<void>; onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
let task: Promise<void> | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
const exit: Exit = Object.assign(
(reason?: unknown) => {
if (task) return task
task = (async () => {
await input.onBeforeExit?.()
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
if (formatted) {
process.stderr.write(formatted + "\n")
}
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
await input.onExit?.()
})()
return task
},
{
message: store,
},
)
process.on("SIGHUP", () => exit())
return exit
},
init: (input: { exit: Exit }) => input.exit,
})

View File

@@ -228,9 +228,11 @@ export const TuiThreadCommand = cmd({
}, 1000).unref?.()
try {
const { tui } = await import("./app")
await tui({
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
const handle = tui({
url: transport.url,
renderer,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
@@ -249,6 +251,7 @@ export const TuiThreadCommand = cmd({
fork: args.fork,
},
})
await handle.done
} finally {
await stop()
}