From 902b9a134e58d96c8b72bbf294b28e744cea64a7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 6 Jun 2026 23:15:52 -0400 Subject: [PATCH 1/3] refactor(tui): organize config modules --- packages/opencode/src/cli/tui/attention.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/opencode/src/cli/tui/attention.ts b/packages/opencode/src/cli/tui/attention.ts index 84d752f80a4a..cb65dcc45216 100644 --- a/packages/opencode/src/cli/tui/attention.ts +++ b/packages/opencode/src/cli/tui/attention.ts @@ -198,7 +198,13 @@ export function createTuiAttention(input: { const requestedSound = typeof request.sound === "object" ? request.sound : undefined const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus) const soundName = +<<<<<<< HEAD requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default" +======= + requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) + ? requestedSound.name + : "default" +>>>>>>> e5ff6e467 (refactor(tui): organize config modules) const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume) if (!notification && !sound) { From 539adf9c38fb426264ff616deef81184f010b946 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 7 Jun 2026 01:00:27 -0400 Subject: [PATCH 2/3] refactor(tui): simplify application lifecycle --- bun.lock | 5 +- packages/cli/src/commands/handlers/default.ts | 2 +- packages/cli/src/tui.ts | 94 +--- packages/opencode/package.json | 2 - packages/opencode/src/cli/cmd/attach.ts | 83 ++- packages/opencode/src/cli/cmd/tui.ts | 227 ++++---- packages/opencode/src/cli/tui/clipboard.ts | 181 ------ packages/opencode/src/cli/tui/editor.ts | 43 -- packages/opencode/src/cli/tui/host.ts | 36 -- packages/opencode/src/cli/tui/layer.ts | 7 + packages/opencode/src/cli/tui/platform.ts | 124 ---- packages/opencode/src/cli/tui/runtime.ts | 57 -- .../test/cli/cmd/tui/attention.test.ts | 2 +- .../test/cli/tui/app-lifecycle.test.ts | 330 ----------- packages/opencode/test/cli/tui/attach.test.ts | 5 +- .../test/cli/tui/editor-context-zed.test.ts | 7 +- .../test/cli/tui/editor-context.test.tsx | 28 +- packages/opencode/test/cli/tui/thread.test.ts | 5 +- .../opencode/test/fixture/tui-environment.tsx | 48 +- packages/tui/package.json | 11 +- packages/tui/src/app.tsx | 531 +++++++----------- .../src/cli/tui => tui/src}/attention.ts | 20 +- packages/tui/src/audio.d.ts | 9 + .../src/cli/tui => tui/src}/audio.ts | 15 +- packages/tui/src/clipboard.ts | 120 ++++ .../tui/src/component/dialog-move-session.tsx | 7 +- .../tui/src/component/dialog-provider.tsx | 8 +- .../tui/src/component/dialog-session-list.tsx | 5 +- .../tui/src/component/error-component.tsx | 19 +- .../tui/src/component/prompt/autocomplete.tsx | 8 +- packages/tui/src/component/prompt/index.tsx | 29 +- .../src/component/prompt/local-attachment.ts | 33 +- packages/tui/src/component/prompt/move.tsx | 6 +- packages/tui/src/context/clipboard.tsx | 18 + packages/tui/src/context/directory.ts | 9 +- packages/tui/src/context/editor.ts | 36 +- packages/tui/src/context/epilogue.tsx | 6 + packages/tui/src/context/exit.tsx | 42 -- packages/tui/src/context/kv.tsx | 17 +- packages/tui/src/context/local.tsx | 30 +- packages/tui/src/context/path-format.tsx | 9 +- packages/tui/src/context/route.tsx | 6 +- packages/tui/src/context/runtime.tsx | 62 ++ packages/tui/src/context/sdk.tsx | 7 +- packages/tui/src/context/sync.tsx | 31 +- packages/tui/src/context/theme.tsx | 46 +- .../src/cli/tui => tui/src}/editor-zed.ts | 7 +- packages/tui/src/editor.ts | 84 +++ .../tui/src/feature-plugins/home/footer.tsx | 9 +- .../src/feature-plugins/home/tips-view.tsx | 12 +- .../src/feature-plugins/sidebar/footer.tsx | 9 +- .../src/feature-plugins/system/session-v2.tsx | 4 +- packages/tui/src/index.tsx | 23 +- packages/tui/src/platform.tsx | 52 -- packages/tui/src/prompt/frecency.tsx | 10 +- packages/tui/src/prompt/history.tsx | 6 +- packages/tui/src/prompt/stash.tsx | 6 +- .../src/routes/home/session-destination.tsx | 6 +- .../tui/src/routes/session/dialog-message.tsx | 6 +- packages/tui/src/routes/session/index.tsx | 64 ++- packages/tui/src/routes/session/sidebar.tsx | 7 +- packages/tui/src/runtime.tsx | 83 --- .../win32.ts => tui/src/terminal-win32.ts} | 0 packages/tui/src/ui/dialog.tsx | 15 +- packages/tui/src/util/presentation.ts | 2 +- packages/tui/src/util/renderer.ts | 6 + packages/tui/src/util/selection.ts | 6 +- packages/tui/test/app-lifecycle.test.tsx | 59 ++ .../tui/test/cli/cmd/tui/sync-fixture.tsx | 21 +- .../tui/test/cli/tui/dialog-prompt.test.tsx | 6 +- .../cli/tui/diff-viewer-file-tree.test.tsx | 6 +- .../tui/test/cli/tui/diff-viewer.test.tsx | 6 +- packages/tui/test/cli/tui/sync-v2.test.tsx | 30 +- packages/tui/test/cli/tui/use-event.test.tsx | 6 +- packages/tui/test/clipboard.test.ts | 19 + packages/tui/test/editor.test.ts | 23 + packages/tui/test/fixture/tui-environment.tsx | 48 +- packages/tui/test/index.test.tsx | 6 +- packages/tui/test/platform.test.tsx | 40 -- .../tui/test/prompt/local-attachment.test.ts | 14 +- packages/tui/test/runtime.test.tsx | 51 +- packages/tui/test/theme.test.ts | 16 + packages/tui/test/util/presentation.test.ts | 15 +- packages/tui/test/util/renderer.test.ts | 30 + 84 files changed, 1233 insertions(+), 2006 deletions(-) delete mode 100644 packages/opencode/src/cli/tui/clipboard.ts delete mode 100644 packages/opencode/src/cli/tui/editor.ts delete mode 100644 packages/opencode/src/cli/tui/host.ts create mode 100644 packages/opencode/src/cli/tui/layer.ts delete mode 100644 packages/opencode/src/cli/tui/platform.ts delete mode 100644 packages/opencode/src/cli/tui/runtime.ts delete mode 100644 packages/opencode/test/cli/tui/app-lifecycle.test.ts rename packages/{opencode/src/cli/tui => tui/src}/attention.ts (92%) create mode 100644 packages/tui/src/audio.d.ts rename packages/{opencode/src/cli/tui => tui/src}/audio.ts (77%) create mode 100644 packages/tui/src/clipboard.ts create mode 100644 packages/tui/src/context/clipboard.tsx create mode 100644 packages/tui/src/context/epilogue.tsx delete mode 100644 packages/tui/src/context/exit.tsx create mode 100644 packages/tui/src/context/runtime.tsx rename packages/{opencode/src/cli/tui => tui/src}/editor-zed.ts (97%) create mode 100644 packages/tui/src/editor.ts delete mode 100644 packages/tui/src/platform.tsx rename packages/{opencode/src/cli/tui/win32.ts => tui/src/terminal-win32.ts} (100%) create mode 100644 packages/tui/src/util/renderer.ts create mode 100644 packages/tui/test/app-lifecycle.test.tsx create mode 100644 packages/tui/test/clipboard.test.ts create mode 100644 packages/tui/test/editor.test.ts delete mode 100644 packages/tui/test/platform.test.tsx create mode 100644 packages/tui/test/util/renderer.test.ts diff --git a/bun.lock b/bun.lock index a6e3f6ac17c9..8e3f50642fa3 100644 --- a/bun.lock +++ b/bun.lock @@ -536,7 +536,6 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", - "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -558,7 +557,6 @@ "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", "chokidar": "4.0.3", - "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", "diff": "catalog:", @@ -796,12 +794,15 @@ "name": "@opencode-ai/tui", "version": "0.0.0", "dependencies": { + "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/scheduled": "1.5.2", + "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index d4bbb363a5e4..d0a9968e5d8e 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -8,6 +8,6 @@ export default Runtime.handler(Commands, () => const daemon = yield* Daemon.Service const transport = yield* daemon.transport() const { runTui } = yield* Effect.promise(() => import("../../tui")) - yield* Effect.promise(() => runTui(transport)) + yield* runTui(transport) }), ) diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 3b8cc13db87f..4722441b2c8f 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -1,102 +1,20 @@ -import { createTuiBuildInfo, createTuiEnvironment, createTuiRenderer, run, type TuiHost } from "@opencode-ai/tui" +import { run } from "@opencode-ai/tui" import { TuiConfig } from "@opencode-ai/tui/config" -import type { TuiPlatform } from "@opencode-ai/tui/platform" -import os from "node:os" -import path from "node:path" +import { Effect } from "effect" +import { Global } from "@opencode-ai/core/global" -declare const OPENCODE_VERSION: string | undefined -declare const OPENCODE_CHANNEL: string | undefined - -export async function runTui(transport: { url: string; headers: RequestInit["headers"] }) { +export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { const config = TuiConfig.resolve({}, { terminalSuspend: false }) - const state = path.join(os.homedir(), ".local", "state", "opencode") - const environment = createTuiEnvironment({ - cwd: process.cwd(), - platform: process.platform, - paths: { - home: os.homedir(), - state, - worktree: path.join(state, "worktree"), - }, - capabilities: { - mouse: config.mouse, - copyOnSelect: true, - terminalTitle: true, - terminalSuspend: false, - workspaces: false, - showTimeToFirstDraw: false, - }, - terminal: { - multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined, - displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined, - }, - editor: { zedTerminal: false }, - skipInitialLoading: false, - }) - const build = createTuiBuildInfo({ - version: typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local", - channel: typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local", - }) - const renderer = await createTuiRenderer(config, { environment, build }) - const handle = run({ + return run({ ...transport, args: {}, config, - environment, - build, - renderer, fetch: gracefulFetch, pluginHost: { async start() {}, async dispose() {}, }, - host: createHost(), - }) - await handle.done -} - -function createHost(): TuiHost { - return { - platform, - attention() { - return { - async notify() { - return { ok: false, notification: false, sound: false, skipped: "attention_disabled" } - }, - soundboard: { - registerPack: () => () => {}, - activate: () => false, - current: () => "", - list: () => [], - }, - dispose() {}, - } - }, - logger: { error: (message, extra) => console.error(message, extra ?? "") }, - lifecycle: { - onSighup(handler) { - process.on("SIGHUP", handler) - return () => process.off("SIGHUP", handler) - }, - writeStdout: (text) => process.stdout.write(text), - writeStderr: (text) => process.stderr.write(text), - }, - formatError: () => undefined, - formatUnknownError(error) { - if (error instanceof Error) return error.message - return String(error) - }, - } -} - -const platform: TuiPlatform = { - files: { - readText: (file) => Bun.file(file).text(), - readBytes: async (file) => new Uint8Array(await Bun.file(file).arrayBuffer()), - async mime(file) { - return Bun.file(file).type || "application/octet-stream" - }, - }, + }).pipe(Effect.provide(Global.defaultLayer)) } const legacyDefaults: Record = { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fbb04035d174..d1bbf9dd1506 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -90,7 +90,6 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", - "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", @@ -112,7 +111,6 @@ "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", "chokidar": "4.0.3", - "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", "diff": "catalog:", diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index f66f381267ba..278cee8a70bc 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -1,10 +1,8 @@ import { cmd } from "./cmd" import { UI } from "@/cli/ui" -import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32" import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" -import { resolveTuiRuntime } from "../tui/runtime" export const AttachCommand = cmd({ command: "attach ", @@ -46,54 +44,46 @@ export const AttachCommand = cmd({ }), handler: async (args) => { const { TuiConfig } = await import("@/config/tui") - const unguard = win32InstallCtrlCGuard() - try { - win32DisableProcessedInput() - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exitCode = 1 - return - } - - const directory = (() => { - if (!args.dir) return undefined - try { - process.chdir(args.dir) - return process.cwd() - } catch { - // If the directory doesn't exist locally (remote attach), pass it through. - return args.dir - } - })() - const headers = ServerAuth.headers({ password: args.password, username: args.username }) - const config = await TuiConfig.get() - const runtime = resolveTuiRuntime(config) + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exitCode = 1 + return + } + const directory = (() => { + if (!args.dir) return undefined try { - await validateSession({ - url: args.url, - sessionID: args.session, - directory, - headers, - }) - } catch (error) { - UI.error(errorMessage(error)) - process.exitCode = 1 - return + process.chdir(args.dir) + return process.cwd() + } catch { + // If the directory doesn't exist locally (remote attach), pass it through. + return args.dir } + })() + const headers = ServerAuth.headers({ password: args.password, username: args.username }) + const config = await TuiConfig.get() + + try { + await validateSession({ + url: args.url, + sessionID: args.session, + directory, + headers, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } - const { createTuiRenderer, tui } = await import("@opencode-ai/tui") - const { createLegacyTuiHost } = await import("../tui/host") - const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") - const renderer = await createTuiRenderer(config, runtime) - const handle = tui({ - ...runtime, + const { Effect } = await import("effect") + const { run } = await import("../tui/layer") + const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") + await Effect.runPromise( + run({ url: args.url, config, - host: createLegacyTuiHost(renderer), pluginHost: createLegacyTuiPluginHost(), - renderer, args: { continue: args.continue, sessionID: args.session, @@ -101,10 +91,7 @@ export const AttachCommand = cmd({ }, directory, headers, - }) - await handle.done - } finally { - unguard?.() - } + }), + ) }, }) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 6416468cf497..67b634891cc4 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -11,7 +11,6 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { EventSource } from "@opencode-ai/tui/context/sdk" -import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32" import { writeHeapSnapshot } from "v8" import { OPENCODE_PROCESS_ROLE, @@ -20,7 +19,6 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "../tui/validate-session" -import { resolveTuiRuntime } from "../tui/runtime" declare global { const OPENCODE_WORKER_PATH: string @@ -114,137 +112,125 @@ export const TuiThreadCommand = cmd({ }), handler: async (args) => { const { TuiConfig } = await import("@/config/tui") - // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it. - // (Important when running under `bun run` wrappers on Windows.) - const unguard = win32InstallCtrlCGuard() - try { - // Must be the very first thing — disables CTRL_C_EVENT before any Worker - // spawn or async work so the OS cannot kill the process group. - win32DisableProcessedInput() - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exitCode = 1 - return - } + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exitCode = 1 + return + } - // Resolve relative --project paths from PWD, then use the real cwd after - // chdir so the thread and worker share the same directory key. - const next = resolveThreadDirectory(args.project) - const file = await target() - try { - process.chdir(next) - } catch { - UI.error("Failed to change directory to " + next) - return - } - const cwd = Filesystem.resolve(process.cwd()) - const env = sanitizedProcessEnv({ - [OPENCODE_PROCESS_ROLE]: "worker", - [OPENCODE_RUN_ID]: ensureRunID(), - }) + // Resolve relative --project paths from PWD, then use the real cwd after + // chdir so the thread and worker share the same directory key. + const next = resolveThreadDirectory(args.project) + const file = await target() + try { + process.chdir(next) + } catch { + UI.error("Failed to change directory to " + next) + return + } + const cwd = Filesystem.resolve(process.cwd()) + const env = sanitizedProcessEnv({ + [OPENCODE_PROCESS_ROLE]: "worker", + [OPENCODE_RUN_ID]: ensureRunID(), + }) - const worker = new Worker(file, { - env, + const worker = new Worker(file, { + env, + }) + worker.onerror = (e) => { + Log.Default.error("thread error", { + message: e.message, + filename: e.filename, + lineno: e.lineno, + colno: e.colno, + error: e.error, }) - worker.onerror = (e) => { - Log.Default.error("thread error", { - message: e.message, - filename: e.filename, - lineno: e.lineno, - colno: e.colno, - error: e.error, - }) - } + } - const client = Rpc.client(worker) - const error = (e: unknown) => { - Log.Default.error("process error", { error: errorMessage(e) }) - } - const reload = () => { - client.call("reload", undefined).catch((err) => { - Log.Default.warn("worker reload failed", { - error: errorMessage(err), - }) + const client = Rpc.client(worker) + const error = (e: unknown) => { + Log.Default.error("process error", { error: errorMessage(e) }) + } + const reload = () => { + client.call("reload", undefined).catch((err) => { + Log.Default.warn("worker reload failed", { + error: errorMessage(err), }) - } - process.on("uncaughtException", error) - process.on("unhandledRejection", error) - process.on("SIGUSR2", reload) - - let stopped = false - const stop = async () => { - if (stopped) return - stopped = true - process.off("uncaughtException", error) - process.off("unhandledRejection", error) - process.off("SIGUSR2", reload) - await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { - Log.Default.warn("worker shutdown failed", { - error: errorMessage(error), - }) + }) + } + process.on("uncaughtException", error) + process.on("unhandledRejection", error) + process.on("SIGUSR2", reload) + + let stopped = false + const stop = async () => { + if (stopped) return + stopped = true + process.off("uncaughtException", error) + process.off("unhandledRejection", error) + process.off("SIGUSR2", reload) + await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { + Log.Default.warn("worker shutdown failed", { + error: errorMessage(error), }) - worker.terminate() - } - - const prompt = await input(args.prompt) - const config = await TuiConfig.get() - const runtime = resolveTuiRuntime(config) - - const network = resolveNetworkOptionsNoConfig(args) - const external = - process.argv.includes("--port") || - process.argv.includes("--hostname") || - process.argv.includes("--mdns") || - network.mdns || - network.port !== 0 || - network.hostname !== "127.0.0.1" + }) + worker.terminate() + } - const transport = external - ? { - url: (await client.call("server", network)).url, - fetch: undefined, - events: undefined, - } - : { - url: "http://opencode.internal", - fetch: createWorkerFetch(client), - events: createEventSource(client), - } + const prompt = await input(args.prompt) + const config = await TuiConfig.get() + + const network = resolveNetworkOptionsNoConfig(args) + const external = + process.argv.includes("--port") || + process.argv.includes("--hostname") || + process.argv.includes("--mdns") || + network.mdns || + network.port !== 0 || + network.hostname !== "127.0.0.1" + + const transport = external + ? { + url: (await client.call("server", network)).url, + fetch: undefined, + events: undefined, + } + : { + url: "http://opencode.internal", + fetch: createWorkerFetch(client), + events: createEventSource(client), + } - try { - await validateSession({ - url: transport.url, - sessionID: args.session, - directory: cwd, - fetch: transport.fetch, - }) - } catch (error) { - UI.error(errorMessage(error)) - process.exitCode = 1 - return - } + try { + await validateSession({ + url: transport.url, + sessionID: args.session, + directory: cwd, + fetch: transport.fetch, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } - setTimeout(() => { - client.call("checkUpgrade", { directory: cwd }).catch(() => {}) - }, 1000).unref?.() + setTimeout(() => { + client.call("checkUpgrade", { directory: cwd }).catch(() => {}) + }, 1000).unref?.() - try { - const { createTuiRenderer, tui } = await import("@opencode-ai/tui") - const { createLegacyTuiHost } = await import("../tui/host") - const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") - const renderer = await createTuiRenderer(config, runtime) - const handle = tui({ - ...runtime, + try { + const { Effect } = await import("effect") + const { run } = await import("../tui/layer") + const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") + await Effect.runPromise( + run({ url: transport.url, - renderer, async onSnapshot() { const tui = writeHeapSnapshot("tui.heapsnapshot") const server = await client.call("snapshot", undefined) return [tui, server] }, config, - host: createLegacyTuiHost(renderer), pluginHost: createLegacyTuiPluginHost(), directory: cwd, fetch: transport.fetch, @@ -257,13 +243,10 @@ export const TuiThreadCommand = cmd({ prompt, fork: args.fork, }, - }) - await handle.done - } finally { - await stop() - } + }), + ) } finally { - unguard?.() + await stop() } process.exit(0) }, diff --git a/packages/opencode/src/cli/tui/clipboard.ts b/packages/opencode/src/cli/tui/clipboard.ts deleted file mode 100644 index 3bb50dffec11..000000000000 --- a/packages/opencode/src/cli/tui/clipboard.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { platform, release } from "os" -import { lazy } from "../../util/lazy.js" -import { tmpdir } from "os" -import path from "path" -import fs from "fs/promises" -import { Effect } from "effect" -import { ChildProcess } from "effect/unstable/process" -import { AppProcess } from "@opencode-ai/core/process" -import * as Filesystem from "../../util/filesystem" -import * as Process from "../../util/process" - -const writeWithStdin = (cmd: string[], text: string): Promise => - Effect.runPromise( - AppProcess.Service.use((svc) => svc.run(ChildProcess.make(cmd[0]!, cmd.slice(1)), { stdin: text })).pipe( - Effect.provide(AppProcess.defaultLayer), - Effect.catch(() => Effect.void), - Effect.asVoid, - ), - ).catch(() => undefined) - -// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup -const getWhich = lazy(async () => { - const { which } = await import("@opencode-ai/core/util/which") - return which -}) - -const getClipboardy = lazy(async () => { - const { default: clipboardy } = await import("clipboardy") - return clipboardy -}) - -/** - * Writes text to clipboard via OSC 52 escape sequence. - * This allows clipboard operations to work over SSH by having - * the terminal emulator handle the clipboard locally. - */ -function writeOsc52(text: string): void { - if (!process.stdout.isTTY) return - const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const passthrough = process.env["TMUX"] || process.env["STY"] - const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - process.stdout.write(sequence) -} - -export interface Content { - data: string - mime: string -} - -// Checks clipboard for images first, then falls back to text. -// -// On Windows prompt/ can call this from multiple paste signals because -// terminals surface image paste differently: -// 1. A forwarded Ctrl+V keypress -// 2. An empty bracketed-paste hint for image-only clipboard in Windows -// Terminal <1.25 -// 3. A kitty Ctrl+V key-release fallback for Windows Terminal 1.25+ -export async function read(): Promise { - const os = platform() - - if (os === "darwin") { - const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") - try { - await Process.run( - [ - "osascript", - "-e", - 'set imageData to the clipboard as "PNGf"', - "-e", - `set fileRef to open for access POSIX file "${tmpfile}" with write permission`, - "-e", - "set eof fileRef to 0", - "-e", - "write imageData to fileRef", - "-e", - "close access fileRef", - ], - { nothrow: true }, - ) - const buffer = await Filesystem.readBytes(tmpfile) - return { data: buffer.toString("base64"), mime: "image/png" } - } catch { - } finally { - await fs.rm(tmpfile, { force: true }).catch(() => {}) - } - } - - // Windows/WSL: probe clipboard for images via PowerShell. - // Bracketed paste can't carry image data so we read it directly. - if (os === "win32" || release().includes("WSL")) { - const script = - "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" - const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], { - nothrow: true, - }) - if (base64.text) { - const imageBuffer = Buffer.from(base64.text.trim(), "base64") - if (imageBuffer.length > 0) { - return { data: imageBuffer.toString("base64"), mime: "image/png" } - } - } - } - - if (os === "linux") { - const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true }) - if (wayland.stdout.byteLength > 0) { - return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" } - } - const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], { - nothrow: true, - }) - if (x11.stdout.byteLength > 0) { - return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" } - } - } - - const clipboardy = await getClipboardy() - const text = await clipboardy.read().catch(() => {}) - if (text) { - return { data: text, mime: "text/plain" } - } -} - -const getCopyMethod = lazy(async () => { - const os = platform() - const which = await getWhich() - - if (os === "darwin" && which("osascript")) { - console.log("clipboard: using osascript") - return async (text: string) => { - const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) - } - } - - if (os === "linux") { - if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) { - console.log("clipboard: using wl-copy") - return (text: string) => writeWithStdin(["wl-copy"], text) - } - if (which("xclip")) { - console.log("clipboard: using xclip") - return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text) - } - if (which("xsel")) { - console.log("clipboard: using xsel") - return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text) - } - } - - if (os === "win32") { - console.log("clipboard: using powershell") - return (text: string) => - // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) - writeWithStdin( - [ - "powershell.exe", - "-NonInteractive", - "-NoProfile", - "-Command", - "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", - ], - text, - ) - } - - console.log("clipboard: no native support") - return async (text: string) => { - const clipboardy = await getClipboardy() - await clipboardy.write(text).catch(() => {}) - } -}) - -export async function copy(text: string): Promise { - writeOsc52(text) - const method = await getCopyMethod() - await method(text) -} - -export * as Clipboard from "./clipboard" diff --git a/packages/opencode/src/cli/tui/editor.ts b/packages/opencode/src/cli/tui/editor.ts deleted file mode 100644 index d6a74f4cc1a0..000000000000 --- a/packages/opencode/src/cli/tui/editor.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { defer } from "@/util/defer" -import { existsSync } from "node:fs" -import { rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { CliRenderer } from "@opentui/core" -import { Filesystem } from "@/util/filesystem" -import { Process } from "@/util/process" - -export async function open(opts: { value: string; renderer: CliRenderer; cwd?: string }): Promise { - const editor = process.env["VISUAL"] || process.env["EDITOR"] - if (!editor) return - - const filepath = join(tmpdir(), `${Date.now()}.md`) - await using _ = defer(async () => rm(filepath, { force: true })) - - // In attach mode the server's project directory may not exist locally. - // Fall back to the local process cwd so the editor can still spawn. - const cwd = opts.cwd && existsSync(opts.cwd) ? opts.cwd : process.cwd() - - await Filesystem.write(filepath, opts.value) - opts.renderer.suspend() - opts.renderer.currentRenderBuffer.clear() - try { - const parts = editor.split(" ") - const proc = Process.spawn([...parts, filepath], { - cwd, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - shell: process.platform === "win32", - }) - await proc.exited - const content = await Filesystem.readText(filepath) - return content || undefined - } finally { - opts.renderer.currentRenderBuffer.clear() - opts.renderer.resume() - opts.renderer.requestRender() - } -} - -export * as Editor from "./editor" diff --git a/packages/opencode/src/cli/tui/host.ts b/packages/opencode/src/cli/tui/host.ts deleted file mode 100644 index 1398beda3f3f..000000000000 --- a/packages/opencode/src/cli/tui/host.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { TuiHost, TuiInput } from "@opencode-ai/tui" -import { Log } from "@opencode-ai/core/util/log" -import { FormatError, FormatUnknownError } from "@/cli/error" -import { createTuiAttention } from "./attention" -import { createLegacyTuiPlatform } from "./platform" -import * as TuiAudio from "./audio" -import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" - -export function createLegacyTuiHost(renderer: TuiInput["renderer"]): TuiHost { - return { - platform: createLegacyTuiPlatform(renderer), - attention: createTuiAttention, - logger: Log.Default, - disposeAudio: TuiAudio.dispose, - formatError: FormatError, - formatUnknownError: FormatUnknownError, - lifecycle: { - prepare() { - const unguard = win32InstallCtrlCGuard() - win32DisableProcessedInput() - return unguard - }, - flushInput: win32FlushInputBuffer, - onSighup(handler) { - process.on("SIGHUP", handler) - return () => process.off("SIGHUP", handler) - }, - writeStdout: (text) => process.stdout.write(text), - writeStderr: (text) => process.stderr.write(text), - suspend(resume) { - process.once("SIGCONT", resume) - process.kill(0, "SIGTSTP") - }, - }, - } -} diff --git a/packages/opencode/src/cli/tui/layer.ts b/packages/opencode/src/cli/tui/layer.ts new file mode 100644 index 000000000000..cc88498cb98a --- /dev/null +++ b/packages/opencode/src/cli/tui/layer.ts @@ -0,0 +1,7 @@ +import { run as runTui, type TuiInput } from "@opencode-ai/tui" +import { Global } from "@opencode-ai/core/global" +import { Effect } from "effect" + +export function run(input: TuiInput) { + return runTui(input).pipe(Effect.provide(Global.defaultLayer)) +} diff --git a/packages/opencode/src/cli/tui/platform.ts b/packages/opencode/src/cli/tui/platform.ts deleted file mode 100644 index 001c81649ce6..000000000000 --- a/packages/opencode/src/cli/tui/platform.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { CliRenderer } from "@opentui/core" -import type { TuiPlatform } from "@opencode-ai/tui/platform" -import { Filesystem } from "@/util/filesystem" -import { Clipboard } from "./clipboard" -import { Editor } from "./editor" -import { Flock } from "@opencode-ai/core/util/flock" -import { Glob } from "@opencode-ai/core/util/glob" -import { Global } from "@opencode-ai/core/global" -import { readJson, writeJsonAtomic } from "@opencode-ai/tui/util/persistence" -import path from "path" -import os from "node:os" -import { readdirSync, readFileSync, statSync } from "node:fs" -import { resolveZedSelection } from "./editor-zed" - -export function createLegacyTuiPlatform(renderer: CliRenderer): TuiPlatform { - const statePath = path.join(Global.Path.state, "kv.json") - const stateLock = `tui-kv:${statePath}` - return { - files: { - readText: Filesystem.readText, - readBytes: Filesystem.readBytes, - mime: Filesystem.mimeType, - }, - state: { - read: () => Flock.withLock(stateLock, () => readJson>(statePath)), - write: (value) => Flock.withLock(stateLock, () => writeJsonAtomic(statePath, value)), - }, - themes: { - async discover() { - const directories = [ - Global.Path.config, - ...(await Array.fromAsync(Filesystem.up({ targets: [".opencode"], start: process.cwd() }))), - ] - const result: Record = {} - for (const dir of directories) { - for (const item of await Glob.scan("themes/*.json", { - cwd: dir, - absolute: true, - dot: true, - symlink: true, - })) { - result[path.basename(item, ".json")] = await Filesystem.readJson(item) - } - } - return result - }, - subscribeRefresh(refresh) { - process.on("SIGUSR2", refresh) - return () => process.off("SIGUSR2", refresh) - }, - }, - clipboard: { - read: Clipboard.read, - write: Clipboard.copy, - }, - editor: { - open: (input) => Editor.open({ ...input, renderer }), - connection: discoverEditorConnection, - selection: (directory) => resolveZedSelection(resolveZedDbPath(), directory), - }, - export: { - write: Filesystem.write, - }, - } -} - -export function discoverEditorConnection(directory: string) { - const root = path.join(os.homedir(), ".claude", "ide") - const contains = (parent: string) => { - const resolved = path.resolve(parent) - const relative = path.relative(resolved, path.resolve(directory)) - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0 - } - try { - return readdirSync(root) - .filter((entry) => entry.endsWith(".lock")) - .flatMap((entry) => { - const file = path.join(root, entry) - const port = Number.parseInt(path.basename(file, ".lock"), 10) - if (!Number.isInteger(port) || port <= 0 || port > 65535) return [] - try { - const value = JSON.parse(readFileSync(file, "utf-8")) as Record - if (value.transport !== undefined && value.transport !== "ws") return [] - const folders = Array.isArray(value.workspaceFolders) - ? value.workspaceFolders.filter((item): item is string => typeof item === "string") - : [] - const score = Math.max(0, ...folders.map(contains)) - if (!score) return [] - return [ - { - url: `ws://127.0.0.1:${port}`, - authToken: typeof value.authToken === "string" ? value.authToken : undefined, - source: `lock:${port}`, - score, - mtime: statSync(file).mtimeMs, - }, - ] - } catch { - return [] - } - }) - .sort((left, right) => right.score - left.score || right.mtime - left.mtime) - .map(({ url, authToken, source }) => ({ url, authToken, source }))[0] - } catch { - return undefined - } -} - -function resolveZedDbPath() { - const candidates = [ - process.env.OPENCODE_ZED_DB, - path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"), - path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"), - ].filter((item): item is string => Boolean(item)) - return ( - candidates.find((item) => { - try { - return statSync(item).isFile() - } catch { - return false - } - }) ?? "" - ) -} diff --git a/packages/opencode/src/cli/tui/runtime.ts b/packages/opencode/src/cli/tui/runtime.ts deleted file mode 100644 index 86d2e80095c1..000000000000 --- a/packages/opencode/src/cli/tui/runtime.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" -import type { TuiConfig } from "@opencode-ai/tui/config" -import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime" -import path from "path" -import { isZedTerminal, resolveZedDbPath } from "./editor-zed" - -export function resolveTuiRuntime(config: TuiConfig.Resolved) { - return { - environment: createTuiEnvironment({ - cwd: process.cwd(), - platform: process.platform, - initialRoute: parseInitialRoute(process.env.OPENCODE_ROUTE), - paths: { - home: Global.Path.home, - state: Global.Path.state, - worktree: path.join(Global.Path.data, "worktree"), - }, - capabilities: { - mouse: !Flag.OPENCODE_DISABLE_MOUSE && (config.mouse ?? true), - copyOnSelect: !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT, - terminalTitle: !Flag.OPENCODE_DISABLE_TERMINAL_TITLE, - terminalSuspend: process.platform !== "win32", - workspaces: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - showTimeToFirstDraw: Flag.OPENCODE_SHOW_TTFD, - }, - terminal: { - multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined, - displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined, - }, - editor: { - command: process.env.VISUAL || process.env.EDITOR, - port: parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT), - zedTerminal: isZedTerminal(), - zedDatabase: resolveZedDbPath(), - }, - skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT), - }), - build: createTuiBuildInfo({ - version: InstallationVersion, - channel: InstallationChannel, - }), - } -} - -function parsePort(value: string | undefined) { - if (!value) return - const port = Number.parseInt(value, 10) - if (!Number.isInteger(port) || port <= 0 || port > 65535) return - return port -} - -function parseInitialRoute(value: string | undefined) { - if (!value) return - return JSON.parse(value) as unknown -} diff --git a/packages/opencode/test/cli/cmd/tui/attention.test.ts b/packages/opencode/test/cli/cmd/tui/attention.test.ts index 97188f972aae..c8644385abc4 100644 --- a/packages/opencode/test/cli/cmd/tui/attention.test.ts +++ b/packages/opencode/test/cli/cmd/tui/attention.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { AudioPlayOptions, AudioSound } from "@opentui/core" -import { createTuiAttention } from "@/cli/tui/attention" +import { createTuiAttention } from "@opencode-ai/tui/attention" import type { TuiConfig } from "@opencode-ai/tui/config" type FocusEvent = "focus" | "blur" diff --git a/packages/opencode/test/cli/tui/app-lifecycle.test.ts b/packages/opencode/test/cli/tui/app-lifecycle.test.ts deleted file mode 100644 index ec329082db2c..000000000000 --- a/packages/opencode/test/cli/tui/app-lifecycle.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -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 { tui, type TuiHandle } from "@opencode-ai/tui" -import { createLegacyTuiHost } from "../../../src/cli/tui/host" -import { Global } from "@opencode-ai/core/global" -import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" -import * as TuiAudio from "../../../src/cli/tui/audio" -import * as TuiKeymap from "@opencode-ai/tui/keymap" -import { createTuiBuildInfo, createTuiEnvironment } from "@opencode-ai/tui/runtime" - -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]() -}) - -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("plugin startup failure does not fail the app", async () => { - const error = spyOn(console, "error").mockImplementation(() => {}) - try { - const app = await startTui({ rejectPlugins: new Error("plugins failed") }) - app.theme.resolve("dark") - - await expect(app.handle.ready).resolves.toBeUndefined() - await app.pluginHost.started - expect(app.setup.renderer.isDestroyed).toBe(false) - expect(app.pluginHost.starts).toBe(1) - await app.handle.exit() - await app.handle.done - } finally { - error.mockRestore() - } -}) - -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")).toBeLessThanOrEqual(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")).toBeLessThanOrEqual(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")).toBeLessThanOrEqual(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 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(app.pluginHost.disposes).toBe(1) - expect(disposeAudio).toHaveBeenCalledTimes(1) - } finally { - registerKeymap.mockRestore() - disposeAudio.mockRestore() - } -}) - -test("plugin disposal failure does not stop remaining cleanup", async () => { - const error = spyOn(console, "error").mockImplementation(() => {}) - const disposeAudio = spyOn(TuiAudio, "dispose") - try { - const app = await startTui({ rejectPluginDispose: new Error("dispose failed") }) - app.theme.resolve("dark") - await app.handle.ready - - await app.handle.exit() - await app.handle.done - - expect(app.pluginHost.disposes).toBe(1) - expect(disposeAudio).toHaveBeenCalledTimes(1) - expect(app.setup.renderer.isDestroyed).toBe(true) - } finally { - error.mockRestore() - disposeAudio.mockRestore() - } -}) - -async function startTui(options: { rejectTheme?: Error; rejectPlugins?: Error; rejectPluginDispose?: Error } = {}) { - const tmp = await tmpdir() - const isolated = 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 pluginStarted = deferred() - const pluginHost = { - starts: 0, - disposes: 0, - started: pluginStarted.promise, - async start() { - pluginHost.starts++ - pluginStarted.resolve() - if (options.rejectPlugins) throw options.rejectPlugins - }, - async dispose() { - pluginHost.disposes++ - if (options.rejectPluginDispose) throw options.rejectPluginDispose - }, - } - const environment = createTuiEnvironment({ - cwd: tmp.path, - platform: "linux", - paths: { home: tmp.path, state: isolated.state, worktree: path.join(tmp.path, "worktree") }, - capabilities: { - mouse: true, - copyOnSelect: true, - terminalTitle: false, - terminalSuspend: false, - workspaces: false, - showTimeToFirstDraw: false, - }, - terminal: {}, - editor: { zedTerminal: false }, - skipInitialLoading: false, - }) - const handle = tui({ - environment, - build: createTuiBuildInfo({ version: "test", channel: "test" }), - url: "http://test", - renderer: setup.renderer, - host: createLegacyTuiHost(setup.renderer), - config: createTuiResolvedConfig({ plugin_enabled: disabledInternalPlugins }), - directory, - fetch: calls.fetch, - events: events.source, - pluginHost, - args: {}, - }) - active = { - handle, - setup, - tmp, - restore: () => { - waitForThemeMode.mockRestore() - isolated.restore() - }, - } - - return { handle, setup, theme, pluginHost } -} - -async function isolateGlobalPaths(root: string) { - const previous = Global.Path.config - Global.Path.config = path.join(root, "config") - const state = path.join(root, "state") - await mkdir(Global.Path.config, { recursive: true }) - await mkdir(state, { recursive: true }) - await Bun.write(path.join(state, "kv.json"), JSON.stringify({ animations_enabled: false })) - - return { - state, - restore() { - Global.Path.config = previous - }, - } -} - -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/attach.test.ts b/packages/opencode/test/cli/tui/attach.test.ts index ade459256957..3535063d0e5b 100644 --- a/packages/opencode/test/cli/tui/attach.test.ts +++ b/packages/opencode/test/cli/tui/attach.test.ts @@ -1,11 +1,10 @@ import { describe, expect, test } from "bun:test" describe("tui attach", () => { - test("loads the public TUI API and legacy hosts lazily", async () => { + test("loads the TUI integration lazily", async () => { const source = await Bun.file(new URL("../../../src/cli/cmd/attach.ts", import.meta.url)).text() - expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/) - expect(source).toContain('await import("../tui/host")') + expect(source).toContain('await import("../tui/layer")') expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/) expect(source).not.toContain('import("./app")') }) diff --git a/packages/opencode/test/cli/tui/editor-context-zed.test.ts b/packages/opencode/test/cli/tui/editor-context-zed.test.ts index 860e13f9d332..bd0ddba57c97 100644 --- a/packages/opencode/test/cli/tui/editor-context-zed.test.ts +++ b/packages/opencode/test/cli/tui/editor-context-zed.test.ts @@ -3,7 +3,12 @@ import { mkdir, symlink } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, expect, spyOn, test } from "bun:test" -import { isZedTerminal, offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/tui/editor-zed" +import { + isZedTerminal, + offsetToPosition, + resolveZedDbPath, + resolveZedSelection, +} from "@opencode-ai/tui/editor-zed" import { tmpdir } from "../../fixture/fixture" const originalZedTerm = process.env.ZED_TERM diff --git a/packages/opencode/test/cli/tui/editor-context.test.tsx b/packages/opencode/test/cli/tui/editor-context.test.tsx index f4357616b920..52cceb2986b8 100644 --- a/packages/opencode/test/cli/tui/editor-context.test.tsx +++ b/packages/opencode/test/cli/tui/editor-context.test.tsx @@ -3,12 +3,11 @@ import os from "node:os" import path from "node:path" import { afterEach, expect, spyOn, test } from "bun:test" import { createRoot } from "solid-js" -import { EditorContextProvider, useEditorContext } from "@opencode-ai/tui/context/editor" +import { EditorContextProvider, useEditorContext, type EditorIntegration } from "@opencode-ai/tui/context/editor" import { tmpdir } from "../../fixture/fixture" import { FakeWebSocket } from "../../lib/websocket" -import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" -import { TuiPlatformProvider, type TuiPlatform } from "@opencode-ai/tui/platform" -import { discoverEditorConnection } from "../../../src/cli/tui/platform" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { discoverEditorConnection } from "@opencode-ai/tui/editor" const originalClaudePort = process.env.CLAUDE_CODE_SSE_PORT const originalOpencodePort = process.env.OPENCODE_EDITOR_SSE_PORT @@ -36,17 +35,14 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) { const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT return ( - - - + - - + ) }) @@ -56,16 +52,8 @@ function mountEditorContext(WebSocketImpl?: typeof WebSocket) { } } -const platform: TuiPlatform = { - files: { - readText: (file) => Bun.file(file).text(), - readBytes: (file) => Bun.file(file).bytes(), - mime: () => Promise.resolve("application/octet-stream"), - }, - editor: { - open: () => Promise.resolve(undefined), - connection: discoverEditorConnection, - }, +const editorService: EditorIntegration = { + connection: discoverEditorConnection, } function createWebSocketImpl(...sockets: FakeWebSocket[]) { diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 24b0c169ab0c..f79fd40da740 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -5,11 +5,10 @@ import { tmpdir } from "../../fixture/fixture" import { resolveThreadDirectory } from "../../../src/cli/cmd/tui" describe("tui thread", () => { - test("loads the public TUI API and legacy hosts lazily", async () => { + test("loads the TUI integration lazily", async () => { const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text() - expect(source).toMatch(/await import\(["']@opencode-ai\/tui["']\)/) - expect(source).toContain('await import("../tui/host")') + expect(source).toContain('await import("../tui/layer")') expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/) expect(source).not.toContain('import("./app")') }) diff --git a/packages/opencode/test/fixture/tui-environment.tsx b/packages/opencode/test/fixture/tui-environment.tsx index d2b60569e4c6..73c3758f0838 100644 --- a/packages/opencode/test/fixture/tui-environment.tsx +++ b/packages/opencode/test/fixture/tui-environment.tsx @@ -1,42 +1,32 @@ /** @jsxImportSource @opentui/solid */ -import { createTuiEnvironment, TuiEnvironmentProvider, type TuiEnvironment } from "@opencode-ai/tui/runtime" +import { + TuiPathsProvider, + TuiStartupProvider, + TuiTerminalEnvironmentProvider, + type TuiPaths, +} from "@opencode-ai/tui/context/runtime" import type { ParentProps } from "solid-js" -export function TestTuiEnvironmentProvider( +export function TestTuiContexts( props: ParentProps<{ cwd?: string directory?: string - paths?: Partial - capabilities?: Partial - editor?: Partial + paths?: Partial }>, ) { return ( - - {props.children} - + + {props.children} + + ) } diff --git a/packages/tui/package.json b/packages/tui/package.json index 53abecff572c..2f270c62fca4 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -14,19 +14,23 @@ "./builtins": "./src/feature-plugins/builtins.ts", "./config": "./src/config/index.tsx", "./context/args": "./src/context/args.tsx", - "./context/exit": "./src/context/exit.tsx", + "./context/epilogue": "./src/context/epilogue.tsx", "./context/kv": "./src/context/kv.tsx", "./context/project": "./src/context/project.tsx", + "./context/runtime": "./src/context/runtime.tsx", "./context/sdk": "./src/context/sdk.tsx", "./context/sync": "./src/context/sync.tsx", "./context/theme": "./src/context/theme.tsx", "./context/editor": "./src/context/editor.ts", + "./context/clipboard": "./src/context/clipboard.tsx", + "./attention": "./src/attention.ts", + "./editor": "./src/editor.ts", + "./editor-zed": "./src/editor-zed.ts", "./context/aggregate-failures": "./src/context/aggregate-failures.ts", "./runtime": "./src/runtime.tsx", "./config/keybind": "./src/config/keybind.ts", "./keymap": "./src/keymap.tsx", "./prompt/display": "./src/prompt/display.ts", - "./platform": "./src/platform.tsx", "./plugin/runtime": "./src/plugin/runtime.tsx", "./plugin/slots": "./src/plugin/slots.tsx", "./plugin/command-shim": "./src/plugin/command-shim.ts", @@ -42,12 +46,15 @@ "./component/spinner": "./src/component/spinner.tsx" }, "dependencies": { + "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@solid-primitives/scheduled": "1.5.2", + "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", "fuzzysort": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 30d39999628a..5a1bb9decc88 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1,7 +1,13 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { Deferred, Effect } from "effect" +import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ClipboardProvider, useClipboard } from "./context/clipboard" +import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" -import { createCliRenderer, MouseButton, type CliRenderer, type CliRendererConfig } from "@opentui/core" +import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core" import { RouteProvider, useRoute } from "./context/route" import { Switch, @@ -15,17 +21,8 @@ import { batch, Show, on, - type ParentProps, } from "solid-js" -import { - TuiBuildInfoProvider, - TuiEnvironmentProvider, - useTuiBuildInfo, - useTuiEnvironment, - type TuiBuildInfo, - type TuiEnvironment, -} from "./runtime" -import { TuiPlatformProvider, useTuiPlatform, type TuiPlatform } from "./platform" +import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider as DialogProviderList } from "./component/dialog-provider" import { ErrorComponent } from "./component/error-component" @@ -57,7 +54,6 @@ 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 { createExit, ExitProvider, useExit, type Exit } from "./context/exit" import { isDefaultTitle } from "./util/session" import { KVProvider, useKV } from "./context/kv" import * as Model from "./util/model" @@ -67,14 +63,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config" import { createTuiApiAdapters } from "./plugin/adapters" import { createTuiApi } from "./plugin/api" -import { - createPluginRuntime, - PluginRuntimeProvider, - usePluginRuntime, - type PluginRuntime, - type TuiPluginHost, -} from "./plugin/runtime" -import type { TuiAttention } from "@opencode-ai/plugin/tui" +import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" import { CommandPaletteDialog } from "./component/command-palette" import { COMMAND_PALETTE_COMMAND, @@ -87,6 +76,10 @@ import { import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" +import { createTuiAttention } from "./attention" +import * as TuiAudio from "./audio" +import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./terminal-win32" +import { destroyRenderer } from "./util/renderer" const appGlobalBindingCommands = [ "session.list", @@ -136,81 +129,19 @@ const appBindingCommands = [ "app.toggle.session_directory_filter", ] as const -export type TuiRuntimeInput = { - environment: TuiEnvironment - build: TuiBuildInfo -} - -export type TuiHost = Readonly<{ - platform: TuiPlatform - attention(input: { - renderer: CliRenderer - config: TuiConfig.Resolved - kv: ReturnType - }): TuiAttention & { dispose(): void } - logger: { error(message: string, extra?: Record): void } - lifecycle: Readonly<{ - prepare?(): (() => void) | undefined - flushInput?(): void - onSighup?(handler: () => void): () => void - writeStdout?(text: string): void - writeStderr?(text: string): void - suspend?(resume: () => void): void - }> - disposeAudio?(): void - formatError(error: unknown): string | undefined - formatUnknownError(error: unknown): string -}> - -export function tuiRendererConfig(_config: TuiConfig.Resolved, runtime: TuiRuntimeInput): CliRendererConfig { - return { - externalOutputMode: "passthrough", - targetFps: 60, - gatherStats: false, - exitOnCtrlC: false, - useKittyKeyboard: {}, - autoFocus: false, - openConsoleOnError: false, - useMouse: runtime.environment.capabilities.mouse, - consoleOptions: { - keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], - }, - } -} - -export function createTuiRenderer(config: TuiConfig.Resolved, runtime: TuiRuntimeInput) { - return createCliRenderer(tuiRendererConfig(config, runtime)) -} - -export type TuiHandle = { - ready: Promise - done: Promise - exit: Exit -} - -export type TuiInput = TuiRuntimeInput & { +export type TuiInput = { url: string args: Args config: TuiConfig.Resolved - renderer: CliRenderer onSnapshot?: () => Promise directory?: string fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource pluginHost: TuiPluginHost - host: TuiHost -} - -type TuiLifecycle = { - exit: Exit - exited: Promise - fail(error: unknown): Promise } -function errorMessage(error: unknown, host: TuiHost) { - const formatted = host.formatError(error) - if (formatted !== undefined) return formatted +function errorMessage(error: unknown) { if ( typeof error === "object" && error !== null && @@ -222,7 +153,7 @@ function errorMessage(error: unknown, host: TuiHost) { ) { return error.data.message } - return host.formatUnknownError(error) + return error instanceof Error ? error.message : String(error) } function isVersionGreater(left: string, right: string) { @@ -242,223 +173,179 @@ function isVersionGreater(left: string, right: string) { return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0 } -export function tui(input: TuiInput): TuiHandle { - const unguard = input.host.lifecycle.prepare?.() - - const renderer = input.renderer - const keymap = createDefaultOpenTuiKeymap(renderer) - const unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.config) - const pluginRuntime = createPluginRuntime() - const lifecycle = createTuiLifecycle({ - renderer, - unguard, - host: input.host, - cleanup: async () => { - unregisterKeymap() - try { - await input.pluginHost.dispose() - } catch (error) { - console.error("Failed to dispose TUI plugins", error) - } finally { - input.host.disposeAudio?.() - } - }, - }) - const ready = mount({ ...input, keymap, pluginRuntime, exit: lifecycle.exit }).catch((error) => lifecycle.fail(error)) - const done = waitUntilDone(ready, lifecycle.exited) - - return { ready, done, exit: lifecycle.exit } -} - -export async function mount( - input: TuiInput & { keymap: ReturnType; pluginRuntime: PluginRuntime; 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 LegacySyncProvider(props: ParentProps & { logger: TuiHost["logger"] }) { - const kv = useKV() - return ( - - {props.children} - - ) -} - -function LocalBridge(props: ParentProps) { - const theme = useTheme() - const toast = useToast() - const route = useRoute() - return ( - - {props.children} - +export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + const global = yield* Global.Service + const epilogue = { value: undefined as string | undefined } + const output = yield* Effect.scoped( + Effect.gen(function* () { + const renderer = yield* Effect.acquireRelease( + Effect.tryPromise(() => + createCliRenderer({ + externalOutputMode: "passthrough", + targetFps: 60, + gatherStats: false, + exitOnCtrlC: false, + useKittyKeyboard: {}, + autoFocus: false, + openConsoleOnError: false, + useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse, + consoleOptions: { + keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], + }, + }), + ), + (renderer) => + Effect.sync(() => destroyRenderer(renderer)), + ) + yield* Effect.acquireRelease( + Effect.sync(() => { + const unguard = win32InstallCtrlCGuard() + win32DisableProcessedInput() + return unguard + }), + (unguard) => + Effect.sync(() => { + try { + unguard?.() + } catch (error) { + console.error("Failed to restore terminal guard", error) + } + }), + ) + const keymap = createDefaultOpenTuiKeymap(renderer) + yield* Effect.acquireRelease( + Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)), + (unregister) => Effect.sync(unregister), + ) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + try { + await input.pluginHost.dispose() + } catch (error) { + console.error("Failed to dispose TUI plugins", error) + } + }), + ) + yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose)) + const shutdown = yield* Deferred.make() + const onSighup = () => destroyRenderer(renderer) + yield* Effect.acquireRelease( + Effect.sync(() => process.on("SIGHUP", onSighup)), + () => Effect.sync(() => process.off("SIGHUP", onSighup)), + ) + renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void)) + const pluginRuntime = createPluginRuntime() + + yield* Effect.tryPromise(async () => { + // 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 ( + }> + + + + + (epilogue.value = value)}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, renderer) + }) + yield* Deferred.await(shutdown) + return epilogue.value + }), ) -} - -function createTuiLifecycle(input: { - renderer: CliRenderer - unguard?: () => void - cleanup: () => Promise - host: TuiHost -}): TuiLifecycle { - let resolveExited!: () => void - const exited = new Promise((resolve) => { - resolveExited = resolve + yield* Effect.sync(() => { + win32FlushInputBuffer() + if (output) process.stdout.write(output + "\n") }) - let exitCompleted = false - let exiting = false - let cleanupTask: Promise | undefined - - const completeExit = () => { - if (exitCompleted) return - exitCompleted = true - resolveExited() - } - - const cleanup = () => { - cleanupTask ??= (async () => { - offSighup?.() - 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() - } - input.host.lifecycle.flushInput?.() - if (reason) { - const formatted = input.host.formatError(reason) ?? input.host.formatUnknownError(reason) - if (formatted) input.host.lifecycle.writeStderr?.(formatted + "\n") - } - const text = message() - if (text) input.host.lifecycle.writeStdout?.(text + "\n") - completeExit() - }) - const onSighup = () => { - void exit() - } - - input.renderer.once("destroy", () => { - if (exiting) return - void cleanup().finally(() => { - input.host.lifecycle.flushInput?.() - completeExit() - }) - }) - const offSighup = input.host.lifecycle.onSighup?.(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; pluginHost: TuiPluginHost; host: TuiHost }) { - const environment = useTuiEnvironment() - const build = useTuiBuildInfo() +function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPluginHost }) { + const startup = useTuiStartup() const tuiConfig = useTuiConfig() const route = useRoute() const dimensions = useTerminalDimensions() @@ -474,15 +361,14 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const { theme, mode, setMode, locked, lock, unlock } = themeState const sync = useSync() const project = useProject() - const exit = useExit() const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() - const attention = props.host.attention({ renderer, config: tuiConfig, kv }) - const platform = useTuiPlatform() + const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) + const clipboard = useClipboard() const api = createTuiApi( createTuiApiAdapters({ - version: build.version, + version: InstallationVersion, tuiConfig, dialog, keymap, @@ -518,8 +404,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const offSelectionKeys = keymap.intercept( "key", ({ event }) => { - if (environment.capabilities.copyOnSelect) return - Selection.handleSelectionKey(renderer, toast, event, platform.clipboard) + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + Selection.handleSelectionKey(renderer, toast, event, clipboard) }, { priority: 1 }, ) @@ -532,8 +418,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return - await platform.clipboard - ?.write?.(text) + await clipboard + .write?.(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) @@ -546,7 +432,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // Update terminal window title based on current route and session createEffect(() => { - if (!terminalTitleEnabled() || !environment.capabilities.terminalTitle) return + if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return if (route.data.type === "home") { renderer.setTerminalTitle("OpenCode") @@ -695,8 +581,8 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi run: async () => { const workspace = currentWorktreeWorkspace() if (!workspace?.directory) return - await platform.clipboard - ?.write?.(workspace.directory) + await clipboard + .write?.(workspace.directory) .then(() => toast.show({ message: "Copied worktree path", variant: "info" })) .catch(toast.error) dialog.clear() @@ -706,7 +592,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi name: "workspace.list", title: "Manage workspaces", category: "Workspace", - hidden: !environment.capabilities.workspaces, + hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, slashName: "workspaces", run: () => { dialog.replace(() => ) @@ -915,7 +801,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi title: "Exit the app", slashName: "exit", slashAliases: ["quit", "q"], - run: () => exit(), + run: () => destroyRenderer(renderer), category: "System", }, { @@ -955,10 +841,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi title: "Suspend terminal", category: "System", hidden: true, - enabled: environment.capabilities.terminalSuspend, + enabled: process.platform !== "win32", run: () => { renderer.suspend() - props.host.lifecycle.suspend?.(() => renderer.resume()) + process.once("SIGCONT", () => renderer.resume()) + process.kill(0, "SIGTSTP") }, }, { @@ -1094,7 +981,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi if (workspace !== project.workspace.current()) return const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return - const message = errorMessage(error, props.host) + const message = errorMessage(error) toast.show({ variant: "error", @@ -1148,7 +1035,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, ) - void exit() + destroyRenderer(renderer) }) const plugin = createMemo(() => { @@ -1166,18 +1053,20 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi flexDirection="column" backgroundColor={theme.background} onMouseDown={(evt) => { - if (environment.capabilities.copyOnSelect) return + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return - if (!Selection.copy(renderer, toast, platform.clipboard)) return + if (!Selection.copy(renderer, toast, clipboard)) return evt.preventDefault() evt.stopPropagation() }} onMouseUp={ - environment.capabilities.copyOnSelect ? () => Selection.copy(renderer, toast, platform.clipboard) : undefined + !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT + ? () => Selection.copy(renderer, toast, clipboard) + : undefined } > - + @@ -1199,7 +1088,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi - + diff --git a/packages/opencode/src/cli/tui/attention.ts b/packages/tui/src/attention.ts similarity index 92% rename from packages/opencode/src/cli/tui/attention.ts rename to packages/tui/src/attention.ts index cb65dcc45216..b309edcd9dd9 100644 --- a/packages/opencode/src/cli/tui/attention.ts +++ b/packages/tui/src/attention.ts @@ -1,3 +1,4 @@ +/// import type { TuiAttention, TuiAttentionNotifyInput, @@ -9,7 +10,7 @@ import type { TuiAttentionSoundPack, TuiAttentionSoundPackInfo, } from "@opencode-ai/plugin/tui" -import { AttentionSoundName, type TuiConfig } from "@opencode-ai/tui/config" +import { AttentionSoundName, type TuiConfig } from "./config" import { Schema } from "effect" import stripAnsi from "strip-ansi" import * as TuiAudio from "./audio" @@ -19,7 +20,6 @@ import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" } import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" } import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" } -import * as Log from "@opencode-ai/core/util/log" type FocusState = "unknown" | "focused" | "blurred" @@ -38,8 +38,6 @@ type TuiAttentionHost = TuiAttention & { dispose(): void } -const log = Log.create({ service: "tui.attention" }) - const DEFAULT_TITLE = "opencode" const DEFAULT_PACK_ID = "opencode.default" const KV_SOUND_PACK = "attention_sound_pack" @@ -154,7 +152,7 @@ export function createTuiAttention(input: { try { for (const file of soundCandidates(name)) { const current = await audio.loadSoundFile(file).catch((error) => { - log.debug("failed to load attention sound", { file, error }) + console.debug("failed to load attention sound", { file, error }) return null }) if (disposed) return false @@ -163,7 +161,7 @@ export function createTuiAttention(input: { } return false } catch (error) { - log.debug("failed to play attention sound", { error }) + console.debug("failed to play attention sound", { error }) return false } } @@ -189,7 +187,7 @@ export function createTuiAttention(input: { normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT), ) } catch (error) { - log.debug("failed to trigger attention notification", { error }) + console.debug("failed to trigger attention notification", { error }) return false } })() @@ -198,13 +196,7 @@ export function createTuiAttention(input: { const requestedSound = typeof request.sound === "object" ? request.sound : undefined const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus) const soundName = -<<<<<<< HEAD requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default" -======= - requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) - ? requestedSound.name - : "default" ->>>>>>> e5ff6e467 (refactor(tui): organize config modules) const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume) if (!notification && !sound) { @@ -218,7 +210,7 @@ export function createTuiAttention(input: { sound, } } catch (error) { - log.debug("failed to handle attention notification", { error }) + console.debug("failed to handle attention notification", { error }) return { ok: false, notification: false, diff --git a/packages/tui/src/audio.d.ts b/packages/tui/src/audio.d.ts new file mode 100644 index 000000000000..092703e747a7 --- /dev/null +++ b/packages/tui/src/audio.d.ts @@ -0,0 +1,9 @@ +declare module "*.mp3" { + const path: string + export default path +} + +declare module "@opencode-ai/ui/audio/*.mp3" { + const path: string + export default path +} diff --git a/packages/opencode/src/cli/tui/audio.ts b/packages/tui/src/audio.ts similarity index 77% rename from packages/opencode/src/cli/tui/audio.ts rename to packages/tui/src/audio.ts index 7d7c3d5a4227..2f8a0b665253 100644 --- a/packages/opencode/src/cli/tui/audio.ts +++ b/packages/tui/src/audio.ts @@ -1,7 +1,5 @@ import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core" -import * as Log from "@opencode-ai/core/util/log" - -const log = Log.create({ service: "tui.audio" }) +import { readFile } from "node:fs/promises" let audio: Audio | null | undefined const sounds = new Map>() @@ -11,12 +9,12 @@ function getAudio() { try { const next = Audio.create({ autoStart: false }) next.on("error", (error: Error, context: AudioErrorContext) => { - log.debug("tui audio error", { error, context }) + console.debug("tui audio error", { error, context }) }) audio = next return next } catch (error) { - log.debug("failed to create tui audio", { error }) + console.debug("failed to create tui audio", { error }) audio = null return null } @@ -27,11 +25,10 @@ export function loadSoundFile(file: string) { if (!current) return Promise.resolve(null) const cached = sounds.get(file) if (cached) return cached - const task = Bun.file(file) - .bytes() + const task = readFile(file) .then((bytes) => current.loadSound(bytes)) .catch((error) => { - log.debug("failed to load tui sound", { file, error }) + console.debug("failed to load tui sound", { file, error }) return null }) sounds.set(file, task) @@ -54,5 +51,3 @@ export function dispose() { audio = undefined sounds.clear() } - -export * as TuiAudio from "./audio" diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts new file mode 100644 index 000000000000..3bd84df648bd --- /dev/null +++ b/packages/tui/src/clipboard.ts @@ -0,0 +1,120 @@ +import { execFile, spawn } from "node:child_process" +import { readFile, rm } from "node:fs/promises" +import { platform, release, tmpdir } from "node:os" +import path from "node:path" +import { promisify } from "node:util" + +const exec = promisify(execFile) + +function command(command: string, args: string[] = [], input?: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] }) + const output: Buffer[] = [] + child.on("error", reject) + child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)) + child.on("close", (code) => { + if (code === 0) return resolve(Buffer.concat(output)) + reject(new Error(`${command} exited with code ${code}`)) + }) + if (input !== undefined) child.stdin?.end(input) + }) +} + +function writeOsc52(text: string) { + if (!process.stdout.isTTY) return + const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07` + process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence) +} + +export async function read() { + if (platform() === "darwin") { + const file = path.join(tmpdir(), "opencode-clipboard.png") + try { + await exec("osascript", [ + "-e", + 'set imageData to the clipboard as "PNGf"', + "-e", + `set fileRef to open for access POSIX file "${file}" with write permission`, + "-e", + "set eof fileRef to 0", + "-e", + "write imageData to fileRef", + "-e", + "close access fileRef", + ]) + return { data: (await readFile(file)).toString("base64"), mime: "image/png" } + } catch { + // Fall through to text clipboard. + } finally { + await rm(file, { force: true }).catch(() => {}) + } + } + + if (platform() === "win32" || release().includes("WSL")) { + const script = + "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" + const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() => + Buffer.alloc(0), + ) + if (image.length) return { data: image.toString().trim(), mime: "image/png" } + } + + if (platform() === "linux") { + const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0)) + if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" } + const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() => + Buffer.alloc(0), + ) + if (x11.length) return { data: x11.toString("base64"), mime: "image/png" } + } + + const { default: clipboardy } = await import("clipboardy") + const text = await clipboardy.read().catch(() => undefined) + if (text) return { data: text, mime: "text/plain" } +} + +export function copyCommand(os: NodeJS.Platform, wayland: boolean, has: (name: string) => boolean): string[] | undefined { + if (os === "darwin" && has("osascript")) return ["osascript"] + if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"] + if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"] + if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"] + if (os === "win32" && has("powershell.exe")) { + return [ + "powershell.exe", + "-NonInteractive", + "-NoProfile", + "-Command", + "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())", + ] + } +} + +let copyMethod: Promise<(text: string) => Promise> | undefined + +function getCopyMethod() { + return (copyMethod ??= (async () => { + const { which } = await import("@opencode-ai/core/util/which") + const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name))) + if (native?.[0] === "osascript") { + return async (text: string) => { + const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined) + } + } + if (native) { + return async (text: string) => { + await command(native[0], native.slice(1), text).catch(() => undefined) + } + } + return async (text: string) => { + const { default: clipboardy } = await import("clipboardy") + await clipboardy.write(text).catch(() => undefined) + } + })()) +} + +export async function write(text: string) { + writeOsc52(text) + const method = await getCopyMethod() + await method(text) +} diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index f72ffdaf8d63..73f04959417e 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -7,7 +7,8 @@ import { useDialog } from "../ui/dialog" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" import { useSync } from "../context/sync" -import { abbreviateHome, useTuiEnvironment } from "../runtime" +import { abbreviateHome } from "../runtime" +import { useTuiPaths } from "../context/runtime" import { Locale } from "../util/locale" import { errorMessage } from "../util/error" import { useToast } from "../ui/toast" @@ -32,7 +33,7 @@ export function DialogMoveSession(props: { const sync = useSync() const projectContext = useProject() const toast = useToast() - const environment = useTuiEnvironment() + const paths = useTuiPaths() const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) @@ -101,7 +102,7 @@ export function DialogMoveSession(props: { }) const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12) return list.map((item) => { - const title = abbreviateHome(item.location, environment.paths.home) + const title = abbreviateHome(item.location, paths.home) const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location) const visible = Locale.truncateLeft(title, titleWidth) const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index ee81fbf76c47..d2a7739adf25 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -14,7 +14,7 @@ import { useToast } from "../ui/toast" import { isConsoleManagedProvider } from "../util/provider-origin" import { useConnected } from "./use-connected" import { useBindings } from "../keymap" -import { useTuiPlatform } from "../platform" +import { useClipboard } from "../context/clipboard" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -238,7 +238,7 @@ function AutoMethod(props: AutoMethodProps) { const dialog = useDialog() const sync = useSync() const toast = useToast() - const platform = useTuiPlatform() + const clipboard = useClipboard() useBindings(() => ({ bindings: [ @@ -249,8 +249,8 @@ function AutoMethod(props: AutoMethodProps) { cmd: () => { const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url - platform.clipboard - ?.write?.(code) + clipboard + .write?.(code) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) }, diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 5c02d12f4729..9666d9903ce2 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -8,7 +8,7 @@ import { useProject } from "../context/project" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { useLocal } from "../context/local" -import { useTuiEnvironment } from "../runtime" +import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" @@ -27,7 +27,6 @@ export function DialogSessionList() { const { theme } = useTheme() const sdk = useSDK() const local = useLocal() - const environment = useTuiEnvironment() const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) @@ -174,7 +173,7 @@ export function DialogSessionList() { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined let footer: JSX.Element | string = "" - if (environment.capabilities.workspaces) { + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (x.workspaceID) { footer = workspace ? ( void - exit: () => Promise - version: string mode?: "dark" | "light" }) { const term = useTerminalDimensions() - const platform = useTuiPlatform() + const renderer = useRenderer() + const clipboard = useClipboard() useKeyboard((evt) => { if (evt.ctrl && evt.name === "c") { - void props.exit() + destroyRenderer(renderer) } }) const [copied, setCopied] = createSignal(false) @@ -43,10 +44,10 @@ export function ErrorComponent(props: { ) } - issueURL.searchParams.set("opencode-version", props.version) + issueURL.searchParams.set("opencode-version", InstallationVersion) const copyIssueURL = () => { - void platform.clipboard?.write?.(issueURL.toString()).then(() => { + void clipboard.write?.(issueURL.toString()).then(() => { setCopied(true) }) } @@ -69,7 +70,7 @@ export function ErrorComponent(props: { Reset TUI - void props.exit()} backgroundColor={colors.primary} padding={1}> + destroyRenderer(renderer)} backgroundColor={colors.primary} padding={1}> Exit diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 95d2ec759657..4221f6f0a913 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -10,7 +10,7 @@ import { useProject } from "../../context/project" import { useSDK } from "../../context/sdk" import { useSync } from "../../context/sync" import { getScrollAcceleration } from "../../util/scroll" -import { useTuiEnvironment } from "../../runtime" +import { useTuiPaths } from "../../context/runtime" import { useTuiConfig } from "../../config" import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" @@ -92,7 +92,7 @@ export function Autocomplete(props: { const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() - const environment = useTuiEnvironment() + const paths = useTuiPaths() const [store, setStore] = createStore({ index: 0, selected: 0, @@ -236,7 +236,7 @@ export function Autocomplete(props: { } function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { - const baseDir = (sync.path.directory || environment.cwd).replace(/\/+$/, "") + const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "") const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) const urlObj = pathToFileURL(fullPath) const filename = @@ -306,7 +306,7 @@ export function Autocomplete(props: { }) function normalizeMentionPath(filePath: string) { - const baseDir = sync.path.directory || environment.cwd + const baseDir = sync.path.directory || paths.cwd const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 6b3104fe37ad..eb926491df56 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -14,10 +14,11 @@ import "opentui-spinner/solid" import path from "path" import { fileURLToPath } from "url" import { useLocal } from "../../context/local" +import { Flag } from "@opencode-ai/core/flag/flag" import { tint, useTheme } from "../../context/theme" import { EmptyBorder, SplitBorder } from "../../ui/border" -import { useTuiEnvironment } from "../../runtime" -import { useTuiPlatform } from "../../platform" +import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" +import { useClipboard } from "../../context/clipboard" import { Spinner } from "../spinner" import { useSDK } from "../../context/sdk" import { useRoute } from "../../context/route" @@ -25,6 +26,8 @@ import { useProject } from "../../context/project" import { useSync } from "../../context/sync" import { useEvent } from "../../context/event" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" +import { openEditor } from "../../editor" +import { destroyRenderer } from "../../util/renderer" import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "../../prompt/history" @@ -34,7 +37,6 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import { useExit } from "../../context/exit" import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" @@ -143,8 +145,9 @@ export function Prompt(props: PromptProps) { const leader = useLeaderActive() const local = useLocal() const args = useArgs() - const environment = useTuiEnvironment() - const platform = useTuiPlatform() + const paths = useTuiPaths() + const terminalEnvironment = useTuiTerminalEnvironment() + const clipboard = useClipboard() const sdk = useSDK() const editor = useEditorContext() const route = useRoute() @@ -366,7 +369,7 @@ export function Prompt(props: PromptProps) { run: async (ctx: CommandContext) => { ctx.event.preventDefault() ctx.event.stopPropagation() - const content = await platform.clipboard?.read?.() + const content = await clipboard.read?.() if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", @@ -430,12 +433,13 @@ export function Prompt(props: PromptProps) { const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") const value = text - const content = await platform.editor?.open({ + const content = await openEditor({ + renderer, value, cwd: (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || project.instance.directory() || - environment.cwd, + paths.cwd, }) if (!content) return @@ -526,7 +530,7 @@ export function Prompt(props: PromptProps) { desc: "Change the workspace for the session", name: "workspace.set", category: "Session", - enabled: environment.capabilities.workspaces, + enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, slashName: "warp", run: () => { workspace.open() @@ -950,7 +954,7 @@ export function Prompt(props: PromptProps) { if (!agent) return false const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { - void exit() + destroyRenderer(renderer) return true } const selectedModel = local.model.current() @@ -1124,7 +1128,6 @@ export function Prompt(props: PromptProps) { if (finishMoveProgress) move.finishSubmit() return true } - const exit = useExit() function pasteText(text: string, virtualText: string) { const currentOffset = input.cursorOffset @@ -1163,10 +1166,10 @@ export function Prompt(props: PromptProps) { async function pasteInputText(text: string) { const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() - const filepath = pastedFilepath(pastedContent, environment.platform) + const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform) const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { - const attachment = await readLocalAttachment(platform.files, filepath) + const attachment = await readLocalAttachment(filepath) const filename = path.basename(filepath) if (attachment?.type === "text") { pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`) diff --git a/packages/tui/src/component/prompt/local-attachment.ts b/packages/tui/src/component/prompt/local-attachment.ts index 61905e52a961..fe0e25f21472 100644 --- a/packages/tui/src/component/prompt/local-attachment.ts +++ b/packages/tui/src/component/prompt/local-attachment.ts @@ -1,10 +1,39 @@ -import type { PlatformFiles } from "../../platform" +import { readFile } from "node:fs/promises" +import path from "node:path" + +export type LocalFiles = Readonly<{ + readText(path: string): Promise + readBytes(path: string): Promise + mime(path: string): Promise +}> export type LocalAttachment = | Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }> | Readonly<{ type: "binary"; mime: string; content: Uint8Array }> -export async function readLocalAttachment(files: PlatformFiles, path: string): Promise { +export function readLocalAttachment(file: string) { + return readLocalAttachmentWith( + { + readText: (value) => readFile(value, "utf8"), + readBytes: (value) => readFile(value), + mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream", + }, + file, + ) +} + +const mimeTypes: Record = { + ".avif": "image/avif", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".pdf": "application/pdf", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +} + +export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise { const mime = await files.mime(path).catch(() => undefined) if (!mime) return if (mime === "image/svg+xml") { diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index c1660881129b..56107fd8b13c 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import path from "path" -import { useTuiEnvironment } from "../../runtime" +import { useTuiPaths } from "../../context/runtime" import { errorMessage } from "../../util/error" import { useDialog } from "../../ui/dialog" import { useSDK } from "../../context/sdk" @@ -20,7 +20,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess const sync = useSync() const toast = useToast() const homeDestination = useHomeSessionDestination() - const environment = useTuiEnvironment() + const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() @@ -35,7 +35,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess { projectID, strategy: "git_worktree", - directory: path.join(environment.paths.worktree, projectID.slice(0, 6)), + directory: path.join(paths.worktree, projectID.slice(0, 6)), context, }, { throwOnError: true }, diff --git a/packages/tui/src/context/clipboard.tsx b/packages/tui/src/context/clipboard.tsx new file mode 100644 index 000000000000..6e0ac5370e1a --- /dev/null +++ b/packages/tui/src/context/clipboard.tsx @@ -0,0 +1,18 @@ +import { createContext, type JSX, useContext } from "solid-js" +import { read, write } from "../clipboard" + +export type ClipboardContent = Readonly<{ data: string; mime: string }> +export type ClipboardService = Readonly<{ + read?(): Promise + write?(text: string): Promise +}> +const clipboard = { read, write } +const ClipboardContext = createContext(clipboard) + +export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) { + return {props.children} +} + +export function useClipboard() { + return useContext(ClipboardContext) +} diff --git a/packages/tui/src/context/directory.ts b/packages/tui/src/context/directory.ts index a4da94413102..b107a40b8164 100644 --- a/packages/tui/src/context/directory.ts +++ b/packages/tui/src/context/directory.ts @@ -1,15 +1,16 @@ import { createMemo } from "solid-js" import { useProject } from "./project" import { useSync } from "./sync" -import { abbreviateHome, useTuiEnvironment } from "../runtime" +import { abbreviateHome } from "../runtime" +import { useTuiPaths } from "./runtime" export function useDirectory() { const project = useProject() const sync = useSync() - const environment = useTuiEnvironment() + const paths = useTuiPaths() return createMemo(() => { - const directory = project.instance.path().directory || environment.cwd - const result = abbreviateHome(directory, environment.paths.home) + const directory = project.instance.path().directory || paths.cwd + const result = abbreviateHome(directory, paths.home) if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result }) diff --git a/packages/tui/src/context/editor.ts b/packages/tui/src/context/editor.ts index 211b4aa5a596..b1c871850bce 100644 --- a/packages/tui/src/context/editor.ts +++ b/packages/tui/src/context/editor.ts @@ -2,9 +2,9 @@ import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { Option, Schema, SchemaGetter } from "effect" import { isRecord } from "../util/record" -import { useTuiEnvironment } from "../runtime" -import { useOptionalTuiPlatform } from "../platform" +import { useTuiPaths } from "./runtime" import { createSimpleContext } from "./helper" +import { editorIntegration } from "../editor" const MCP_PROTOCOL_VERSION = "2025-11-25" @@ -104,11 +104,20 @@ type EditorConnection = { source: string } +export type EditorIntegration = Readonly<{ + connection?(directory: string): EditorConnection | undefined + selection?(directory: string): Promise +}> + export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({ name: "EditorContext", - init: (props: { WebSocketImpl?: typeof WebSocket }) => { - const environment = useTuiEnvironment() - const platform = useOptionalTuiPlatform() + init: (props: { integration?: EditorIntegration; WebSocketImpl?: typeof WebSocket }) => { + const paths = useTuiPaths() + const editor = props.integration ?? editorIntegration + const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT + const parsedPort = value ? Number.parseInt(value, 10) : undefined + const port = parsedPort && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : undefined + const zedTerminal = process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed" const mentionListeners = new Set<(mention: EditorMention) => void>() const WebSocketImpl = props.WebSocketImpl ?? WebSocket const [store, setStore] = createStore<{ @@ -130,7 +139,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create let requestID = 0 let zedSelection: Promise | undefined let lastZedSelectionKey: string | undefined - let directory = environment.cwd + let directory = paths.cwd let preserveSelectionOnReconnect = false const pending = new Map() @@ -163,20 +172,20 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create const connect = () => { if (closed) return - const connection = resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection) + const connection = resolveEditorConnection(directory, port, editor.connection) if (!connection) { - if (!environment.editor.zedTerminal) { + if (!zedTerminal) { setStore("status", "disabled") scheduleReconnect() return } - - if (!platform?.editor?.selection) { + if (!editor.selection) { setStore("status", "disabled") scheduleReconnect() return } - zedSelection ??= platform.editor + + zedSelection ??= editor .selection(directory) .then((result) => { if (closed || socket) return @@ -278,7 +287,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create } const reconnectWithDirectory = (nextDirectory?: string) => { - const resolved = nextDirectory || environment.cwd + const resolved = nextDirectory || paths.cwd const sameDirectory = directory === resolved clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory }) if (sameDirectory) return @@ -311,8 +320,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create return { enabled() { return Boolean( - resolveEditorConnection(directory, environment.editor.port, platform?.editor?.connection) || - (environment.editor.zedTerminal && platform?.editor?.selection), + resolveEditorConnection(directory, port, editor.connection) || (zedTerminal && editor.selection), ) }, connected() { diff --git a/packages/tui/src/context/epilogue.tsx b/packages/tui/src/context/epilogue.tsx new file mode 100644 index 000000000000..9e6dce81c7e1 --- /dev/null +++ b/packages/tui/src/context/epilogue.tsx @@ -0,0 +1,6 @@ +import { createSimpleContext } from "./helper" + +export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({ + name: "Epilogue", + init: (props: { set(value?: string): void }) => props.set, +}) diff --git a/packages/tui/src/context/exit.tsx b/packages/tui/src/context/exit.tsx deleted file mode 100644 index 5b25a600396f..000000000000 --- a/packages/tui/src/context/exit.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { createSimpleContext } from "./helper" - -export type Exit = ((reason?: unknown) => Promise) & { - message: { - set: (value?: string) => () => void - clear: () => void - get: () => string | undefined - } -} - -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: { exit: Exit }) => input.exit, -}) diff --git a/packages/tui/src/context/kv.tsx b/packages/tui/src/context/kv.tsx index f499f465bc03..f64c9e2bb03c 100644 --- a/packages/tui/src/context/kv.tsx +++ b/packages/tui/src/context/kv.tsx @@ -1,18 +1,25 @@ import { createSignal, type Setter } from "solid-js" import { createStore, unwrap } from "solid-js/store" import { createSimpleContext } from "./helper" -import { useOptionalTuiPlatform } from "../platform" +import { Flock } from "@opencode-ai/core/util/flock" +import { Global } from "@opencode-ai/core/global" +import { readJson, writeJsonAtomic } from "../util/persistence" +import { useTuiPaths } from "./runtime" +import path from "path" export const { use: useKV, provider: KVProvider } = createSimpleContext({ name: "KV", init: () => { - const platform = useOptionalTuiPlatform() + const paths = useTuiPaths() + void Global.Path.state + const file = path.join(paths.state, "kv.json") + const lock = `tui-kv:${file}` const [ready, setReady] = createSignal(false) const [store, setStore] = createStore>() // Queue same-process writes so rapid updates persist in order. let write = Promise.resolve() - ;(platform?.state?.read() ?? Promise.resolve({})) + ;Flock.withLock(lock, () => readJson>(file)) .then((x) => { setStore(x) }) @@ -48,7 +55,9 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ setStore(key, value) const snapshot = structuredClone(unwrap(store)) write = write - .then(() => platform?.state?.write(snapshot)) + .then(() => + Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)), + ) .catch((error) => { console.error("Failed to write KV state", { error }) }) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 915214001f2d..d9d0c492f3b8 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -4,11 +4,14 @@ import { batch, createEffect, createMemo } from "solid-js" import { useSync } from "./sync" import { useEvent } from "./event" import path from "path" -import { useTuiEnvironment } from "../runtime" +import { useTuiPaths } from "./runtime" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { readJson, writeJsonAtomic } from "../util/persistence" +import { useTheme } from "./theme" +import { useToast } from "../ui/toast" +import { useRoute } from "./route" export type LocalTheme = { secondary: RGBA @@ -20,17 +23,6 @@ export type LocalTheme = { info: RGBA } -export type LocalDependencies = { - theme: LocalTheme - toast: { - show(options: { variant: "info" | "warning" | "error"; message: string; duration?: number }): void - } - route: { - readonly data: { type: string; sessionID?: string } - navigate(route: { type: "session"; sessionID: string }): void - } -} - export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { @@ -57,11 +49,13 @@ export function recentModels( export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", - init: (props: LocalDependencies) => { + init: () => { const sync = useSync() const sdk = useSDK() - const toast = props.toast - const environment = useTuiEnvironment() + const toast = useToast() + const theme = useTheme().theme + const route = useRoute() + const paths = useTuiPaths() function isModelValid(model: { providerID: string; modelID: string }) { const provider = sync.data.provider.find((x) => x.id === model.providerID) @@ -82,7 +76,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [agentStore, setAgentStore] = createStore({ current: undefined as string | undefined, }) - const theme = props.theme const colors = createMemo(() => [ theme.secondary, theme.accent, @@ -164,7 +157,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ variant: {}, }) - const filePath = path.join(environment.paths.state, "model.json") + const filePath = path.join(paths.state, "model.json") const state = { pending: false, } @@ -421,7 +414,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ pinned: [], }) - const filePath = path.join(environment.paths.state, "session.json") + const filePath = path.join(paths.state, "session.json") const state = { pending: false, } @@ -453,7 +446,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (state.pending) save() }) - const route = props.route const event = useEvent() const slots = createMemo(() => { diff --git a/packages/tui/src/context/path-format.tsx b/packages/tui/src/context/path-format.tsx index 624a0fd6bb61..8cf77aab9460 100644 --- a/packages/tui/src/context/path-format.tsx +++ b/packages/tui/src/context/path-format.tsx @@ -1,6 +1,7 @@ import path from "path" import { createContext, useContext, type ParentProps } from "solid-js" -import { abbreviateHome, useTuiEnvironment } from "../runtime" +import { abbreviateHome } from "../runtime" +import { useTuiPaths } from "./runtime" const context = createContext<{ path: () => string @@ -8,12 +9,12 @@ const context = createContext<{ }>() export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { - const environment = useTuiEnvironment() + const paths = useTuiPaths() return ( props.path || environment.cwd, - format: (input) => formatPath(input, props.path || environment.cwd, environment.paths.home), + path: () => props.path || paths.cwd, + format: (input) => formatPath(input, props.path || paths.cwd, paths.home), }} > {props.children} diff --git a/packages/tui/src/context/route.tsx b/packages/tui/src/context/route.tsx index 8af963ebd12b..7355fe54d7dc 100644 --- a/packages/tui/src/context/route.tsx +++ b/packages/tui/src/context/route.tsx @@ -1,7 +1,7 @@ import { createStore, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import type { PromptInfo } from "../prompt/history" -import { useTuiEnvironment } from "../runtime" +import { useTuiStartup } from "./runtime" export type HomeRoute = { type: "home" @@ -25,9 +25,9 @@ export type Route = HomeRoute | SessionRoute | PluginRoute export const { use: useRoute, provider: RouteProvider } = createSimpleContext({ name: "Route", init: (props: { initialRoute?: Route }) => { - const environment = useTuiEnvironment() + const startup = useTuiStartup() const [store, setStore] = createStore( - props.initialRoute ?? initialRoute(environment.initialRoute) ?? { type: "home" }, + props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" }, ) return { diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx new file mode 100644 index 000000000000..281049fe5786 --- /dev/null +++ b/packages/tui/src/context/runtime.tsx @@ -0,0 +1,62 @@ +import { createComponent, createContext, type JSX, useContext } from "solid-js" + +export type TuiPaths = Readonly<{ + cwd: string + home: string + state: string + worktree: string +}> + +export type TuiTerminalEnvironment = Readonly<{ + platform: string + multiplexer?: "tmux" | "screen" + displayServer?: "wayland" | "x11" +}> + +export type TuiStartup = Readonly<{ + initialRoute?: unknown + skipInitialLoading: boolean +}> + +const PathsContext = createContext() +const TerminalEnvironmentContext = createContext() +const StartupContext = createContext() + +function provider(context: ReturnType>, value: T, children: () => JSX.Element) { + return createComponent(context.Provider, { + value: Object.freeze({ ...value }), + get children() { + return children() + }, + }) +} + +export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element }) { + return provider(PathsContext, props.value, () => props.children) +} + +export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) { + return provider(TerminalEnvironmentContext, props.value, () => props.children) +} + +export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Element }) { + return provider(StartupContext, props.value, () => props.children) +} + +function required(context: ReturnType>, name: string) { + const value = useContext(context) + if (!value) throw new Error(`${name} is missing`) + return value +} + +export function useTuiPaths() { + return required(PathsContext, "TuiPathsProvider") +} + +export function useTuiTerminalEnvironment() { + return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider") +} + +export function useTuiStartup() { + return required(StartupContext, "TuiStartupProvider") +} diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index f0e855279bab..93180c6e21da 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,6 +1,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2" -import { useTuiEnvironment } from "../runtime" +import { Flag } from "@opencode-ai/core/flag/flag" import { createSimpleContext } from "./helper" import { batch, onCleanup, onMount } from "solid-js" @@ -17,7 +17,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ headers?: RequestInit["headers"] events?: EventSource }) => { - const environment = useTuiEnvironment() const abort = new AbortController() let sse: AbortController | undefined @@ -94,7 +93,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ sseMaxRetryAttempts: 0, }) - if (environment.capabilities.workspaces) { + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { // Start syncing workspaces, it's important to do this after // we've started listening to events await sdk.sync.start().catch(() => {}) @@ -122,7 +121,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ const unsub = await props.events.subscribe(handleEvent) onCleanup(unsub) - if (environment.capabilities.workspaces) { + if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { // Start syncing workspaces, it's important to do this after // we've started listening to events await sdk.sync.start().catch(() => {}) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 42d732675e57..9b05e2350115 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -24,13 +24,15 @@ import { createStore, produce, reconcile } from "solid-js/store" import { useProject } from "./project" import { useEvent } from "./event" import { useSDK } from "./sdk" -import { useTuiEnvironment } from "../runtime" +import { useTuiStartup } from "./runtime" import { createSimpleContext } from "./helper" -import { useExit } from "./exit" +import { useRenderer } from "@opentui/solid" import { useArgs } from "./args" import { batch, onMount } from "solid-js" import path from "path" import { aggregateFailures } from "./aggregate-failures" +import { useKV } from "./kv" +import { destroyRenderer } from "../util/renderer" const emptyConsoleState: ConsoleState = { consoleManagedProviders: [], @@ -50,19 +52,11 @@ function search(items: T[], target: string, key: (item: T) => string) { return { found: false, index: left } } -export type SyncDependencies = { - kv: { get(key: string, defaultValue: boolean): boolean } - logger: { error(message: string, extra?: Record): void } -} - -export const { - context: SyncContext, - use: useSync, - provider: SyncProvider, -} = createSimpleContext({ +export const { context: SyncContext, use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", - init: (dependencies: SyncDependencies) => { - const environment = useTuiEnvironment() + init: () => { + const startup = useTuiStartup() + const kv = useKV() const [store, setStore] = createStore<{ status: "loading" | "partial" | "complete" provider: Provider[] @@ -136,7 +130,6 @@ export const { const event = useEvent() const project = useProject() const sdk = useSDK() - const kv = dependencies.kv const fullSyncedSessions = new Set() const syncingSessions = new Map>() @@ -427,7 +420,7 @@ export const { } }) - const exit = useExit() + const renderer = useRenderer() const args = useArgs() async function bootstrap(input: { fatal?: boolean } = {}) { @@ -520,13 +513,13 @@ export const { }) }) .catch(async (e) => { - dependencies.logger.error("tui bootstrap failed", { + console.error("tui bootstrap failed", { error: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, }) if (fatal) { - await exit(e) + destroyRenderer(renderer) } else { throw e } @@ -544,7 +537,7 @@ export const { return store.status }, get ready() { - if (environment.skipInitialLoading) return true + if (startup.skipInitialLoading) return true return store.status !== "loading" }, get path() { diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 690e7b5edc13..65d4816a9621 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -24,7 +24,41 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" import { useKV } from "./kv" import { useTuiConfig } from "../config" -import { useOptionalTuiPlatform } from "../platform" +import { Global } from "@opencode-ai/core/global" +import { Glob } from "@opencode-ai/core/util/glob" +import { readFile } from "node:fs/promises" +import path from "node:path" + +export type ThemeSource = Readonly<{ + discover(): Promise> + subscribeRefresh?(refresh: () => void): () => void +}> + +const themeSource: ThemeSource = { + async discover() { + const directories = [Global.Path.config] + for (let current = process.cwd(); ; current = path.dirname(current)) { + directories.push(path.join(current, ".opencode")) + if (path.dirname(current) === current) break + } + return discoverThemes(directories) + }, + subscribeRefresh(refresh) { + process.on("SIGUSR2", refresh) + return () => process.off("SIGUSR2", refresh) + }, +} + +export async function discoverThemes(directories: string[]) { + const result: Record = {} + for (const directory of directories) { + const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true }) + for (const file of files) { + result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown + } + } + return result +} export { DEFAULT_THEMES, @@ -67,11 +101,11 @@ subscribeThemes((themes) => setStore("themes", themes)) export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", - init: (props: { mode: "dark" | "light" }) => { + init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => { const renderer = useRenderer() const config = useTuiConfig() const kv = useKV() - const platform = useOptionalTuiPlatform() + const themes = props.source ?? themeSource const pick = (value: unknown) => { if (value === "dark" || value === "light") return value return @@ -96,7 +130,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) function syncCustomThemes() { - return (platform?.themes?.discover() ?? Promise.resolve({})) + return themes + .discover() .then((themes) => { setCustomThemes( Object.entries(themes).reduce>((result, [name, theme]) => { @@ -207,7 +242,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }, delay), ) } - const unsubscribeRefresh = platform?.themes?.subscribeRefresh?.(refresh) + let unsubscribeRefresh: (() => void) | undefined + unsubscribeRefresh = themes.subscribeRefresh?.(refresh) onCleanup(() => { renderer.off(CliRenderEvents.THEME_MODE, handle) diff --git a/packages/opencode/src/cli/tui/editor-zed.ts b/packages/tui/src/editor-zed.ts similarity index 97% rename from packages/opencode/src/cli/tui/editor-zed.ts rename to packages/tui/src/editor-zed.ts index c91c1a994204..8ef53ab44389 100644 --- a/packages/opencode/src/cli/tui/editor-zed.ts +++ b/packages/tui/src/editor-zed.ts @@ -1,9 +1,10 @@ import { Database } from "bun:sqlite" import { statSync } from "node:fs" +import { readFile as readFileAsync } from "node:fs/promises" import os from "node:os" import path from "node:path" import { Option, Schema } from "effect" -import type { EditorSelection } from "@opencode-ai/tui/context/editor" +import type { EditorSelection } from "./context/editor" const ZedEditorRowSchema = Schema.Struct({ item_kind: Schema.String, @@ -63,9 +64,7 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): const text = contents.type === "contents" && contents.contents != null ? contents.contents - : await Bun.file(row.buffer_path) - .text() - .catch(() => undefined) + : await readFileAsync(row.buffer_path, "utf8").catch(() => undefined) if (text == null) return { type: "unavailable" } const ranges = byteRanges.map((range) => { diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts new file mode 100644 index 000000000000..3626ff16aef2 --- /dev/null +++ b/packages/tui/src/editor.ts @@ -0,0 +1,84 @@ +import type { CliRenderer } from "@opentui/core" +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs" +import { readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { spawn } from "node:child_process" +import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" + +export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string }) { + const editor = process.env.VISUAL || process.env.EDITOR + if (!editor) return + const file = path.join(os.tmpdir(), `${Date.now()}.md`) + await writeFile(file, input.value) + input.renderer.suspend() + input.renderer.currentRenderBuffer.clear() + try { + await new Promise((resolve, reject) => { + const parts = editor.split(" ") + const child = spawn(parts[0]!, [...parts.slice(1), file], { + cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(), + stdio: "inherit", + shell: process.platform === "win32", + }) + child.on("error", reject) + child.on("exit", (code, signal) => { + if (code === 0) return resolve() + reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`)) + }) + }) + return (await readFile(file, "utf8")) || undefined + } finally { + await rm(file, { force: true }).catch(() => {}) + input.renderer.currentRenderBuffer.clear() + input.renderer.resume() + input.renderer.requestRender() + } +} + +export function discoverEditorConnection(directory: string) { + const root = path.join(os.homedir(), ".claude", "ide") + const contains = (parent: string) => { + const resolved = path.resolve(parent) + const relative = path.relative(resolved, path.resolve(directory)) + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0 + } + try { + return readdirSync(root) + .filter((entry) => entry.endsWith(".lock")) + .flatMap((entry) => { + const file = path.join(root, entry) + const port = Number.parseInt(path.basename(file, ".lock"), 10) + if (!Number.isInteger(port) || port <= 0 || port > 65535) return [] + try { + const value = JSON.parse(readFileSync(file, "utf8")) as Record + if (value.transport !== undefined && value.transport !== "ws") return [] + const folders = Array.isArray(value.workspaceFolders) + ? value.workspaceFolders.filter((item): item is string => typeof item === "string") + : [] + const score = Math.max(0, ...folders.map(contains)) + if (!score) return [] + return [ + { + url: `ws://127.0.0.1:${port}`, + authToken: typeof value.authToken === "string" ? value.authToken : undefined, + source: `lock:${port}`, + score, + mtime: statSync(file).mtimeMs, + }, + ] + } catch { + return [] + } + }) + .sort((left, right) => right.score - left.score || right.mtime - left.mtime) + .map(({ url, authToken, source }) => ({ url, authToken, source }))[0] + } catch { + return undefined + } +} + +export const editorIntegration = { + connection: discoverEditorConnection, + selection: (directory: string) => resolveZedSelection(resolveZedDbPath() ?? "", directory), +} diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index b7357d33a1a9..a417ea195490 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,7 +1,8 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, Match, Show, Switch } from "solid-js" -import { abbreviateHome, useTuiEnvironment } from "../../runtime" +import { abbreviateHome } from "../../runtime" +import { useTuiPaths } from "../../context/runtime" import { useHomeSessionDestination } from "../../routes/home/session-destination" const id = "internal:home-footer" @@ -9,13 +10,13 @@ const id = "internal:home-footer" function Directory(props: { api: TuiPluginApi }) { const theme = () => props.api.theme.current const destination = useHomeSessionDestination() - const environment = useTuiEnvironment() + const paths = useTuiPaths() const dir = createMemo(() => { const selected = destination?.destination() if (!selected || selected.type === "new") return - const out = abbreviateHome(selected.directory, environment.paths.home) + const out = abbreviateHome(selected.directory, paths.home) const branch = - selected.directory === (props.api.state.path.directory || environment.cwd) + selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined if (branch) return out + ":" + branch diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 979d7ec3a0b9..16354a59ff6e 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -2,7 +2,6 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createMemo, For, type Accessor } from "solid-js" import { DEFAULT_THEMES, useTheme } from "../../context/theme" import { useCommandShortcut } from "../../keymap" -import { useTuiEnvironment } from "../../runtime" const themeCount = Object.keys(DEFAULT_THEMES).length @@ -97,7 +96,6 @@ function configShortcut(api: TuiPluginApi, command: string): TipShortcut { export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { const theme = useTheme().theme - const environment = useTuiEnvironment() const tipOffset = Math.random() const shortcuts: Shortcuts = { agentCycle: useCommandShortcut("agent.cycle"), @@ -136,12 +134,10 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { } const tip = createMemo(() => { if (props.connected === false) return NO_MODELS_TIP - const tips = [...TIPS, environment.capabilities.terminalSuspend ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap( - (item) => { - const value = typeof item === "string" ? item : item(shortcuts) - return value ? [value] : [] - }, - ) + const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => { + const value = typeof item === "string" ? item : item(shortcuts) + return value ? [value] : [] + }) return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP }, NO_MODELS_TIP) // Solid can expose a memo's initial value while a pure computation is pending. diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index b6738d97e0c9..c59046a01722 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -1,12 +1,13 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, Show } from "solid-js" -import { abbreviateHome, useTuiEnvironment } from "../../runtime" +import { abbreviateHome } from "../../runtime" +import { useTuiPaths } from "../../context/runtime" const id = "internal:sidebar-footer" function View(props: { api: TuiPluginApi; sessionID: string }) { - const environment = useTuiEnvironment() + const paths = useTuiPaths() const theme = () => props.api.theme.current const has = createMemo(() => props.api.state.provider.some( @@ -17,8 +18,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const show = createMemo(() => !has() && !done()) const path = createMemo(() => { const session = props.api.state.session.get(props.sessionID) - const dir = session?.directory || props.api.state.path.directory || environment.cwd - const out = abbreviateHome(dir, environment.paths.home) + const dir = session?.directory || props.api.state.path.directory || paths.cwd + const out = abbreviateHome(dir, paths.home) const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined const text = branch ? out + ":" + branch : out const list = text.split("/") diff --git a/packages/tui/src/feature-plugins/system/session-v2.tsx b/packages/tui/src/feature-plugins/system/session-v2.tsx index ac6be015f0d9..88bba833070c 100644 --- a/packages/tui/src/feature-plugins/system/session-v2.tsx +++ b/packages/tui/src/feature-plugins/system/session-v2.tsx @@ -10,7 +10,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { useBindings } from "../../keymap" import { Locale } from "../../util/locale" -import { useTuiEnvironment } from "../../runtime" +import { useTuiPaths } from "../../context/runtime" import { LANGUAGE_EXTENSIONS } from "../../util/filetype" import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display" import path from "path" @@ -1122,7 +1122,7 @@ function input(input: Record, omit?: string[]) { } function usePathNormalizer() { - const cwd = useTuiEnvironment().cwd + const cwd = useTuiPaths().cwd return (input?: string) => normalizePath(input, cwd) } diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index f9c9a2014928..722c2baf51b0 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1,22 +1 @@ -export { - createTuiBuildInfo, - createTuiEnvironment, - TuiBuildInfoProvider, - TuiEnvironmentProvider, - useTuiBuildInfo, - useTuiEnvironment, - type TuiBuildInfo, - type TuiEnvironment, -} from "./runtime" -export { - createTuiRenderer, - createTuiRenderer as createRenderer, - mount, - tui, - tui as run, - tuiRendererConfig, - type TuiHandle, - type TuiHost, - type TuiInput, - type TuiRuntimeInput, -} from "./app" +export { run, type TuiInput } from "./app" diff --git a/packages/tui/src/platform.tsx b/packages/tui/src/platform.tsx deleted file mode 100644 index e7b87d0c76af..000000000000 --- a/packages/tui/src/platform.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createContext, type JSX, useContext } from "solid-js" - -export type PlatformFiles = Readonly<{ - readText(path: string): Promise - readBytes(path: string): Promise - mime(path: string): Promise -}> - -export type PlatformClipboardContent = Readonly<{ - data: string - mime: string -}> - -export type TuiPlatform = Readonly<{ - files: PlatformFiles - state?: Readonly<{ - read(): Promise> - write(value: Record): Promise - }> - themes?: Readonly<{ - discover(): Promise> - subscribeRefresh?(refresh: () => void): () => void - }> - clipboard?: Readonly<{ - read?(): Promise - write?(text: string): Promise - }> - editor?: Readonly<{ - open(input: Readonly<{ value: string; cwd?: string }>): Promise - connection?(directory: string): Readonly<{ url: string; authToken?: string; source: string }> | undefined - selection?(directory: string): Promise - }> - export?: Readonly<{ - write(path: string, content: string): Promise - }> -}> - -const PlatformContext = createContext() - -export function TuiPlatformProvider(props: { value: TuiPlatform; children: JSX.Element }) { - return {props.children} -} - -export function useTuiPlatform() { - const value = useContext(PlatformContext) - if (!value) throw new Error("TuiPlatformProvider is missing") - return value -} - -export function useOptionalTuiPlatform() { - return useContext(PlatformContext) -} diff --git a/packages/tui/src/prompt/frecency.tsx b/packages/tui/src/prompt/frecency.tsx index bf67c0ce6df4..2e6375f13be3 100644 --- a/packages/tui/src/prompt/frecency.tsx +++ b/packages/tui/src/prompt/frecency.tsx @@ -2,7 +2,7 @@ import path from "path" import { onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { useTuiEnvironment } from "../runtime" +import { useTuiPaths } from "../context/runtime" import { appendText, readText, writeText } from "../util/persistence" type FrecencyEntry = { path: string; frequency: number; lastOpen: number } @@ -38,8 +38,8 @@ function calculateFrecency(entry?: { frequency: number; lastOpen: number }) { export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({ name: "Frecency", init: () => { - const environment = useTuiEnvironment() - const frecencyPath = path.join(environment.paths.state, "frecency.jsonl") + const paths = useTuiPaths() + const frecencyPath = path.join(paths.state, "frecency.jsonl") onMount(async () => { const lines = parseFrecency(await readText(frecencyPath).catch(() => "")) setStore( @@ -55,7 +55,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont const [store, setStore] = createStore({ data: {} as Record }) function updateFrecency(filePath: string) { - const absolutePath = path.resolve(environment.cwd, filePath) + const absolutePath = path.resolve(paths.cwd, filePath) const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() } setStore("data", absolutePath, newEntry) appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {}) @@ -72,7 +72,7 @@ export const { use: useFrecency, provider: FrecencyProvider } = createSimpleCont } return { - getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(environment.cwd, filePath)]), + getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(paths.cwd, filePath)]), updateFrecency, data: () => store.data, } diff --git a/packages/tui/src/prompt/history.tsx b/packages/tui/src/prompt/history.tsx index e5d64bfaa3ce..e795d11da48b 100644 --- a/packages/tui/src/prompt/history.tsx +++ b/packages/tui/src/prompt/history.tsx @@ -3,7 +3,7 @@ import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "../context/helper" -import { useTuiEnvironment } from "../runtime" +import { useTuiPaths } from "../context/runtime" import { appendText, readText, writeText } from "../util/persistence" export type PromptInfo = { @@ -49,8 +49,8 @@ export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptI export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", init: () => { - const environment = useTuiEnvironment() - const historyPath = path.join(environment.paths.state, "prompt-history.jsonl") + const paths = useTuiPaths() + const historyPath = path.join(paths.state, "prompt-history.jsonl") onMount(async () => { const lines = parsePromptHistory(await readText(historyPath).catch(() => "")) setStore("history", lines) diff --git a/packages/tui/src/prompt/stash.tsx b/packages/tui/src/prompt/stash.tsx index 56bb4474cdf0..d43ee0eb4230 100644 --- a/packages/tui/src/prompt/stash.tsx +++ b/packages/tui/src/prompt/stash.tsx @@ -2,7 +2,7 @@ import path from "path" import { onMount } from "solid-js" import { createStore, produce, unwrap } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { useTuiEnvironment } from "../runtime" +import { useTuiPaths } from "../context/runtime" import { appendText, readText, writeText } from "../util/persistence" import type { PromptInfo } from "./history" @@ -32,8 +32,8 @@ export function parsePromptStash(text: string) { export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({ name: "PromptStash", init: () => { - const environment = useTuiEnvironment() - const stashPath = path.join(environment.paths.state, "prompt-stash.jsonl") + const paths = useTuiPaths() + const stashPath = path.join(paths.state, "prompt-stash.jsonl") onMount(async () => { const lines = parsePromptStash(await readText(stashPath).catch(() => "")) setStore("entries", lines) diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx index b0f51c080f37..352010840ba0 100644 --- a/packages/tui/src/routes/home/session-destination.tsx +++ b/packages/tui/src/routes/home/session-destination.tsx @@ -8,7 +8,7 @@ import { type Setter, } from "solid-js" import { useSync } from "../../context/sync" -import { useTuiEnvironment } from "../../runtime" +import { useTuiPaths } from "../../context/runtime" export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } @@ -22,10 +22,10 @@ const HomeSessionDestinationContext = createContext() export function HomeSessionDestinationProvider(props: ParentProps) { const sync = useSync() - const environment = useTuiEnvironment() + const paths = useTuiPaths() const [selected, setDestination] = createSignal() const destination = createMemo( - () => selected() ?? { type: "directory", directory: sync.path.directory || environment.cwd, subdirectory: false }, + () => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false }, ) return ( sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID)) const route = useRoute() - const platform = useTuiPlatform() + const clipboard = useClipboard() return ( { + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, content) + } const pluginRuntime = usePluginRuntime() const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() const event = useEvent() const project = useProject() - const environment = useTuiEnvironment() + const paths = useTuiPaths() const tuiConfig = useTuiConfig() const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) + + createEffect(() => { + const title = Locale.truncate(session()?.title ?? "", 50) + setEpilogue(sessionEpilogue({ title, sessionID: session()?.id })) + }) + onCleanup(() => setEpilogue()) const children = createMemo(() => { const parentID = session()?.parentID ?? session()?.id return sync.data.session @@ -353,13 +367,6 @@ export function Session() { }) }) - const exit = useExit() - - createEffect(() => { - const title = Locale.truncate(session()?.title ?? "", 50) - return exit.message.set(sessionExitSummary({ title, sessionID: session()?.id })) - }) - // Helper: Find next visible message boundary in direction const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() @@ -460,8 +467,7 @@ export function Session() { }, run: async () => { const copy = (url: string) => - platform.clipboard - ?.write?.(url) + clipboard.write?.(url) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to copy URL to clipboard", variant: "error" })) const url = session()?.share?.url @@ -896,8 +902,7 @@ export function Session() { return } - platform.clipboard - ?.write?.(text) + clipboard.write?.(text) .then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" })) .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) dialog.clear() @@ -925,7 +930,7 @@ export function Session() { providers: sync.data.provider, }, ) - await platform.clipboard?.write?.(transcript) + await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { toast.show({ message: "Failed to copy session transcript", variant: "error" }) @@ -972,30 +977,32 @@ export function Session() { if (options.openWithoutSaving) { // Just open in editor without saving - await platform.editor?.open({ + await openEditor({ + renderer, value: transcript, cwd: (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || project.instance.directory() || - environment.cwd, + paths.cwd, }) } else { - const exportDir = environment.cwd + const exportDir = paths.cwd const filename = options.filename.trim() const filepath = path.join(exportDir, filename) - await platform.export?.write(filepath, transcript) + await writeExport(filepath, transcript) // Open with EDITOR if available - const result = await platform.editor?.open({ + const result = await openEditor({ + renderer, value: transcript, cwd: (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || project.instance.directory() || - environment.cwd, + paths.cwd, }) if (result !== undefined) { - await platform.export?.write(filepath, result) + await writeExport(filepath, result) } toast.show({ message: `Session exported to ${filename}`, variant: "success" }) @@ -2510,9 +2517,12 @@ function Skill(props: ToolProps) { function Diagnostics(props: { diagnostics: unknown; filePath: string }) { const { theme } = useTheme() - const environment = useTuiEnvironment() + const terminalEnvironment = useTuiTerminalEnvironment() const errors = createMemo(() => { - const normalized = normalizePath(typeof props.filePath === "string" ? props.filePath : "", environment.platform) + const normalized = normalizePath( + typeof props.filePath === "string" ? props.filePath : "", + terminalEnvironment.platform, + ) return parseDiagnostics(props.diagnostics, normalized) }) diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index ce39c8c95a13..0c5d2b313967 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -3,7 +3,7 @@ import { useSync } from "../../context/sync" import { createMemo, Show } from "solid-js" import { useTheme } from "../../context/theme" import { useTuiConfig } from "../../config" -import { useTuiBuildInfo } from "../../runtime" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { usePluginRuntime } from "../../plugin/runtime" import { getScrollAcceleration } from "../../util/scroll" @@ -15,7 +15,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const build = useTuiBuildInfo() const session = createMemo(() => sync.session.get(props.sessionID)) const workspace = () => { const workspaceID = session()?.workspaceID @@ -58,7 +57,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session()!.title} - + {props.sessionID} @@ -94,7 +93,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { Code {" "} - {build.version} + {InstallationVersion} diff --git a/packages/tui/src/runtime.tsx b/packages/tui/src/runtime.tsx index 5d8932da6330..6f519cbe8215 100644 --- a/packages/tui/src/runtime.tsx +++ b/packages/tui/src/runtime.tsx @@ -1,88 +1,5 @@ -import { createComponent, createContext, type JSX, useContext } from "solid-js" import path from "path" -export type TuiEnvironment = Readonly<{ - cwd: string - platform: string - initialRoute?: unknown - paths: Readonly<{ - home: string - state: string - worktree: string - }> - capabilities: Readonly<{ - mouse: boolean - copyOnSelect: boolean - terminalTitle: boolean - terminalSuspend: boolean - workspaces: boolean - showTimeToFirstDraw: boolean - }> - terminal: Readonly<{ - multiplexer?: "tmux" | "screen" - displayServer?: "wayland" | "x11" - }> - editor: Readonly<{ - command?: string - port?: number - zedTerminal: boolean - zedDatabase?: string - }> - skipInitialLoading: boolean -}> - -export type TuiBuildInfo = Readonly<{ - version: string - channel: string -}> - -const EnvironmentContext = createContext() -const BuildInfoContext = createContext() - -export function TuiEnvironmentProvider(props: { value: TuiEnvironment; children: JSX.Element }) { - return createComponent(EnvironmentContext.Provider, { - value: props.value, - get children() { - return props.children - }, - }) -} - -export function TuiBuildInfoProvider(props: { value: TuiBuildInfo; children: JSX.Element }) { - return createComponent(BuildInfoContext.Provider, { - value: props.value, - get children() { - return props.children - }, - }) -} - -export function useTuiEnvironment() { - const value = useContext(EnvironmentContext) - if (!value) throw new Error("TuiEnvironmentProvider is missing") - return value -} - -export function useTuiBuildInfo() { - const value = useContext(BuildInfoContext) - if (!value) throw new Error("TuiBuildInfoProvider is missing") - return value -} - -export function createTuiEnvironment(input: TuiEnvironment): TuiEnvironment { - return Object.freeze({ - ...input, - paths: Object.freeze({ ...input.paths }), - capabilities: Object.freeze({ ...input.capabilities }), - terminal: Object.freeze({ ...input.terminal }), - editor: Object.freeze({ ...input.editor }), - }) -} - -export function createTuiBuildInfo(input: TuiBuildInfo): TuiBuildInfo { - return Object.freeze({ ...input }) -} - export function abbreviateHome(input: string, home: string) { if (!home) return input const relative = path.relative(home, input) diff --git a/packages/opencode/src/cli/tui/win32.ts b/packages/tui/src/terminal-win32.ts similarity index 100% rename from packages/opencode/src/cli/tui/win32.ts rename to packages/tui/src/terminal-win32.ts diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index af3801ec8d82..b6cd705b1e84 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -4,9 +4,9 @@ import { useTheme } from "../context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" -import { useTuiEnvironment } from "../runtime" +import { Flag } from "@opencode-ai/core/flag/flag" import { useBindings, useOpencodeModeStack } from "../keymap" -import { useOptionalTuiPlatform } from "../platform" +import { useClipboard } from "../context/clipboard" export function Dialog( props: ParentProps<{ @@ -180,13 +180,12 @@ export function DialogProvider(props: ParentProps) { const value = init() const renderer = useRenderer() const toast = useToast() - const environment = useTuiEnvironment() - const platform = useOptionalTuiPlatform() + const clipboard = useClipboard() function copySelection() { const text = renderer.getSelection()?.getSelectedText() - if (!text || !platform?.clipboard?.write) return false - void platform.clipboard.write(text).then( + if (!text || !clipboard.write) return false + void clipboard.write(text).then( () => toast.show({ message: "Copied to clipboard", variant: "info" }), (error) => toast.error(error), ) @@ -201,14 +200,14 @@ export function DialogProvider(props: ParentProps) { position="absolute" zIndex={3000} onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => { - if (environment.capabilities.copyOnSelect) return + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return if (!copySelection()) return evt.preventDefault() evt.stopPropagation() }} - onMouseUp={environment.capabilities.copyOnSelect ? copySelection : undefined} + onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined} > value.clear()} size={value.size}> diff --git a/packages/tui/src/util/presentation.ts b/packages/tui/src/util/presentation.ts index 5d66cc68c1fb..cf432bf967eb 100644 --- a/packages/tui/src/util/presentation.ts +++ b/packages/tui/src/util/presentation.ts @@ -26,7 +26,7 @@ function wordmark(pad = "") { }) } -export function sessionExitSummary(input: { title: string; sessionID?: string }) { +export function sessionEpilogue(input: { title: string; sessionID?: string }) { const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}` return [ ...wordmark(" "), diff --git a/packages/tui/src/util/renderer.ts b/packages/tui/src/util/renderer.ts new file mode 100644 index 000000000000..7bcd28ab655c --- /dev/null +++ b/packages/tui/src/util/renderer.ts @@ -0,0 +1,6 @@ +import type { CliRenderer } from "@opentui/core" + +export function destroyRenderer(renderer: Pick) { + renderer.setTerminalTitle("") + if (!renderer.isDestroyed) renderer.destroy() +} diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index 1722e64c1ebe..d9158ba40760 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -1,4 +1,4 @@ -import type { TuiPlatform } from "../platform" +import type { ClipboardService } from "../context/clipboard" type Toast = { show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void @@ -23,7 +23,7 @@ type SelectionKeyEvent = { stopPropagation: () => void } -export function copy(renderer: Renderer, toast: Toast, clipboard: TuiPlatform["clipboard"]): boolean { +export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { const selection = renderer.getSelection() if (!selection) return false @@ -47,7 +47,7 @@ export function handleSelectionKey( renderer: Renderer, toast: Toast, event: SelectionKeyEvent, - clipboard: TuiPlatform["clipboard"], + clipboard: ClipboardService, ) { const selection = renderer.getSelection() if (!selection) return diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx new file mode 100644 index 000000000000..4c42debc8e34 --- /dev/null +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -0,0 +1,59 @@ +import { expect, mock, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { Global } from "@opencode-ai/core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory } from "./fixture/tui-sdk" + +test("SIGHUP clears title and disposes scoped resources once", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const titles: string[] = [] + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + setup.renderer.setTerminalTitle = (title) => { + titles.push(title) + setTitle(title) + } + const listeners = new Set(process.listeners("SIGHUP")) + const events = createEventSource() + const calls = createFetch() + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + let disposes = 0 + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: {}, + pluginHost: { + async start() { + started() + }, + async dispose() { + disposes++ + }, + }, + }).pipe(Effect.provide(Global.defaultLayer)), + ) + await ready + process.emit("SIGHUP") + await task + + expect(setup.renderer.isDestroyed).toBe(true) + expect(titles.at(-1)).toBe("") + expect(disposes).toBe(1) + expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}) diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index 96b9e29f665f..96b95bcc63fa 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -2,13 +2,12 @@ import { testRender } from "@opentui/solid" import { onMount } from "solid-js" import { ArgsProvider } from "../../../../src/context/args" -import { createExit, ExitProvider } from "../../../../src/context/exit" import { KVProvider, useKV } from "../../../../src/context/kv" import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" import { SyncProvider, useSync } from "../../../../src/context/sync" import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" -import { TestTuiEnvironmentProvider } from "../../../fixture/tui-environment" +import { TestTuiContexts } from "../../../fixture/tui-environment" export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" export async function wait(fn: () => boolean, timeout = 2000) { @@ -44,32 +43,22 @@ export async function mount(override?: FetchHandler, state?: string) { } const app = await testRender(() => ( - + - {})}> - + - + - - + )) await ready await wait(() => sync.status === "complete") return { app, emit: events.emit, kv, project, sync, session: calls.session } } - -function SyncFixtureProvider(props: { children: import("solid-js").JSX.Element }) { - return ( - - {props.children} - - ) -} diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index bd6df18ea8a6..cca8f321e21c 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -9,7 +9,7 @@ import { onCleanup } from "solid-js" import { tmpdir } from "../../fixture/fixture" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import type { TuiKeybind } from "../../../src/config/keybind" -import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" +import { TestTuiContexts } from "../../fixture/tui-environment" async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() @@ -57,7 +57,7 @@ async function mountPrompt(input: { onCleanup(off) return ( - - + ) } diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index 176b112866e9..2a5a172f9c53 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -8,7 +8,7 @@ import { KVProvider } from "../../../src/context/kv" import { ThemeProvider } from "../../../src/context/theme" import { TuiConfigProvider } from "../../../src/config" import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree" -import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" +import { TestTuiContexts } from "../../fixture/tui-environment" import { allExpandedFileTreeDirectories, buildFileTree, @@ -180,13 +180,13 @@ async function captureSettledFrame(app: Awaited>) function withTheme(component: () => JSX.Element) { return ( - + {component()} - + ) } diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 2be887e01d73..0c54786672e4 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -13,7 +13,7 @@ import { OpencodeKeymapProvider } from "../../../src/keymap" import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" +import { TestTuiContexts } from "../../fixture/tui-environment" test("closing the diff viewer returns to the route it opened from", async () => { const viewer = await renderDiffViewer([]) @@ -152,7 +152,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) { commands.get("diff.open")?.run?.({} as never) return ( - + @@ -162,7 +162,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20) { - + ) } diff --git a/packages/tui/test/cli/tui/sync-v2.test.tsx b/packages/tui/test/cli/tui/sync-v2.test.tsx index 53fe31c39041..68de5fdf81ba 100644 --- a/packages/tui/test/cli/tui/sync-v2.test.tsx +++ b/packages/tui/test/cli/tui/sync-v2.test.tsx @@ -7,7 +7,7 @@ import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" import { SyncProviderV2, useSyncV2 } from "../../../src/context/sync-v2" import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk" -import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" +import { TestTuiContexts } from "../../fixture/tui-environment" async function wait(fn: () => boolean, timeout = 2000) { const start = Date.now() @@ -43,7 +43,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { } const app = await testRender(() => ( - + @@ -51,7 +51,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => { - + )) try { @@ -172,7 +172,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => { } const app = await testRender(() => ( - + @@ -180,7 +180,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => { - + )) try { @@ -236,7 +236,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () => } const app = await testRender(() => ( - + @@ -244,7 +244,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () => - + )) try { @@ -284,7 +284,7 @@ test("sync v2 projects live context updates with their message ID", async () => } const app = await testRender(() => ( - + @@ -292,7 +292,7 @@ test("sync v2 projects live context updates with their message ID", async () => - + )) try { @@ -339,7 +339,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn } const app = await testRender(() => ( - + @@ -347,7 +347,7 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn - + )) try { @@ -389,7 +389,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows", } const app = await testRender(() => ( - + @@ -397,7 +397,7 @@ test("sync v2 replaces stale cached rows while preserving in-flight live rows", - + )) try { @@ -458,7 +458,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn } const app = await testRender(() => ( - + @@ -466,7 +466,7 @@ test("sync v2 preserves snapshot order and metadata for in-flight updates", asyn - + )) try { diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 77e4948ea21a..8b29a7f5b8b3 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -7,7 +7,7 @@ import { ProjectProvider, useProject } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" import { useEvent } from "../../../src/context/event" import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" -import { TestTuiEnvironmentProvider } from "../../fixture/tui-environment" +import { TestTuiContexts } from "../../fixture/tui-environment" const projectID = "proj_test" @@ -60,7 +60,7 @@ async function mount() { }) const app = await testRender(() => ( - + - + )) await ready diff --git a/packages/tui/test/clipboard.test.ts b/packages/tui/test/clipboard.test.ts new file mode 100644 index 000000000000..f2d4994c7e2a --- /dev/null +++ b/packages/tui/test/clipboard.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { copyCommand } from "../src/clipboard" + +test("prefers Wayland clipboard when available", () => { + expect(copyCommand("linux", true, (name) => name === "wl-copy")).toEqual(["wl-copy"]) +}) + +test("uses osascript on macOS", () => { + expect(copyCommand("darwin", false, (name) => name === "osascript")).toEqual(["osascript"]) +}) + +test("falls back through X11 clipboard commands", () => { + expect(copyCommand("linux", true, (name) => name === "xclip")).toEqual(["xclip", "-selection", "clipboard"]) + expect(copyCommand("linux", false, (name) => name === "xsel")).toEqual(["xsel", "--clipboard", "--input"]) +}) + +test("returns undefined when native clipboard is unavailable", () => { + expect(copyCommand("linux", false, () => false)).toBeUndefined() +}) diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts new file mode 100644 index 000000000000..369a2136fe5f --- /dev/null +++ b/packages/tui/test/editor.test.ts @@ -0,0 +1,23 @@ +import { afterEach, expect, test } from "bun:test" +import { openEditor } from "../src/editor" + +const editor = process.env.EDITOR +const visual = process.env.VISUAL + +afterEach(() => { + process.env.EDITOR = editor + process.env.VISUAL = visual +}) + +test("rejects when the external editor cannot start", async () => { + delete process.env.VISUAL + process.env.EDITOR = "opencode-editor-that-does-not-exist" + const renderer = { + suspend() {}, + resume() {}, + requestRender() {}, + currentRenderBuffer: { clear() {} }, + } + + await expect(openEditor({ value: "original", renderer: renderer as never })).rejects.toThrow() +}) diff --git a/packages/tui/test/fixture/tui-environment.tsx b/packages/tui/test/fixture/tui-environment.tsx index c0d41737e18a..543332ba1821 100644 --- a/packages/tui/test/fixture/tui-environment.tsx +++ b/packages/tui/test/fixture/tui-environment.tsx @@ -1,42 +1,32 @@ /** @jsxImportSource @opentui/solid */ -import { createTuiEnvironment, TuiEnvironmentProvider, type TuiEnvironment } from "../../src/runtime" +import { + TuiPathsProvider, + TuiStartupProvider, + TuiTerminalEnvironmentProvider, + type TuiPaths, +} from "../../src/context/runtime" import type { ParentProps } from "solid-js" -export function TestTuiEnvironmentProvider( +export function TestTuiContexts( props: ParentProps<{ cwd?: string directory?: string - paths?: Partial - capabilities?: Partial - editor?: Partial + paths?: Partial }>, ) { return ( - - {props.children} - + + {props.children} + + ) } diff --git a/packages/tui/test/index.test.tsx b/packages/tui/test/index.test.tsx index 095bd8929d2d..4603ce52580a 100644 --- a/packages/tui/test/index.test.tsx +++ b/packages/tui/test/index.test.tsx @@ -1,8 +1,6 @@ import { expect, test } from "bun:test" -import { createRenderer, createTuiRenderer, mount, run, tui } from "../src" +import { run } from "../src" test("exports the canonical application lifecycle", () => { - expect(run).toBe(tui) - expect(createRenderer).toBe(createTuiRenderer) - expect(typeof mount).toBe("function") + expect(typeof run).toBe("function") }) diff --git a/packages/tui/test/platform.test.tsx b/packages/tui/test/platform.test.tsx deleted file mode 100644 index be379fd73ec3..000000000000 --- a/packages/tui/test/platform.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { TuiPlatformProvider, useTuiPlatform, type TuiPlatform } from "../src/platform" - -test("provides host platform operations", async () => { - const platform: TuiPlatform = { - files: { - readText: async (path) => `text:${path}`, - readBytes: async () => new Uint8Array([1, 2, 3]), - mime: async () => "text/plain", - }, - } - - function Consumer() { - const value = useTuiPlatform() - return {value.clipboard ? "clipboard" : "files-only"} - } - - const app = await testRender( - () => ( - - - - ), - { width: 20, height: 3 }, - ) - - try { - await app.renderOnce() - expect(app.captureCharFrame()).toContain("files-only") - expect(await platform.files.readText("file.txt")).toBe("text:file.txt") - expect(await platform.files.readBytes("file.bin")).toEqual(new Uint8Array([1, 2, 3])) - } finally { - app.renderer.destroy() - } -}) - -test("requires a platform provider", () => { - expect(() => useTuiPlatform()).toThrow("TuiPlatformProvider is missing") -}) diff --git a/packages/tui/test/prompt/local-attachment.test.ts b/packages/tui/test/prompt/local-attachment.test.ts index 405d5d1fe176..fb86847d0b40 100644 --- a/packages/tui/test/prompt/local-attachment.test.ts +++ b/packages/tui/test/prompt/local-attachment.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { readLocalAttachment } from "../../src/component/prompt/local-attachment" -import type { PlatformFiles } from "../../src/platform" +import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment" +import type { LocalFiles } from "../../src/component/prompt/local-attachment" -function files(input: { mime: string; text?: string; bytes?: Uint8Array }): PlatformFiles { +function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles { return { mime: async () => input.mime, readText: async () => input.text ?? "", @@ -12,7 +12,7 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Plat describe("prompt local attachments", () => { test("reads SVG attachments as text", async () => { - expect(await readLocalAttachment(files({ mime: "image/svg+xml", text: "" }), "/tmp/image.svg")).toEqual({ + expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "" }), "/tmp/image.svg")).toEqual({ type: "text", mime: "image/svg+xml", content: "", @@ -21,7 +21,7 @@ describe("prompt local attachments", () => { test("reads image and PDF attachments as bytes", async () => { const content = new Uint8Array([1, 2, 3]) - expect(await readLocalAttachment(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({ + expect(await readLocalAttachmentWith(files({ mime: "application/pdf", bytes: content }), "/tmp/file.pdf")).toEqual({ type: "binary", mime: "application/pdf", content, @@ -29,9 +29,9 @@ describe("prompt local attachments", () => { }) test("ignores unsupported and unreadable local files", async () => { - expect(await readLocalAttachment(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined() + expect(await readLocalAttachmentWith(files({ mime: "text/plain" }), "/tmp/file.txt")).toBeUndefined() expect( - await readLocalAttachment( + await readLocalAttachmentWith( { ...files({ mime: "image/png" }), readBytes: async () => Promise.reject(new Error("missing")), diff --git a/packages/tui/test/runtime.test.tsx b/packages/tui/test/runtime.test.tsx index 334c0dc25cd4..b9e60d0b37c1 100644 --- a/packages/tui/test/runtime.test.tsx +++ b/packages/tui/test/runtime.test.tsx @@ -1,14 +1,10 @@ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" +import { abbreviateHome } from "../src/runtime" import { - abbreviateHome, - createTuiBuildInfo, - createTuiEnvironment, - TuiBuildInfoProvider, - TuiEnvironmentProvider, - useTuiBuildInfo, - useTuiEnvironment, -} from "../src/runtime" + TuiPathsProvider, + useTuiPaths, +} from "../src/context/runtime" test("abbreviates paths within home boundaries", () => { expect(abbreviateHome("/home/test", "/home/test")).toBe("~") @@ -17,48 +13,27 @@ test("abbreviates paths within home boundaries", () => { expect(abbreviateHome("/tmp/project", "/home/test")).toBe("/tmp/project") }) -test("provides immutable runtime inputs", async () => { - const environment = createTuiEnvironment({ - cwd: "/work", - platform: "linux", - paths: { home: "/home/test", state: "/state", worktree: "/data/worktree" }, - capabilities: { - mouse: true, - copyOnSelect: true, - terminalTitle: true, - terminalSuspend: true, - workspaces: false, - showTimeToFirstDraw: false, - }, - terminal: { multiplexer: "tmux", displayServer: "wayland" }, - editor: { command: "vim", port: 4242, zedTerminal: false }, - skipInitialLoading: false, - }) - const build = createTuiBuildInfo({ version: "1.2.3", channel: "beta" }) +test("provides focused immutable runtime inputs", async () => { + let paths: ReturnType function Runtime() { - const runtime = useTuiEnvironment() - const info = useTuiBuildInfo() - return {`${runtime.cwd} ${runtime.editor.command} ${info.version}`} + paths = useTuiPaths() + return {paths.cwd} } const app = await testRender( () => ( - - - - - + + + ), { width: 40, height: 3 }, ) try { await app.renderOnce() - expect(app.captureCharFrame()).toContain("/work vim 1.2.3") - expect(Object.isFrozen(environment)).toBe(true) - expect(Object.isFrozen(environment.paths)).toBe(true) - expect(Object.isFrozen(build)).toBe(true) + expect(app.captureCharFrame()).toContain("/work") + expect(Object.isFrozen(paths!)).toBe(true) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/theme.test.ts b/packages/tui/test/theme.test.ts index 3f6634667967..acdb2a39c41e 100644 --- a/packages/tui/test/theme.test.ts +++ b/packages/tui/test/theme.test.ts @@ -1,6 +1,10 @@ import { expect, test } from "bun:test" +import { mkdir, writeFile } from "node:fs/promises" +import path from "node:path" import type { TerminalColors } from "@opentui/core" import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme, terminalMode } from "../src/theme" +import { discoverThemes } from "../src/context/theme" +import { tmpdir } from "./fixture/fixture" test("addTheme writes into module theme store", () => { const name = `plugin-theme-${Date.now()}` @@ -63,3 +67,15 @@ test("terminalMode derives mode from refreshed background", () => { test("terminalMode does not derive mode from ANSI slot zero", () => { expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined() }) + +test("custom theme precedence follows directory order", async () => { + await using tmp = await tmpdir() + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + await mkdir(path.join(global, "themes"), { recursive: true }) + await mkdir(path.join(project, "themes"), { recursive: true }) + await writeFile(path.join(global, "themes", "custom.json"), JSON.stringify({ source: "global" })) + await writeFile(path.join(project, "themes", "custom.json"), JSON.stringify({ source: "project" })) + + await expect(discoverThemes([global, project])).resolves.toEqual({ custom: { source: "project" } }) +}) diff --git a/packages/tui/test/util/presentation.test.ts b/packages/tui/test/util/presentation.test.ts index 51c8633a671b..d1aab4ea49b4 100644 --- a/packages/tui/test/util/presentation.test.ts +++ b/packages/tui/test/util/presentation.test.ts @@ -1,11 +1,8 @@ -import { describe, expect, test } from "bun:test" -import { sessionExitSummary } from "../../src/util/presentation" +import { expect, test } from "bun:test" +import { sessionEpilogue } from "../../src/util/presentation" -describe("util.presentation", () => { - test("formats the ANSI session exit summary", () => { - const summary = sessionExitSummary({ title: "A session", sessionID: "ses_123" }) - expect(summary.split("\n")).toHaveLength(8) - expect(summary).toContain("\x1b[90mSession \x1b[0m\x1b[1mA session\x1b[0m") - expect(summary).toContain("\x1b[1mopencode -s ses_123\x1b[0m") - }) +test("formats session continuation summary", () => { + const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" }) + expect(epilogue).toContain("A session") + expect(epilogue).toContain("opencode -s ses_123") }) diff --git a/packages/tui/test/util/renderer.test.ts b/packages/tui/test/util/renderer.test.ts new file mode 100644 index 000000000000..a5c4e7b54eb8 --- /dev/null +++ b/packages/tui/test/util/renderer.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test" +import { destroyRenderer } from "../../src/util/renderer" + +test("clears the terminal title before destroying the renderer", () => { + const calls: string[] = [] + destroyRenderer({ + isDestroyed: false, + setTerminalTitle(title) { + calls.push(`title:${title}`) + }, + destroy() { + calls.push("destroy") + }, + }) + expect(calls).toEqual(["title:", "destroy"]) +}) + +test("still clears the title after renderer destruction", () => { + const calls: string[] = [] + destroyRenderer({ + isDestroyed: true, + setTerminalTitle(title) { + calls.push(`title:${title}`) + }, + destroy() { + calls.push("destroy") + }, + }) + expect(calls).toEqual(["title:"]) +}) From 7042861d6facf9e99c4e7daad6bf75f09ce0ea98 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 7 Jun 2026 01:07:24 -0400 Subject: [PATCH 3/3] fix(tui): install ctrl-c guard before startup --- packages/opencode/src/cli/cmd/tui.ts | 256 ++++++++++++++------------- packages/tui/package.json | 1 + packages/tui/src/app.tsx | 21 +-- 3 files changed, 137 insertions(+), 141 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 67b634891cc4..ed2e46a1a007 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -19,6 +19,7 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" import { validateSession } from "../tui/validate-session" +import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" declare global { const OPENCODE_WORKER_PATH: string @@ -111,144 +112,153 @@ export const TuiThreadCommand = cmd({ describe: "agent to use", }), handler: async (args) => { - const { TuiConfig } = await import("@/config/tui") - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exitCode = 1 - return - } - - // Resolve relative --project paths from PWD, then use the real cwd after - // chdir so the thread and worker share the same directory key. - const next = resolveThreadDirectory(args.project) - const file = await target() + const unguard = win32InstallCtrlCGuard() try { - process.chdir(next) - } catch { - UI.error("Failed to change directory to " + next) - return - } - const cwd = Filesystem.resolve(process.cwd()) - const env = sanitizedProcessEnv({ - [OPENCODE_PROCESS_ROLE]: "worker", - [OPENCODE_RUN_ID]: ensureRunID(), - }) + const { TuiConfig } = await import("@/config/tui") + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exitCode = 1 + return + } - const worker = new Worker(file, { - env, - }) - worker.onerror = (e) => { - Log.Default.error("thread error", { - message: e.message, - filename: e.filename, - lineno: e.lineno, - colno: e.colno, - error: e.error, + // Resolve relative --project paths from PWD, then use the real cwd after + // chdir so the thread and worker share the same directory key. + const next = resolveThreadDirectory(args.project) + const file = await target() + try { + process.chdir(next) + } catch { + UI.error("Failed to change directory to " + next) + return + } + const cwd = Filesystem.resolve(process.cwd()) + const env = sanitizedProcessEnv({ + [OPENCODE_PROCESS_ROLE]: "worker", + [OPENCODE_RUN_ID]: ensureRunID(), }) - } - const client = Rpc.client(worker) - const error = (e: unknown) => { - Log.Default.error("process error", { error: errorMessage(e) }) - } - const reload = () => { - client.call("reload", undefined).catch((err) => { - Log.Default.warn("worker reload failed", { - error: errorMessage(err), - }) + const worker = new Worker(file, { + env, }) - } - process.on("uncaughtException", error) - process.on("unhandledRejection", error) - process.on("SIGUSR2", reload) - - let stopped = false - const stop = async () => { - if (stopped) return - stopped = true - process.off("uncaughtException", error) - process.off("unhandledRejection", error) - process.off("SIGUSR2", reload) - await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { - Log.Default.warn("worker shutdown failed", { - error: errorMessage(error), + worker.onerror = (e) => { + Log.Default.error("thread error", { + message: e.message, + filename: e.filename, + lineno: e.lineno, + colno: e.colno, + error: e.error, }) - }) - worker.terminate() - } + } - const prompt = await input(args.prompt) - const config = await TuiConfig.get() - - const network = resolveNetworkOptionsNoConfig(args) - const external = - process.argv.includes("--port") || - process.argv.includes("--hostname") || - process.argv.includes("--mdns") || - network.mdns || - network.port !== 0 || - network.hostname !== "127.0.0.1" - - const transport = external - ? { - url: (await client.call("server", network)).url, - fetch: undefined, - events: undefined, - } - : { - url: "http://opencode.internal", - fetch: createWorkerFetch(client), - events: createEventSource(client), - } + const client = Rpc.client(worker) + const error = (e: unknown) => { + Log.Default.error("process error", { error: errorMessage(e) }) + } + const reload = () => { + client.call("reload", undefined).catch((err) => { + Log.Default.warn("worker reload failed", { + error: errorMessage(err), + }) + }) + } + process.on("uncaughtException", error) + process.on("unhandledRejection", error) + process.on("SIGUSR2", reload) - try { - await validateSession({ - url: transport.url, - sessionID: args.session, - directory: cwd, - fetch: transport.fetch, - }) - } catch (error) { - UI.error(errorMessage(error)) - process.exitCode = 1 - return - } + let stopped = false + const stop = async () => { + if (stopped) return + stopped = true + process.off("uncaughtException", error) + process.off("unhandledRejection", error) + process.off("SIGUSR2", reload) + await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { + Log.Default.warn("worker shutdown failed", { + error: errorMessage(error), + }) + }) + worker.terminate() + } - setTimeout(() => { - client.call("checkUpgrade", { directory: cwd }).catch(() => {}) - }, 1000).unref?.() + const prompt = await input(args.prompt) + const config = await TuiConfig.get() - try { - const { Effect } = await import("effect") - const { run } = await import("../tui/layer") - const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") - await Effect.runPromise( - run({ + const network = resolveNetworkOptionsNoConfig(args) + const external = + process.argv.includes("--port") || + process.argv.includes("--hostname") || + process.argv.includes("--mdns") || + network.mdns || + network.port !== 0 || + network.hostname !== "127.0.0.1" + + const transport = external + ? { + url: (await client.call("server", network)).url, + fetch: undefined, + events: undefined, + } + : { + url: "http://opencode.internal", + fetch: createWorkerFetch(client), + events: createEventSource(client), + } + + try { + await validateSession({ url: transport.url, - async onSnapshot() { - const tui = writeHeapSnapshot("tui.heapsnapshot") - const server = await client.call("snapshot", undefined) - return [tui, server] - }, - config, - pluginHost: createLegacyTuiPluginHost(), + sessionID: args.session, directory: cwd, fetch: transport.fetch, - events: transport.events, - args: { - continue: args.continue, - sessionID: args.session, - agent: args.agent, - model: args.model, - prompt, - fork: args.fork, - }, - }), - ) + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + + setTimeout(() => { + client.call("checkUpgrade", { directory: cwd }).catch(() => {}) + }, 1000).unref?.() + + try { + const { Effect } = await import("effect") + const { run } = await import("../tui/layer") + const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime") + await Effect.runPromise( + run({ + url: transport.url, + async onSnapshot() { + const tui = writeHeapSnapshot("tui.heapsnapshot") + const server = await client.call("snapshot", undefined) + return [tui, server] + }, + config, + pluginHost: createLegacyTuiPluginHost(), + directory: cwd, + fetch: transport.fetch, + events: transport.events, + args: { + continue: args.continue, + sessionID: args.session, + agent: args.agent, + model: args.model, + prompt, + fork: args.fork, + }, + }), + ) + } finally { + await stop() + } + process.exit(0) } finally { - await stop() + try { + unguard?.() + } catch (error) { + Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) }) + } } - process.exit(0) }, }) // scratch diff --git a/packages/tui/package.json b/packages/tui/package.json index 2f270c62fca4..46711311e602 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -28,6 +28,7 @@ "./editor-zed": "./src/editor-zed.ts", "./context/aggregate-failures": "./src/context/aggregate-failures.ts", "./runtime": "./src/runtime.tsx", + "./terminal-win32": "./src/terminal-win32.ts", "./config/keybind": "./src/config/keybind.ts", "./keymap": "./src/keymap.tsx", "./prompt/display": "./src/prompt/display.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 5a1bb9decc88..ec1fe4d7d3ad 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -78,7 +78,7 @@ import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" -import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./terminal-win32" +import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" import { destroyRenderer } from "./util/renderer" const appGlobalBindingCommands = [ @@ -194,24 +194,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }, }), ), - (renderer) => - Effect.sync(() => destroyRenderer(renderer)), - ) - yield* Effect.acquireRelease( - Effect.sync(() => { - const unguard = win32InstallCtrlCGuard() - win32DisableProcessedInput() - return unguard - }), - (unguard) => - Effect.sync(() => { - try { - unguard?.() - } catch (error) { - console.error("Failed to restore terminal guard", error) - } - }), + (renderer) => Effect.sync(() => destroyRenderer(renderer)), ) + win32DisableProcessedInput() const keymap = createDefaultOpenTuiKeymap(renderer) yield* Effect.acquireRelease( Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)),