From d8d5ff3708a7353fba29724080ee13f18bca521d Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Tue, 19 May 2026 19:08:00 +0200 Subject: [PATCH] squash for rebase --- packages/opencode/src/cli/cmd/tui/app.tsx | 296 ++++++++++++------ packages/opencode/src/cli/cmd/tui/attach.ts | 7 +- .../cli/cmd/tui/component/error-component.tsx | 19 +- .../opencode/src/cli/cmd/tui/context/exit.tsx | 80 ++--- packages/opencode/src/cli/cmd/tui/thread.ts | 7 +- .../test/cli/cmd/tui/sync-fixture.tsx | 89 +----- .../test/cli/tui/app-lifecycle.test.ts | 261 +++++++++++++++ .../opencode/test/cli/tui/use-event.test.tsx | 35 +-- packages/opencode/test/fixture/tui-sdk.ts | 82 +++++ 9 files changed, 596 insertions(+), 280 deletions(-) create mode 100644 packages/opencode/test/cli/tui/app-lifecycle.test.ts create mode 100644 packages/opencode/test/fixture/tui-sdk.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index e326a39b59..7ff168060a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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 + done: Promise + exit: Exit +} + +type TuiInput = { + url: string + args: Args + config: TuiConfig.Resolved + renderer: CliRenderer + onSnapshot?: () => Promise + directory?: string + fetch?: typeof fetch + headers?: RequestInit["headers"] + events?: EventSource +} + +type TuiLifecycle = { + exit: Exit + exited: Promise + fail(error: unknown): Promise +} + 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 - 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(async (resolve) => { - const unguard = win32InstallCtrlCGuard() - win32DisableProcessedInput() +export function tui(input: TuiInput): TuiHandle { + const unguard = win32InstallCtrlCGuard() + win32DisableProcessedInput() - const onExit = async () => { - unguard?.() - resolve() - } - const onBeforeExit = async () => { - offKeymap() + 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 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 ( - ( - - )} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) - }, renderer) + }, }) + 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; 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 ( + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, renderer) +} + +function createTuiLifecycle(input: { + renderer: CliRenderer + unguard?: () => void + cleanup: () => Promise +}): TuiLifecycle { + let resolveExited!: () => void + const exited = new Promise((resolve) => { + resolveExited = resolve + }) + let exitCompleted = false + let exiting = false + let cleanupTask: Promise | 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() + } + + 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, exited: Promise) { + await ready + await exited } function App(props: { onSnapshot?: () => Promise }) { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 2897a41caf..b908887c39 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/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?.() } diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index fcbd27ca9b..e67dc249c8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/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 - onExit: () => Promise + exit: () => Promise 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: { Reset TUI - + void props.exit()} backgroundColor={colors.primary} padding={1}> Exit diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 205025f867..5b25a60039 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/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) & { + +export type Exit = ((reason?: unknown) => Promise) & { message: { set: (value?: string) => () => void clear: () => void @@ -10,51 +8,35 @@ type Exit = ((reason?: unknown) => Promise) & { } } +export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise) { + let message: string | undefined + let task: Promise | 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; onExit?: () => Promise }) => { - const renderer = useRenderer() - let message: string | undefined - let task: Promise | 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, }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 7230dae16a..382147d918 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/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() } diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx index 5f51374c16..4b3c6037ea 100644 --- a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx +++ b/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 | 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; project: ReturnType; sync: ReturnType } export async function mount(override?: FetchHandler) { @@ -123,7 +44,7 @@ export async function mount(override?: FetchHandler) { const app = await testRender(() => ( - + {})}> diff --git a/packages/opencode/test/cli/tui/app-lifecycle.test.ts b/packages/opencode/test/cli/tui/app-lifecycle.test.ts new file mode 100644 index 0000000000..8f5cb52346 --- /dev/null +++ b/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> +type TmpDir = Awaited> + +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) { + let state: "pending" | "resolved" | "rejected" = "pending" + promise.then( + () => { + state = "resolved" + }, + () => { + state = "rejected" + }, + ) + await Promise.resolve() + return state +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index d690cfd6ce..2aa3e97812 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/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 = [] - 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 let done!: () => void const ready = new Promise((resolve) => { @@ -82,7 +59,7 @@ async function mount() { }) const app = await testRender(() => ( - + { @@ -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) diff --git a/packages/opencode/test/fixture/tui-sdk.ts b/packages/opencode/test/fixture/tui-sdk.ts new file mode 100644 index 0000000000..cf59222a15 --- /dev/null +++ b/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 | 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 } +}