Просмотр исходного кода

Prepare TUI lifecycle for scenario tests (#28258)

Sebastian 2 месяцев назад
Родитель
Сommit
848d763d08

+ 196 - 98
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -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()
-
-    const onExit = async () => {
-      unguard?.()
-      resolve()
-    }
-    const onBeforeExit = async () => {
-      offKeymap()
+export function tui(input: TuiInput): TuiHandle {
+  const unguard = win32InstallCtrlCGuard()
+  win32DisableProcessedInput()
+
+  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 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()
+  }
 
-    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)
+  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[]> }) {

+ 5 - 2
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -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?.()
     }

+ 4 - 15
packages/opencode/src/cli/cmd/tui/component/error-component.tsx

@@ -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>

+ 31 - 49
packages/opencode/src/cli/cmd/tui/context/exit.tsx

@@ -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,
 })

+ 5 - 2
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -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()
       }

+ 5 - 84
packages/opencode/test/cli/cmd/tui/sync-fixture.tsx

@@ -2,15 +2,13 @@
 import { testRender } from "@opentui/solid"
 import { onMount } from "solid-js"
 import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
-import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
+import { createExit, ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
 import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
 import { ProjectProvider, useProject } from "../../../../src/cli/cmd/tui/context/project"
-import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
+import { SDKProvider } from "../../../../src/cli/cmd/tui/context/sdk"
 import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
-import type { GlobalEvent } from "@opencode-ai/sdk/v2"
-
-export const worktree = "/tmp/opencode"
-export const directory = `${worktree}/packages/opencode`
+import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
+export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
 
 export async function wait(fn: () => boolean, timeout = 2000) {
   const start = Date.now()
@@ -20,83 +18,6 @@ export async function wait(fn: () => boolean, timeout = 2000) {
   }
 }
 
-export function json(data: unknown, init?: ResponseInit) {
-  return new Response(JSON.stringify(data), {
-    ...init,
-    headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
-  })
-}
-
-export function eventSource(): EventSource {
-  return { subscribe: async () => () => {} }
-}
-
-export function createEventSource() {
-  let fn: ((event: GlobalEvent) => void) | undefined
-
-  return {
-    source: {
-      subscribe: async (handler: (event: GlobalEvent) => void) => {
-        fn = handler
-        return () => {
-          if (fn === handler) fn = undefined
-        }
-      },
-    } satisfies EventSource,
-    emit(event: GlobalEvent) {
-      if (!fn) throw new Error("event source not ready")
-      fn(event)
-    },
-  }
-}
-
-type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
-
-export function createFetch(override?: FetchHandler) {
-  const session = [] as URL[]
-  const fetch = (async (input: RequestInfo | URL) => {
-    const url = new URL(input instanceof Request ? input.url : String(input))
-    if (url.pathname === "/session") session.push(url)
-
-    const overridden = await override?.(url)
-    if (overridden) return overridden
-
-    switch (url.pathname) {
-      case "/agent":
-      case "/command":
-      case "/experimental/workspace":
-      case "/experimental/workspace/status":
-      case "/formatter":
-      case "/lsp":
-        return json([])
-      case "/config":
-      case "/experimental/resource":
-      case "/mcp":
-      case "/provider/auth":
-      case "/session/status":
-        return json({})
-      case "/config/providers":
-        return json({ providers: {}, default: {} })
-      case "/experimental/console":
-        return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
-      case "/path":
-        return json({ home: "", state: "", config: "", worktree, directory })
-      case "/project/current":
-        return json({ id: "proj_test" })
-      case "/provider":
-        return json({ all: [], default: {}, connected: [] })
-      case "/session":
-        return json([])
-      case "/vcs":
-        return json({ branch: "main" })
-    }
-
-    throw new Error(`unexpected request: ${url.pathname}`)
-  }) as typeof globalThis.fetch
-
-  return { fetch, session }
-}
-
 type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
 
 export async function mount(override?: FetchHandler) {
@@ -123,7 +44,7 @@ export async function mount(override?: FetchHandler) {
 
   const app = await testRender(() => (
     <ArgsProvider>
-      <ExitProvider>
+      <ExitProvider exit={createExit(async () => {})}>
         <KVProvider>
           <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
             <ProjectProvider>

+ 261 - 0
packages/opencode/test/cli/tui/app-lifecycle.test.ts

@@ -0,0 +1,261 @@
+import { afterEach, expect, spyOn, test } from "bun:test"
+import { createTestRenderer } from "@opentui/core/testing"
+import { mkdir } from "node:fs/promises"
+import path from "node:path"
+import { tmpdir } from "../../fixture/fixture"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
+import { TuiPluginRuntime } from "../../../src/cli/cmd/tui/plugin/runtime"
+import { tui, type TuiHandle } from "../../../src/cli/cmd/tui/app"
+import { Global } from "@opencode-ai/core/global"
+import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
+import * as TuiAudio from "../../../src/cli/cmd/tui/util/audio"
+import * as TuiKeymap from "../../../src/cli/cmd/tui/keymap"
+
+type TestRendererSetup = Awaited<ReturnType<typeof createTestRenderer>>
+type TmpDir = Awaited<ReturnType<typeof tmpdir>>
+
+const disabledInternalPlugins = {
+  "internal:home-footer": false,
+  "internal:home-tips": false,
+  "internal:sidebar-context": false,
+  "internal:sidebar-mcp": false,
+  "internal:sidebar-lsp": false,
+  "internal:sidebar-todo": false,
+  "internal:sidebar-files": false,
+  "internal:sidebar-footer": false,
+  "internal:plugin-manager": false,
+  "internal:session-v2-debug": false,
+  "which-key": false,
+}
+let active: { handle?: TuiHandle; setup?: TestRendererSetup; restore?: () => void; tmp?: TmpDir } | undefined
+
+afterEach(async () => {
+  const current = active
+  active = undefined
+  await current?.handle?.exit().catch(() => {})
+  await current?.handle?.done.catch(() => {})
+  await current?.handle?.ready.catch(() => {})
+  if (current?.setup && !current.setup.renderer.isDestroyed) current.setup.renderer.destroy()
+  current?.restore?.()
+  await Bun.sleep(20)
+  await current?.tmp?.[Symbol.asyncDispose]()
+  await TuiPluginRuntime.dispose().catch(() => {})
+})
+
+test("returns a handle immediately and resolves ready after async mount setup", async () => {
+  const app = await startTui()
+
+  expect(await promiseState(app.handle.ready)).toBe("pending")
+
+  app.theme.resolve("dark")
+  await app.handle.ready
+
+  expect(app.setup.renderer.isDestroyed).toBe(false)
+  expect(await promiseState(app.handle.done)).toBe("pending")
+})
+
+test("production can await done only and still receives mount failures", async () => {
+  const app = await startTui({ rejectTheme: new Error("theme failed") })
+
+  await expect(app.handle.done).rejects.toThrow("theme failed")
+  expect(app.setup.renderer.isDestroyed).toBe(true)
+})
+
+test("exit destroys the renderer, resolves done, and runs cleanup once", async () => {
+  const beforeSighup = process.listenerCount("SIGHUP")
+  const app = await startTui()
+
+  app.theme.resolve("dark")
+  await app.handle.ready
+  expect(process.listenerCount("SIGHUP")).toBeGreaterThan(beforeSighup)
+
+  await Promise.all([app.handle.exit(), app.handle.exit()])
+  await app.handle.done
+
+  expect(app.setup.renderer.isDestroyed).toBe(true)
+  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
+})
+
+test("exit preserves reason formatting and exit messages", async () => {
+  const stdout: string[] = []
+  const stderr: string[] = []
+  const stdoutWrite = spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
+    stdout.push(String(chunk))
+    return true
+  })
+  const stderrWrite = spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
+    stderr.push(String(chunk))
+    return true
+  })
+
+  try {
+    const app = await startTui()
+    app.theme.resolve("dark")
+    await app.handle.ready
+
+    app.handle.exit.message.set("goodbye")
+    await app.handle.exit(new Error("boom"))
+    await app.handle.done
+
+    expect(stderr.join("")).toContain("boom")
+    expect(stdout.join("")).toBe("goodbye\n")
+  } finally {
+    stdoutWrite.mockRestore()
+    stderrWrite.mockRestore()
+  }
+})
+
+test("exit before ready cancels mount and resolves done", async () => {
+  const app = await startTui()
+
+  await app.handle.exit()
+  await app.handle.done
+
+  expect(app.setup.renderer.isDestroyed).toBe(true)
+  await expect(app.handle.ready).resolves.toBeUndefined()
+})
+
+test("direct renderer destruction still cleans up and resolves done", async () => {
+  const beforeSighup = process.listenerCount("SIGHUP")
+  const app = await startTui()
+
+  app.theme.resolve("dark")
+  await app.handle.ready
+  app.setup.renderer.destroy()
+  await app.handle.done
+
+  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
+})
+
+test("SIGHUP exits before ready and removes its listener", async () => {
+  const beforeSighup = process.listenerCount("SIGHUP")
+  const app = await startTui()
+
+  process.emit("SIGHUP")
+  await app.handle.done
+
+  expect(app.setup.renderer.isDestroyed).toBe(true)
+  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
+})
+
+test("SIGHUP exits after ready and removes its listener", async () => {
+  const beforeSighup = process.listenerCount("SIGHUP")
+  const app = await startTui()
+
+  app.theme.resolve("dark")
+  await app.handle.ready
+  process.emit("SIGHUP")
+  await app.handle.done
+
+  expect(app.setup.renderer.isDestroyed).toBe(true)
+  expect(process.listenerCount("SIGHUP")).toBe(beforeSighup)
+})
+
+test("plugin, audio, and keymap cleanup run exactly once", async () => {
+  const originalRegister = TuiKeymap.registerOpencodeKeymap
+  let unregisterKeymapCalls = 0
+  const registerKeymap = spyOn(TuiKeymap, "registerOpencodeKeymap").mockImplementation((...args) => {
+    const unregister = originalRegister(...args)
+    return () => {
+      unregisterKeymapCalls++
+      unregister()
+    }
+  })
+  const disposePlugins = spyOn(TuiPluginRuntime, "dispose")
+  const disposeAudio = spyOn(TuiAudio, "dispose")
+
+  try {
+    const app = await startTui()
+    app.theme.resolve("dark")
+    await app.handle.ready
+
+    app.setup.renderer.destroy()
+    await Promise.all([app.handle.exit(), app.handle.exit()])
+    await app.handle.done
+
+    expect(registerKeymap).toHaveBeenCalledTimes(1)
+    expect(unregisterKeymapCalls).toBe(1)
+    expect(disposePlugins).toHaveBeenCalledTimes(1)
+    expect(disposeAudio).toHaveBeenCalledTimes(1)
+  } finally {
+    registerKeymap.mockRestore()
+    disposePlugins.mockRestore()
+    disposeAudio.mockRestore()
+  }
+})
+
+async function startTui(options: { rejectTheme?: Error } = {}) {
+  const tmp = await tmpdir()
+  const restore = await isolateGlobalPaths(tmp.path)
+  const setup = await createTestRenderer({ width: 80, height: 24, useThread: false, maxFps: Number.POSITIVE_INFINITY })
+  const theme = deferred<"dark" | "light" | null>()
+  const waitForThemeMode = spyOn(setup.renderer, "waitForThemeMode").mockImplementation(() => {
+    if (options.rejectTheme) return Promise.reject(options.rejectTheme)
+    return theme.promise
+  })
+  setup.renderer.once("destroy", () => theme.resolve(null))
+
+  const calls = createFetch()
+  const events = createEventSource()
+  const handle = tui({
+    url: "http://test",
+    renderer: setup.renderer,
+    config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }),
+    directory,
+    fetch: calls.fetch,
+    events: events.source,
+    args: {},
+  })
+  active = {
+    handle,
+    setup,
+    tmp,
+    restore: () => {
+      waitForThemeMode.mockRestore()
+      restore()
+    },
+  }
+
+  return { handle, setup, theme }
+}
+
+async function isolateGlobalPaths(root: string) {
+  const previous = {
+    config: Global.Path.config,
+    state: Global.Path.state,
+  }
+  Global.Path.config = path.join(root, "config")
+  Global.Path.state = path.join(root, "state")
+  await mkdir(Global.Path.config, { recursive: true })
+  await mkdir(Global.Path.state, { recursive: true })
+  await Bun.write(path.join(Global.Path.state, "kv.json"), JSON.stringify({ animations_enabled: false }))
+
+  return () => {
+    Global.Path.config = previous.config
+    Global.Path.state = previous.state
+  }
+}
+
+async function promiseState(promise: Promise<unknown>) {
+  let state: "pending" | "resolved" | "rejected" = "pending"
+  promise.then(
+    () => {
+      state = "resolved"
+    },
+    () => {
+      state = "rejected"
+    },
+  )
+  await Promise.resolve()
+  return state
+}
+
+function deferred<T>() {
+  let resolve!: (value: T | PromiseLike<T>) => void
+  let reject!: (error: unknown) => void
+  const promise = new Promise<T>((done, fail) => {
+    resolve = done
+    reject = fail
+  })
+  return { promise, resolve, reject }
+}

+ 6 - 29
packages/opencode/test/cli/tui/use-event.test.tsx

@@ -6,6 +6,7 @@ import { onMount } from "solid-js"
 import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
 import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
 import { useEvent } from "../../../src/cli/cmd/tui/context/event"
+import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk"
 
 const projectID = "proj_test"
 
@@ -46,35 +47,11 @@ function update(version: string): Event {
   }
 }
 
-function createSource() {
-  let fn: ((event: GlobalEvent) => void) | undefined
-
-  return {
-    source: {
-      subscribe: async (handler: (event: GlobalEvent) => void) => {
-        fn = handler
-        return () => {
-          if (fn === handler) fn = undefined
-        }
-      },
-    },
-    emit(evt: GlobalEvent) {
-      if (!fn) throw new Error("event source not ready")
-      fn(evt)
-    },
-  }
-}
-
 async function mount() {
-  const source = createSource()
+  const events = createEventSource()
+  const calls = createFetch()
   const seen: Event[] = []
   const workspaces: Array<string | undefined> = []
-  const fetch = (async (input: RequestInfo | URL) => {
-    const url = new URL(input instanceof Request ? input.url : String(input))
-    if (url.pathname === "/path") return Response.json({ home: "", state: "", config: "", directory: "/tmp/root" })
-    if (url.pathname === "/project/current") return Response.json({ id: projectID })
-    throw new Error(`unexpected request: ${url.pathname}`)
-  }) as typeof globalThis.fetch
   let project!: ReturnType<typeof useProject>
   let done!: () => void
   const ready = new Promise<void>((resolve) => {
@@ -82,7 +59,7 @@ async function mount() {
   })
 
   const app = await testRender(() => (
-    <SDKProvider url="http://test" directory="/tmp/root" events={source.source} fetch={fetch}>
+    <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
       <ProjectProvider>
         <Probe
           onReady={async (ctx) => {
@@ -98,7 +75,7 @@ async function mount() {
   ))
 
   await ready
-  return { app, emit: source.emit, project, seen, workspaces }
+  return { app, emit: events.emit, project, seen, workspaces }
 }
 
 function Probe(props: {
@@ -140,7 +117,7 @@ describe("useEvent", () => {
     const { app, emit, seen } = await mount()
 
     try {
-      emit(event(vcs("other"), { directory: "/tmp/root", project: "proj_other" }))
+      emit(event(vcs("other"), { directory, project: "proj_other" }))
       await Bun.sleep(30)
 
       expect(seen).toHaveLength(0)

+ 82 - 0
packages/opencode/test/fixture/tui-sdk.ts

@@ -0,0 +1,82 @@
+import type { GlobalEvent } from "@opencode-ai/sdk/v2"
+import type { EventSource } from "../../src/cli/cmd/tui/context/sdk"
+
+export const worktree = "/tmp/opencode"
+export const directory = `${worktree}/packages/opencode`
+
+export function json(data: unknown, init?: ResponseInit) {
+  return new Response(JSON.stringify(data), {
+    ...init,
+    headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
+  })
+}
+
+export function eventSource(): EventSource {
+  return { subscribe: async () => () => {} }
+}
+
+export function createEventSource() {
+  let fn: ((event: GlobalEvent) => void) | undefined
+
+  return {
+    source: {
+      subscribe: async (handler: (event: GlobalEvent) => void) => {
+        fn = handler
+        return () => {
+          if (fn === handler) fn = undefined
+        }
+      },
+    } satisfies EventSource,
+    emit(event: GlobalEvent) {
+      if (!fn) throw new Error("event source not ready")
+      fn(event)
+    },
+  }
+}
+
+export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
+
+export function createFetch(override?: FetchHandler) {
+  const session = [] as URL[]
+  const fetch = (async (input: RequestInfo | URL) => {
+    const url = new URL(input instanceof Request ? input.url : String(input))
+    if (url.pathname === "/session") session.push(url)
+
+    const overridden = await override?.(url)
+    if (overridden) return overridden
+
+    switch (url.pathname) {
+      case "/agent":
+      case "/command":
+      case "/experimental/workspace":
+      case "/experimental/workspace/status":
+      case "/formatter":
+      case "/lsp":
+        return json([])
+      case "/config":
+      case "/experimental/resource":
+      case "/mcp":
+      case "/provider/auth":
+      case "/session/status":
+        return json({})
+      case "/config/providers":
+        return json({ providers: {}, default: {} })
+      case "/experimental/console":
+        return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
+      case "/path":
+        return json({ home: "", state: "", config: "", worktree, directory })
+      case "/project/current":
+        return json({ id: "proj_test" })
+      case "/provider":
+        return json({ all: [], default: {}, connected: [] })
+      case "/session":
+        return json([])
+      case "/vcs":
+        return json({ branch: "main" })
+    }
+
+    throw new Error(`unexpected request: ${url.pathname}`)
+  }) as typeof globalThis.fetch
+
+  return { fetch, session }
+}