From 81e8cb81a56905b898a6f14fa545a74272ee2470 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 26 Apr 2026 21:44:20 +0200 Subject: [PATCH 1/3] 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. --- packages/opencode/src/cli/cmd/tui/app.tsx | 148 ++++++++++++---------- 1 file changed, 78 insertions(+), 70 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 833c8dc8c3..3644cafa6a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -117,11 +117,15 @@ export function tui(input: { 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(async (resolve) => { + return new Promise((resolve, reject) => { const unguard = win32InstallCtrlCGuard() - win32DisableProcessedInput() + let renderer: Awaited> | undefined + const fail = (error: unknown) => { + renderer?.destroy() + renderer = undefined + unguard?.() + reject(error) + } const onExit = async () => { unguard?.() @@ -132,73 +136,77 @@ export function tui(input: { await TuiPluginRuntime.dispose() } - const renderer = await createCliRenderer(rendererConfig(input.config)) - const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + void (async () => { + win32DisableProcessedInput() - await render(() => { - return ( - ( - - )} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) - }, renderer) + renderer = await createCliRenderer(rendererConfig(input.config)) + const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + + await render(() => { + return ( + ( + + )} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, renderer) + })().catch(fail) }) } From 7141d76e1ced2bf31cc2aeb55f0c5aaf47a33e55 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 26 Apr 2026 22:17:32 +0200 Subject: [PATCH 2/3] add test --- packages/opencode/test/cli/tui/app.test.ts | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 packages/opencode/test/cli/tui/app.test.ts diff --git a/packages/opencode/test/cli/tui/app.test.ts b/packages/opencode/test/cli/tui/app.test.ts new file mode 100644 index 0000000000..b2f308021f --- /dev/null +++ b/packages/opencode/test/cli/tui/app.test.ts @@ -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) +}) From 9973c7e7399326e5245f982229e73757718a4d96 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 26 Apr 2026 22:56:45 +0200 Subject: [PATCH 3/3] simplify --- packages/opencode/src/cli/cmd/tui/app.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 3644cafa6a..b85f7fdcaa 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -119,13 +119,6 @@ export function tui(input: { }) { return new Promise((resolve, reject) => { const unguard = win32InstallCtrlCGuard() - let renderer: Awaited> | undefined - const fail = (error: unknown) => { - renderer?.destroy() - renderer = undefined - unguard?.() - reject(error) - } const onExit = async () => { unguard?.() @@ -139,7 +132,7 @@ export function tui(input: { void (async () => { win32DisableProcessedInput() - renderer = await createCliRenderer(rendererConfig(input.config)) + const renderer = await createCliRenderer(rendererConfig(input.config)) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" await render(() => { @@ -206,7 +199,7 @@ export function tui(input: { ) }, renderer) - })().catch(fail) + })().catch(reject) }) }