From 45bc81e1abea59f3df66f0fa88988fc39dd5dba0 Mon Sep 17 00:00:00 2001 From: niraj12chaudhary Date: Sat, 28 Feb 2026 12:52:02 +0530 Subject: [PATCH 1/2] fix(tui): recover event stream after lock/sleep disconnect --- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 54 ++++++++++++----- .../opencode/src/cli/cmd/tui/context/sync.tsx | 4 ++ packages/opencode/src/cli/cmd/tui/worker.ts | 58 +++++++++++++------ 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 7fa7e05c3d2..be047a187f9 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -32,6 +32,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ let queue: Event[] = [] let timer: Timer | undefined let last = 0 + const RECONNECT_DELAY_MS = 250 + const HEARTBEAT_TIMEOUT_MS = 15_000 const flush = () => { if (queue.length === 0) return @@ -60,6 +62,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ } flush() } + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const aborted = (error: unknown) => error instanceof Error && error.name === "AbortError" onMount(async () => { // If an event source is provided, use it instead of SSE @@ -72,22 +76,46 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ // Fall back to SSE while (true) { if (abort.signal.aborted) break - const events = await sdk.event.subscribe( - {}, - { - signal: abort.signal, - }, - ) - - for await (const event of events.stream) { - handleEvent(event) + const attempt = new AbortController() + const onAbort = () => { + attempt.abort() + } + abort.signal.addEventListener("abort", onAbort) + let heartbeat: Timer | undefined + const resetHeartbeat = () => { + if (heartbeat) clearTimeout(heartbeat) + heartbeat = setTimeout(() => { + attempt.abort() + }, HEARTBEAT_TIMEOUT_MS) } - // Flush any remaining events - if (timer) clearTimeout(timer) - if (queue.length > 0) { - flush() + try { + const events = await sdk.event.subscribe( + {}, + { + signal: attempt.signal, + }, + ) + resetHeartbeat() + + for await (const event of events.stream) { + resetHeartbeat() + handleEvent(event) + } + } catch (error) { + if (!aborted(error)) { + // swallow and reconnect + } + } finally { + abort.signal.removeEventListener("abort", onAbort) + if (heartbeat) clearTimeout(heartbeat) + // Flush any remaining events before reconnect + if (timer) clearTimeout(timer) + if (queue.length > 0) flush() } + + if (abort.signal.aborted) break + await wait(RECONNECT_DELAY_MS) } }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 269ed7ae0bd..4516d7bad84 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -107,6 +107,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.event.listen((e) => { const event = e.details switch (event.type) { + case "server.connected": + case "global.disposed": + if (store.status !== "loading") bootstrap() + break case "server.instance.disposed": bootstrap() break diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index bb5495c4811..b4cc50e0c6a 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -48,6 +48,8 @@ const startEventStream = (directory: string) => { const abort = new AbortController() eventStream.abort = abort const signal = abort.signal + const RECONNECT_DELAY_MS = 250 + const HEARTBEAT_TIMEOUT_MS = 15_000 const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) @@ -63,32 +65,54 @@ const startEventStream = (directory: string) => { signal, }) + const aborted = (error: unknown) => error instanceof Error && error.name === "AbortError" + ;(async () => { + let streamErrorLogged = false while (!signal.aborted) { - const events = await Promise.resolve( - sdk.event.subscribe( + const attempt = new AbortController() + const onAbort = () => { + attempt.abort() + } + signal.addEventListener("abort", onAbort) + let heartbeat: Timer | undefined + const resetHeartbeat = () => { + if (heartbeat) clearTimeout(heartbeat) + heartbeat = setTimeout(() => { + attempt.abort() + }, HEARTBEAT_TIMEOUT_MS) + } + + try { + const events = await sdk.event.subscribe( {}, { - signal, + signal: attempt.signal, }, - ), - ).catch(() => undefined) - - if (!events) { - await Bun.sleep(250) - continue - } - - for await (const event of events.stream) { - Rpc.emit("event", event as Event) + ) + resetHeartbeat() + + for await (const event of events.stream) { + streamErrorLogged = false + resetHeartbeat() + Rpc.emit("event", event as Event) + } + } catch (error) { + if (!aborted(error) && !streamErrorLogged) { + streamErrorLogged = true + Log.Default.error("event stream error", { + error: error instanceof Error ? error.message : error, + }) + } + } finally { + signal.removeEventListener("abort", onAbort) + if (heartbeat) clearTimeout(heartbeat) } - if (!signal.aborted) { - await Bun.sleep(250) - } + if (!signal.aborted) await Bun.sleep(RECONNECT_DELAY_MS) } })().catch((error) => { - Log.Default.error("event stream error", { + Log.Default.error("event stream crashed", { error: error instanceof Error ? error.message : error, }) }) From 219953b2da0e98fe32fbe1b7407a8a470563211d Mon Sep 17 00:00:00 2001 From: niraj12chaudhary Date: Sun, 1 Mar 2026 10:02:49 +0530 Subject: [PATCH 2/2] fix(tui): use theme-aware dialog overlay on transparent system theme --- packages/opencode/src/cli/cmd/tui/ui/dialog.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 8cebd9cba54..9ee82df6690 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,6 +1,6 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" -import { useTheme } from "@tui/context/theme" +import { tint, useTheme } from "@tui/context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" @@ -14,10 +14,14 @@ export function Dialog( }>, ) { const dimensions = useTerminalDimensions() - const { theme } = useTheme() + const { mode, theme } = useTheme() const renderer = useRenderer() let dismiss = false + const overlay = () => { + if (theme.background.a !== 0) return RGBA.fromInts(0, 0, 0, 150) + return tint(theme.backgroundPanel, RGBA.fromInts(0, 0, 0), mode() === "dark" ? 0.45 : 0.18) + } return ( {