Compare commits

...

3 Commits

Author SHA1 Message Date
Simon Klee 9973c7e739 simplify 2026-04-28 10:16:37 +02:00
Simon Klee 7141d76e1c add test 2026-04-28 10:16:37 +02:00
Simon Klee 81e8cb81a5 fix(tui): startup rejection handling
Propagate renderer startup failures from the TUI promise instead of
leaving opencode hanging, and destroy any partially initialized
renderer before rejecting to restore terminal state.
2026-04-28 10:16:37 +02:00
2 changed files with 100 additions and 70 deletions
+71 -70
View File
@@ -117,11 +117,8 @@ export function tui(input: {
headers?: RequestInit["headers"] headers?: RequestInit["headers"]
events?: EventSource events?: EventSource
}) { }) {
// promise to prevent immediate exit return new Promise<void>((resolve, reject) => {
// 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() const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const onExit = async () => { const onExit = async () => {
unguard?.() unguard?.()
@@ -132,73 +129,77 @@ export function tui(input: {
await TuiPluginRuntime.dispose() await TuiPluginRuntime.dispose()
} }
const renderer = await createCliRenderer(rendererConfig(input.config)) void (async () => {
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" win32DisableProcessedInput()
await render(() => { const renderer = await createCliRenderer(rendererConfig(input.config))
return ( const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
<ErrorBoundary
fallback={(error, reset) => ( await render(() => {
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} /> return (
)} <ErrorBoundary
> fallback={(error, reset) => (
<ArgsProvider {...input.args}> <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}> )}
<KVProvider> >
<ToastProvider> <ArgsProvider {...input.args}>
<RouteProvider <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
initialRoute={ <KVProvider>
input.args.continue <ToastProvider>
? { <RouteProvider
type: "session", initialRoute={
sessionID: "dummy", input.args.continue
} ? {
: undefined type: "session",
} sessionID: "dummy",
> }
<TuiConfigProvider config={input.config}> : undefined
<SDKProvider }
url={input.url} >
directory={input.directory} <TuiConfigProvider config={input.config}>
fetch={input.fetch} <SDKProvider
headers={input.headers} url={input.url}
events={input.events} directory={input.directory}
> fetch={input.fetch}
<ProjectProvider> headers={input.headers}
<SyncProvider> events={input.events}
<ThemeProvider mode={mode}> >
<LocalProvider> <ProjectProvider>
<KeybindProvider> <SyncProvider>
<PromptStashProvider> <ThemeProvider mode={mode}>
<DialogProvider> <LocalProvider>
<CommandProvider> <KeybindProvider>
<FrecencyProvider> <PromptStashProvider>
<PromptHistoryProvider> <DialogProvider>
<PromptRefProvider> <CommandProvider>
<EditorContextProvider> <FrecencyProvider>
<App onSnapshot={input.onSnapshot} /> <PromptHistoryProvider>
</EditorContextProvider> <PromptRefProvider>
</PromptRefProvider> <EditorContextProvider>
</PromptHistoryProvider> <App onSnapshot={input.onSnapshot} />
</FrecencyProvider> </EditorContextProvider>
</CommandProvider> </PromptRefProvider>
</DialogProvider> </PromptHistoryProvider>
</PromptStashProvider> </FrecencyProvider>
</KeybindProvider> </CommandProvider>
</LocalProvider> </DialogProvider>
</ThemeProvider> </PromptStashProvider>
</SyncProvider> </KeybindProvider>
</ProjectProvider> </LocalProvider>
</SDKProvider> </ThemeProvider>
</TuiConfigProvider> </SyncProvider>
</RouteProvider> </ProjectProvider>
</ToastProvider> </SDKProvider>
</KVProvider> </TuiConfigProvider>
</ExitProvider> </RouteProvider>
</ArgsProvider> </ToastProvider>
</ErrorBoundary> </KVProvider>
) </ExitProvider>
}, renderer) </ArgsProvider>
</ErrorBoundary>
)
}, renderer)
})().catch(reject)
}) })
} }
@@ -0,0 +1,29 @@
import { afterEach, expect, mock, spyOn, test } from "bun:test"
import * as Core from "@opentui/core"
afterEach(() => {
mock.restore()
})
test("tui rejects when renderer startup fails", async () => {
const err = new Error("setRawMode failed with errno: 9")
spyOn(Core, "createCliRenderer").mockRejectedValue(err)
const { tui } = await import("../../../src/cli/cmd/tui/app")
const result = await Promise.race([
tui({
url: "http://opencode.internal",
config: {},
args: {
continue: false,
fork: false,
},
}).then(
() => "resolved",
(error) => error,
),
Bun.sleep(100).then(() => "timeout"),
])
expect(result).toBe(err)
})