feat(cli): add Vite-powered TUI development entrypoint (#47950)

This commit is contained in:
James Long
2026-09-08 10:00:16 -04:00
committed by GitHub
parent f9bc2233dd
commit ded9c7e505
11 changed files with 339 additions and 0 deletions
+1
View File
@@ -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:",
},
+2
View File
@@ -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",
+23
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
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)
+56
View File
@@ -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 (
<Portal
ref={(container) => {
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
}}
>
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
</Dialog>
</Portal>
)
}
+2
View File
@@ -0,0 +1,2 @@
// A JS package entry lets Vite externalize the native lifecycle owner.
export { host } from "./tui.ts"
+3
View File
@@ -0,0 +1,3 @@
declare module "solid-refresh/dist/solid-refresh.mjs" {
export * from "solid-refresh"
}
+48
View File
@@ -0,0 +1,48 @@
/// <reference types="vite/client" />
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<symbol>()
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<P extends Record<string, unknown>>(
registry: Registry,
id: string,
render: (props: P) => JSX.Element,
options?: Parameters<typeof $$component>[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)
},
})
}
+171
View File
@@ -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<void, unknown, Global.Service | FileSystem.FileSystem>
const require = createRequire(import.meta.url)
export const host: {
active?: Fiber.Fiber<void, unknown>
mount?: (app: Run) => Promise<void>
stop?: () => Promise<void>
reset?: () => void
} = {}
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
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)
+15
View File
@@ -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")
+2
View File
@@ -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:"
}