mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(cli): add Vite-powered TUI development entrypoint (#47950)
This commit is contained in:
@@ -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:",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// A JS package entry lets Vite externalize the native lifecycle owner.
|
||||
export { host } from "./tui.ts"
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
declare module "solid-refresh/dist/solid-refresh.mjs" {
|
||||
export * from "solid-refresh"
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user