Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 41 additions & 13 deletions packages/opencode/src/cli/cmd/tui/context/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +62,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}
flush()
}
const wait = (ms: number) => new Promise<void>((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
Expand All @@ -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)
}
})

Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions packages/opencode/src/cli/cmd/tui/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 (
<box
Expand All @@ -38,7 +42,7 @@ export function Dialog(
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
backgroundColor={overlay()}
>
<box
onMouseUp={(e) => {
Expand Down
58 changes: 41 additions & 17 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
})
})
Expand Down
Loading