From ded9c7e505b0cd0a1d3caa5f72cffc81f690c662 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 8 Sep 2026 10:00:16 -0400 Subject: [PATCH] feat(cli): add Vite-powered TUI development entrypoint (#47950) --- bun.lock | 1 + package.json | 2 + packages/cli/dev/README.md | 23 ++++ packages/cli/dev/entry.ts | 16 +++ packages/cli/dev/error-overlay.tsx | 56 +++++++++ packages/cli/dev/host.js | 2 + packages/cli/dev/refresh-runtime.d.ts | 3 + packages/cli/dev/refresh.ts | 48 ++++++++ packages/cli/dev/tui.ts | 171 ++++++++++++++++++++++++++ packages/cli/dev/vite.ts | 15 +++ packages/cli/package.json | 2 + 11 files changed, 339 insertions(+) create mode 100644 packages/cli/dev/README.md create mode 100644 packages/cli/dev/entry.ts create mode 100644 packages/cli/dev/error-overlay.tsx create mode 100644 packages/cli/dev/host.js create mode 100644 packages/cli/dev/refresh-runtime.d.ts create mode 100644 packages/cli/dev/refresh.ts create mode 100644 packages/cli/dev/tui.ts create mode 100644 packages/cli/dev/vite.ts diff --git a/bun.lock b/bun.lock index df5c15d1e40..7d2e2beebaa 100644 --- a/bun.lock +++ b/bun.lock @@ -168,6 +168,7 @@ "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2", + "solid-refresh": "0.6.3", "vite": "catalog:", "vite-plugin-solid": "catalog:", }, diff --git a/package.json b/package.json index 809d9fd19e5..d5020150bce 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "scripts": { "dev": "bun run --cwd packages/cli src/index.ts", "dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --", + "dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts", + "dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --", "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", diff --git a/packages/cli/dev/README.md b/packages/cli/dev/README.md new file mode 100644 index 00000000000..a8d93f95c74 --- /dev/null +++ b/packages/cli/dev/README.md @@ -0,0 +1,23 @@ +# Vite TUI entrypoint + +From the repository root: + +```sh +bun run dev:vite:live /path/to/project +``` + +This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it. + +- Component edits hot-update through the existing Solid refresh runtime. +- Full reloads await TUI cleanup and reopen the original launch target (Home, `--session`, or `--continue`). They do not preserve a subsequently selected route or local UI state, and do not replay `--prompt`. +- Correcting syntax errors retries a failed reload. The backend stays alive. +- Refreshable components get local error boundaries. Render errors appear in the shared themed Dialog with a red title, normal error text, and no stack trace. Only the latest error is shown, so translucent backdrops never stack. Escape dismisses the dialog and restores prompt focus; saving retries failed components without resetting the app's root boundary. State within remounted components can still reset, especially when several components share an edited file. +- Launcher/config/dependency changes require restarting the development client. + +`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed. + +`refresh.ts` delegates module/component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. Only failed boundaries retry; the global reset is deliberately avoided. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export. + +The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime. + +Tested on Linux/Bun with full-app rendering, message/palette HMR, draft preservation, and native-terminal full reload/error recovery. External native-loaded plugins remain experimental across full reloads because their process-lifetime runtime mappings can retain an older Solid generation. diff --git a/packages/cli/dev/entry.ts b/packages/cli/dev/entry.ts new file mode 100644 index 00000000000..705e7ddc258 --- /dev/null +++ b/packages/cli/dev/entry.ts @@ -0,0 +1,16 @@ +/// +import { host } from "@opencode/cli/vite-host" +import { configureErrorOverlay } from "./refresh" + +if (import.meta.hot) { + import.meta.hot.on("vite:beforeFullReload", async () => { + await host.stop?.() + host.reset?.() + }) +} + +const { run } = await import("../../tui/src/index") +// Theme/dialog modules use refresh themselves; initialize their overlay after the app graph loads. +const { ErrorOverlay } = await import("./error-overlay") +configureErrorOverlay(ErrorOverlay) +await host.mount?.(run) diff --git a/packages/cli/dev/error-overlay.tsx b/packages/cli/dev/error-overlay.tsx new file mode 100644 index 00000000000..82997b7ed1c --- /dev/null +++ b/packages/cli/dev/error-overlay.tsx @@ -0,0 +1,56 @@ +/* @refresh skip */ +import { BoxRenderable, TextAttributes } from "@opentui/core" +import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { onCleanup, onMount } from "solid-js" +import { useTheme } from "../../tui/src/context/theme" +import { Dialog } from "../../tui/src/ui/dialog" +import { Keymap } from "../../tui/src/context/keymap" + +export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) { + const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const theme = useTheme("elevated") + const focus = renderer.currentFocusedRenderable + onCleanup(Keymap.use().mode.push("modal")) + Keymap.createLayer(() => ({ + mode: "modal", + priority: 1000, + commands: [{ bind: "escape", title: "Close hot reload error", group: "Development", run: props.onClose }], + })) + onMount(() => focus?.blur()) + onCleanup(() => { + if (focus && !focus.isDestroyed) focus.focus() + }) + + return ( + { + if (!(container instanceof BoxRenderable)) return + // Anchor Portal's wrapper above the app rather than after it in root layout. + container.position = "absolute" + container.left = 0 + container.top = 0 + container.zIndex = 5000 + }} + > + + + + + Error while hot reloading + + + esc + + + + {props.error instanceof Error ? props.error.message : String(props.error)} + + + {props.component} ยท Fix the component and save to retry. + + + + + ) +} diff --git a/packages/cli/dev/host.js b/packages/cli/dev/host.js new file mode 100644 index 00000000000..b88bc1fd234 --- /dev/null +++ b/packages/cli/dev/host.js @@ -0,0 +1,2 @@ +// A JS package entry lets Vite externalize the native lifecycle owner. +export { host } from "./tui.ts" diff --git a/packages/cli/dev/refresh-runtime.d.ts b/packages/cli/dev/refresh-runtime.d.ts new file mode 100644 index 00000000000..e7b32081c73 --- /dev/null +++ b/packages/cli/dev/refresh-runtime.d.ts @@ -0,0 +1,3 @@ +declare module "solid-refresh/dist/solid-refresh.mjs" { + export * from "solid-refresh" +} diff --git a/packages/cli/dev/refresh.ts b/packages/cli/dev/refresh.ts new file mode 100644 index 00000000000..c62b8999dbb --- /dev/null +++ b/packages/cli/dev/refresh.ts @@ -0,0 +1,48 @@ +/// +import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js" +import { $$component, type Registry } from "solid-refresh/dist/solid-refresh.mjs" +import type { ErrorOverlay } from "./error-overlay" + +let overlay: typeof ErrorOverlay +const [activeError, setActiveError] = createSignal() +export function configureErrorOverlay(component: typeof ErrorOverlay) { + overlay = component +} + +export { $$context, $$decline, $$refresh, $$registry } from "solid-refresh/dist/solid-refresh.mjs" +export { component as $$component } + +function component

>( + registry: Registry, + id: string, + render: (props: P) => JSX.Element, + options?: Parameters[3], +) { + const proxy = $$component(registry, id, render, options) + return (props: P) => + createComponent(ErrorBoundary, { + fallback(error: unknown, reset: () => void) { + const token = Symbol(id) + // Several instances can fail in one update. Stack neither dialogs nor translucent backdrops. + setActiveError(token) + onCleanup(() => { + if (activeError() === token) setActiveError(undefined) + }) + // Retry only this failed subtree. Resetting the app's boundary destroys its providers and route. + import.meta.hot?.on("vite:afterUpdate", reset) + onCleanup(() => import.meta.hot?.off("vite:afterUpdate", reset)) + return createComponent(Show, { + keyed: true, + get when() { + return activeError() === token + }, + get children() { + return createComponent(overlay, { component: id, error, onClose: () => setActiveError(undefined) }) + }, + }) + }, + get children() { + return createComponent(proxy, props) + }, + }) +} diff --git a/packages/cli/dev/tui.ts b/packages/cli/dev/tui.ts new file mode 100644 index 00000000000..5f9a732c147 --- /dev/null +++ b/packages/cli/dev/tui.ts @@ -0,0 +1,171 @@ +import { createRequire } from "node:module" +import path from "node:path" +import { Effect, Exit, Fiber, type FileSystem } from "effect" +import type { TuiInput } from "@opencode/tui" +import type { Global } from "@opencode/util/global" +import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite" +import solid from "vite-plugin-solid" +import refresh from "solid-refresh/babel" + +type Run = (input: TuiInput) => Effect.Effect +const require = createRequire(import.meta.url) +export const host: { + active?: Fiber.Fiber + mount?: (app: Run) => Promise + stop?: () => Promise + reset?: () => void +} = {} + +export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters[0]) { + const fork = Effect.runForkWith(yield* Effect.context>>()) + const finished = Promise.withResolvers>() + let initial = true + host.stop = async () => { + const fiber = host.active + host.active = undefined + if (fiber) await Effect.runPromise(Fiber.interrupt(fiber)) + } + host.mount = async (app) => { + await host.stop?.() + const fiber = fork( + app( + initial + ? input + : { + ...input, + args: { ...input.args, prompt: undefined }, + terminalHandoff: undefined, + }, + ), + ) + initial = false + host.active = fiber + fiber.addObserver((exit) => { + if (host.active !== fiber) return + host.active = undefined + finished.resolve(exit) + }) + } + + const server = yield* Effect.acquireRelease( + Effect.tryPromise(() => + createServer({ + root: path.resolve(import.meta.dirname, "../../tui"), + configFile: false, + appType: "custom", + clearScreen: false, + logLevel: "error", + server: { middlewareMode: true, ws: false }, + resolve: { + alias: [ + { find: /^solid-js(?:\/dist\/solid.js)?$/, replacement: require.resolve("solid-js/dist/dev.js") }, + { + find: /^solid-js\/store(?:\/dist\/store.js)?$/, + replacement: require.resolve("solid-js/store/dist/dev.js"), + }, + ], + }, + plugins: [ + { + name: "tui-refresh-boundaries", + enforce: "pre", + load(id) { + if (id === "/@solid-refresh") + return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}` + }, + }, + solid({ + hot: false, + dev: true, + solid: { generate: "universal", moduleName: "@opentui/solid" }, + // Enable the existing refresh plugin in Vite's non-browser environment. + babel: { plugins: [[refresh, { bundler: "vite" }]] }, + }), + { + name: "tui-recovery", + hotUpdate() { + if (this.environment.name !== "native" || host.active) return + this.environment.moduleGraph.invalidateAll() + this.environment.hot.send({ type: "full-reload" }) + return [] + }, + }, + ], + environments: { + native: { + consumer: "server", + resolve: { + conditions: ["bun", "development", "module"], + externalConditions: ["bun", "node"], + noExternal: [ + "solid-js", + "solid-refresh", + "@opentui/solid", + "@opentui/keymap", + "opentui-spinner", + /^@solid-primitives\//, + "@opencode/plugin", + "@opencode/client", + "@opencode/latex", + "@opencode/merman", + ], + // Exact subpaths are needed for workspace TypeScript exports. + external: [ + "@opentui/core", + "@opentui/core/testing", + "effect", + "@opencode/cli/vite-host", + "@opencode/client", + "@opencode/client/effect/service", + "@opencode/client/promise", + "@opencode/core/util/slug", + "@opencode/schema", + "@opencode/schema/event", + "@opencode/schema/project", + "@opencode/schema/session-id", + "@opencode/schema/session-inbox", + "@opencode/schema/session-message", + "@opencode/schema/skill", + "@opencode/schema/token-usage", + "@opencode/schema/vcs", + "@opencode/schema/worktree", + "@opencode/simulation/frontend", + "@opencode/simulation/protocol", + "@opencode/theme/tui", + "@opencode/theme/tui/v1", + "@opencode/util/activity-calendar", + "@opencode/util/flock", + "@opencode/util/global", + "@opencode/util/hash", + "@opencode/util/session-title-fallback", + ], + }, + optimizeDeps: { noDiscovery: true, include: [] }, + dev: { + createEnvironment: (name, config) => + createRunnableDevEnvironment(name, config, { + runnerOptions: { + sourcemapInterceptor: false, + hmr: { logger: { debug() {}, error: (error) => console.error(error) } }, + }, + }), + }, + }, + }, + }), + ), + (server) => + Effect.promise(async () => { + await host.stop?.() + await server.close() + }), + ) + const environment = server.environments.native + if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment")) + host.reset = () => environment.runner.clearCache() + yield* Effect.promise(() => + environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error), + ) + const exit = yield* Effect.promise(() => finished.promise) + if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause) +}, Effect.scoped) diff --git a/packages/cli/dev/vite.ts b/packages/cli/dev/vite.ts new file mode 100644 index 00000000000..880780596b9 --- /dev/null +++ b/packages/cli/dev/vite.ts @@ -0,0 +1,15 @@ +import { plugin } from "bun" +import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin" + +ensureSolidTransformPlugin() +if (process.argv[2] !== "serve") { + // Vite must initialize before the CLI installs its process/error handling on Bun. + const { run } = await import("./tui") + plugin({ + name: "vite-tui-entry", + setup(build) { + build.module("@opencode/tui", () => ({ loader: "object", exports: { run } })) + }, + }) +} +await import("../src/index") diff --git a/packages/cli/package.json b/packages/cli/package.json index d94831ebf58..f6e2e0d691f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,6 +11,7 @@ "bin" ], "exports": { + "./vite-host": "./dev/host.js", "./run": "./src/run/index.ts", "./server-process": "./src/server-process.ts" }, @@ -74,6 +75,7 @@ "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1", + "solid-refresh": "0.6.3", "vite": "catalog:", "vite-plugin-solid": "catalog:" }