tui: remove legacy V1 CLI TUI integration to simplify startup

This commit is contained in:
Dax Raad 2026-07-13 01:05:14 -04:00
commit a43de3f86d
13 changed files with 8 additions and 542 deletions

View file

@ -159,7 +159,6 @@ for (const item of targets) {
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
const workerPath = "./src/cli/tui/worker.ts"
// Use platform-specific bunfs root path based on target OS
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
@ -185,13 +184,12 @@ for (const item of targets) {
windows: {},
},
files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
entrypoints: ["./src/index.ts", parserWorker, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
define: {
FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"),
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_MODELS_DEV: generated.modelsData,
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
OPENCODE_WORKER_PATH: workerPath,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),

View file

@ -1,100 +0,0 @@
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { OpenCode } from "@opencode-ai/client/promise"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({
command: "attach <url>",
describe: "attach to a running opencode server",
builder: (yargs) =>
yargs
.positional("url", {
type: "string",
describe: "http://localhost:4096",
demandOption: true,
})
.option("dir", {
type: "string",
description: "directory to run in",
})
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
type: "string",
describe: "session id to continue",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
handler: async (args) => {
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 { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
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 { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
api: OpenCode.make({ baseUrl: args.url, headers }),
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
continue: args.continue,
sessionID: args.session,
fork: args.fork,
},
}),
)
},
})

View file

@ -1,198 +0,0 @@
import { cmd } from "@/cli/cmd/cmd"
import { Rpc } from "@/util/rpc"
import { type rpc } from "../tui/worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { OpenCode } from "@opencode-ai/client/promise"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { writeHeapSnapshot } from "v8"
import { ServerAuth } from "@/server/auth"
import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
declare global {
const OPENCODE_WORKER_PATH: string
}
async function target() {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
const dist = new URL("./cli/tui/worker.js", import.meta.url)
if (await Filesystem.exists(fileURLToPath(dist))) return dist
return new URL("../tui/worker.ts", import.meta.url)
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
if (!value) return piped
if (!piped) return value
return piped + "\n" + value
}
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
const root = Filesystem.resolve(envPWD ?? cwd)
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
return Filesystem.resolve(cwd)
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
withNetworkOptions(yargs)
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
})
.option("continue", {
alias: ["c"],
describe: "continue the last session",
type: "boolean",
})
.option("session", {
alias: ["s"],
type: "string",
describe: "session id to continue",
})
.option("fork", {
type: "boolean",
describe: "fork the session when continuing (use with --continue or --session)",
})
.option("prompt", {
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("auto", {
type: "boolean",
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
default: false,
})
.option("yolo", {
type: "boolean",
hidden: true,
default: false,
})
.option("dangerously-skip-permissions", {
type: "boolean",
hidden: true,
default: false,
}),
handler: async (args) => {
const unguard = win32InstallCtrlCGuard()
try {
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()
try {
process.chdir(next)
} catch {
UI.error("Failed to change directory to " + next)
return
}
const cwd = Filesystem.resolve(process.cwd())
const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
const client = Rpc.client<typeof rpc>(worker)
const reload = () => {
client.call("reload", undefined).catch(() => {})
}
process.on("SIGUSR2", reload)
let stopped = false
const stop = async () => {
if (stopped) return
stopped = true
process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch(() => {})
worker.terminate()
}
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true
const headers = external ? ServerAuth.headers() : undefined
const url = (await client.call("server", network)).url
try {
await validateSession({
url,
sessionID: args.session,
directory: cwd,
headers,
})
} 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({
// @ts-expect-error V1 does not consume the V2-only server input.
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
api: OpenCode.make({ baseUrl: url, headers }),
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
pluginHost: createLegacyTuiPluginHost(),
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
auto: args.auto || args.yolo || args["dangerously-skip-permissions"],
},
}),
)
} finally {
await stop()
}
} finally {
try {
unguard?.()
} catch {}
}
process.exit(0)
},
})
// scratch

View file

@ -1,8 +0,0 @@
import { run as runTui, type TuiInput } from "@opencode-ai/tui"
import { Global } from "@opencode-ai/core/global"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect } from "effect"
export function run(input: TuiInput) {
return runTui(input).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}

View file

@ -1,54 +0,0 @@
import { Server } from "@/server/server"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
Heap.start()
const onUnhandledRejection = (_error: unknown) => {}
const onUncaughtException = (_error: Error) => {}
process.on("unhandledRejection", onUnhandledRejection)
process.on("uncaughtException", onUncaughtException)
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = {
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = await Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
await InstanceRuntime.load({ directory: input.directory })
await upgrade().catch(() => {})
},
async reload() {
await AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
yield* cfg.invalidate()
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
}),
)
},
async shutdown() {
await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true)
process.off("unhandledRejection", onUnhandledRejection)
process.off("uncaughtException", onUncaughtException)
},
}
Rpc.listen(rpc)

View file

@ -18,9 +18,7 @@ import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/attach"
import { V2ServeCommand } from "./cli/cmd/v2-serve"
import { TuiThreadCommand } from "./cli/cmd/tui"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
@ -82,8 +80,6 @@ const cli = yargs(args)
.command(AcpCommand)
.command(McpCommand)
.command(V2ServeCommand)
.command(TuiThreadCommand)
.command(AttachCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)

View file

@ -42,7 +42,7 @@ import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
import { createCommandShim } from "@opencode-ai/tui/plugin/command-shim"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Effect } from "effect"
import { createPluginRuntime, type PluginRuntime, type TuiPluginHost } from "@opencode-ai/tui/plugin/runtime"
import { createPluginRuntime, type PluginRuntime } from "@opencode-ai/tui/plugin/runtime"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
@ -1121,11 +1121,4 @@ async function load(input: {
}
}
export function createLegacyTuiPluginHost(): TuiPluginHost {
return {
start: init,
dispose,
}
}
export * as TuiPluginRuntime from "./runtime"

View file

@ -1,5 +1,4 @@
import yargs from "yargs"
import { TuiThreadCommand } from "./cli/cmd/tui"
import { V2ServeCommand } from "./cli/cmd/v2-serve"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { hideBin } from "yargs/helpers"
@ -29,5 +28,4 @@ const cli = yargs(hideBin(process.argv))
if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
})
.command(V2ServeCommand)
.command(TuiThreadCommand)
.parse()

View file

@ -1,11 +0,0 @@
import { describe, expect, test } from "bun:test"
describe("tui attach", () => {
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).toContain('await import("../tui/layer")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
})

View file

@ -1,89 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import yargs from "yargs"
import { tmpdir } from "../../fixture/fixture"
import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui"
import { cliIt } from "../../lib/cli-process"
describe("tui thread", () => {
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).toContain('await import("../tui/layer")')
expect(source).toMatch(/await import\(["']@\/plugin\/tui\/runtime["']\)/)
expect(source).not.toContain('import("./app")')
})
test("forwards the CLI environment to the TUI worker", async () => {
const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text()
expect(source).toMatch(/new Worker\(file, \{\s*env: Object\.fromEntries\(\s*Object\.entries\(process\.env\)/)
})
async function check(project?: string) {
await using tmp = await tmpdir({ git: true })
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir"
try {
await fs.symlink(tmp.path, link, type)
expect(resolveThreadDirectory(project, link, tmp.path)).toBe(tmp.path)
} finally {
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
}
}
test("uses the real cwd when PWD points at a symlink", async () => {
await check()
})
test("uses the real cwd after resolving a relative project from PWD", async () => {
await check(".")
})
test("resolves a relative project from PWD when cwd differs", async () => {
await using pwd = await tmpdir({ git: true })
await using cwd = await tmpdir({ git: true })
expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(pwd.path)
expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path)
})
test("preserves boolean negation for existing options", async () => {
const args = await yargs([])
.command({ ...TuiThreadCommand, handler: () => {} })
.exitProcess(false)
.parse(["--mdns", "--no-mdns"])
expect(args.mdns).toBe(false)
})
cliIt.live("rejects removed top-level mini alias", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("rejects removed run mini flag", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["run", "--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
cliIt.live("rejects removed attach mini alias", ({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"])
opencode.expectExit(result, 1)
expect(result.stderr).not.toContain("opencode mini requires a TTY stdout")
}),
)
})

View file

@ -77,7 +77,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { TuiConfigV1 } from "./config/v1"
import { createTuiApiAdapters } from "./plugin/adapters"
import { createTuiApi } from "./plugin/api"
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
@ -139,7 +138,6 @@ const appBindingCommands = [
"diff.open",
"app.debug",
"app.console",
"app.heap_snapshot",
"terminal.suspend",
"terminal.title.toggle",
"app.toggle.animations",
@ -155,8 +153,7 @@ export type TuiInput = {
reload?: () => Promise<void>
}
args: Args
config: Config.Interface | TuiConfigV1.Resolved
onSnapshot?: () => Promise<string[]>
config: Config.Interface
pluginHost: TuiPluginHost
terminalHandoff?: () => Promise<
| {
@ -201,45 +198,12 @@ function isVersionGreater(left: string, right: string) {
return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0
}
function fromV1(config: TuiConfigV1.Resolved): Config.Info {
return {
theme: config.theme ? { name: config.theme } : undefined,
plugins: config.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
leader: { timeout: config.leader_timeout },
scroll:
config.scroll_speed === undefined && config.scroll_acceleration === undefined
? undefined
: { speed: config.scroll_speed, acceleration: config.scroll_acceleration?.enabled },
attention: config.attention,
diffs: config.diff_style === undefined ? undefined : { view: config.diff_style === "stacked" ? "unified" : "auto" },
mouse: config.mouse,
}
}
function isConfigInterface(config: Config.Interface | TuiConfigV1.Resolved): config is Config.Interface {
return (
"get" in config && typeof config.get === "function" && "update" in config && typeof config.update === "function"
)
}
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const log = input.log ?? (() => {})
const global = yield* Global.Service
const configInput = input.config
const loaded = yield* Effect.gen(function* () {
if (isConfigInterface(configInput)) {
return {
service: configInput,
info: yield* Effect.tryPromise(() => configInput.get()),
legacy: undefined,
}
}
return { service: undefined, info: fromV1(configInput), legacy: configInput }
const config = Config.resolve(yield* Effect.tryPromise(() => input.config.get()), {
terminalSuspend: process.platform !== "win32",
})
const config = Config.resolve(loaded.info, { terminalSuspend: process.platform !== "win32" })
if (loaded.legacy) config.keybinds = loaded.legacy.keybinds
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
@ -376,7 +340,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<ArgsProvider {...input.args}>
<ConfigProvider
config={config}
service={loaded.service}
service={input.config}
options={{ terminalSuspend: process.platform !== "win32" }}
>
<KVProvider>
@ -412,9 +376,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<EditorContextProvider>
<LocationProvider>
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
pluginConfig={loaded.legacy ?? config}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
@ -473,15 +435,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
function App(props: {
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
pluginConfig: any
pair?: DialogPairCredentials
}) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const configContext = useConfig()
const config = configContext.data
const config = useConfig().data
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@ -555,7 +514,6 @@ function App(props: {
props.pluginHost
.start({
api,
config: props.pluginConfig,
runtime: pluginRuntime,
dispose: () => attention.dispose(),
})
@ -859,7 +817,6 @@ function App(props: {
name: "opencode.settings",
title: "Open settings",
slashName: "settings",
enabled: configContext.writable,
run: () => {
dialog.replace(() => <DialogConfig />)
},
@ -983,20 +940,6 @@ function App(props: {
dialog.clear()
},
},
{
name: "app.heap_snapshot",
title: "Write heap snapshot",
category: "System",
run: async () => {
const files = await props.onSnapshot?.()
toast.show({
variant: "info",
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
},
},
{
name: "terminal.suspend",
title: "Suspend terminal",

View file

@ -179,7 +179,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
const ConfigContext = createContext<{
data: Resolved
update: Interface["update"]
writable: boolean
}>()
export function ConfigProvider(props: {
@ -197,7 +196,7 @@ export function ConfigProvider(props: {
return info
}
return (
<ConfigContext.Provider value={{ data: config, update, writable: !!host }}>{props.children}</ConfigContext.Provider>
<ConfigContext.Provider value={{ data: config, update }}>{props.children}</ConfigContext.Provider>
)
}

View file

@ -60,7 +60,6 @@ export type PluginRuntime = ReturnType<typeof createPluginRuntime>
export type TuiPluginHost = {
start(input: {
api: TuiPluginApi
config: any
runtime: PluginRuntime
dispose?: () => void
}): Promise<void>