multiprocess

This commit is contained in:
Dax Raad 2025-10-02 05:25:58 -04:00
parent 46d98858d2
commit b3177271d8
7 changed files with 253 additions and 292 deletions

View file

@ -50,7 +50,7 @@ for (const [os, arch] of targets) {
execArgv: [`--user-agent=opencode/${version}`, `--env-file=""`, `--`],
windows: {},
},
entrypoints: ["./src/index.ts"],
entrypoints: ["./src/index.ts", "./src/cli/cmd/tui/worker.ts"],
define: {
OPENCODE_VERSION: `'${version}'`,
OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,

View file

@ -1,56 +0,0 @@
import { Global } from "../../global"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Log } from "../../util/log"
import { $ } from "bun"
export const AttachCommand = cmd({
command: "attach <server>",
describe: "attach to a running opencode server",
builder: (yargs) =>
yargs.positional("server", {
type: "string",
describe: "http://localhost:4096",
}),
handler: async (args) => {
let cmd = [] as string[]
const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File
if (tui) {
let binaryName = tui.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, tui, { mode: 0o755 })
if (process.platform !== "win32") await fs.chmod(binary, 0o755)
}
cmd = [binary]
}
if (!tui) {
const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}`
await $`go build -o ${binaryName} ./main.go`.cwd(dir)
cmd = [path.join(dir, binaryName)]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: args.server,
},
})
await proc.exited
},
})

View file

@ -0,0 +1,212 @@
import { cmd } from "@/cli/cmd/cmd"
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { SDKProvider } from "@tui/context/sdk"
import { SyncProvider } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { Instance } from "@/project/instance"
import { Theme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert"
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",
}),
handler: async (args) => {
if (args.dir) process.chdir(args.dir)
await render(
() => {
return (
<RouteProvider>
<SDKProvider url={args.url}>
<SyncProvider>
<LocalProvider>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App
onExit={() => {
process.exit(0)
}}
/>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
)
},
{
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: true,
},
)
},
})
function App(props: { onExit: () => void }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const command = useCommandDialog()
const keybind = useKeybind()
useKeyboard(async (evt) => {
if (keybind.match("agent_cycle", evt)) {
local.agent.move(1)
return
}
if (keybind.match("agent_cycle_reverse", evt)) {
local.agent.move(-1)
}
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
}
if (evt.meta && evt.name === "d") {
renderer.console.toggle()
return
}
if (keybind.match("app_exit", evt)) {
await Instance.disposeAll()
renderer.destroy()
props.onExit()
}
})
createEffect(() => {
console.log(JSON.stringify(route.data))
})
command.register(() => [
{
title: "Switch session",
value: "session.list",
keybind: "session_list",
category: "Session",
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "session.new",
keybind: "session_new",
category: "Session",
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
{
title: "Switch model",
value: "model.list",
keybind: "model_list",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Switch agent",
value: "agent.list",
keybind: "agent_list",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
},
])
createEffect(() => {
const providerID = local.model.current().providerID
if (providerID === "openrouter" && !local.kv.data.openrouter_warning) {
untrack(() => {
DialogAlert.show(
dialog,
"Warning",
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
).then(() => local.kv.set("openrouter_warning", true))
})
}
})
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
<box
height={1}
backgroundColor={Theme.backgroundPanel}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
>
<box flexDirection="row">
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={Theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={Theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}></text>
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background} wrap={false}>
{" "}
<span style={{ bold: true }}>{local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</box>
</box>
)
}

View file

@ -1,27 +1,9 @@
import { cmd } from "@/cli/cmd/cmd"
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { SDKProvider } from "@tui/context/sdk"
import { SyncProvider } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
import { Theme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { DialogAlert } from "./ui/dialog-alert"
import path from "path"
import { Server } from "@/server/server"
import { Config } from "@/config/config"
import { InstanceBootstrap } from "@/project/bootstrap"
export const TuiCommand = cmd({
command: "$0 [project]",
@ -32,30 +14,6 @@ export const TuiCommand = cmd({
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"],
describe: "session id to continue",
type: "string",
})
.option("prompt", {
alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("port", {
type: "number",
describe: "port to listen on",
@ -67,193 +25,38 @@ export const TuiCommand = cmd({
describe: "hostname to listen on",
default: "127.0.0.1",
}),
handler: async () => {
await Instance.provide({
directory: process.cwd(),
fn: () => Config.get(),
handler: async (args) => {
const server = Server.listen({
port: args.port,
hostname: "127.0.0.1",
})
const worker = new Worker("./src/cli/cmd/tui/worker.ts")
worker.onerror = console.log
worker.onmessageerror = console.log
const url = await new Promise<string>((resolve) => {
worker.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "ready") {
resolve(data.url)
}
}
})
await render(
() => {
return (
<RouteProvider>
<SDKProvider url={url}>
<SyncProvider>
<LocalProvider>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<App
onExit={() => {
worker.terminate()
}}
/>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</LocalProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
)
},
{
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: true,
},
)
const bin = process.execPath
const cmd = []
let cwd = process.cwd()
if (bin.endsWith("bun")) {
cmd.push(
process.execPath,
"run",
"--conditions",
"browser",
new URL("../../../index.ts", import.meta.url).pathname,
)
cwd = new URL("../../../../", import.meta.url).pathname
} else cmd.push(process.execPath)
cmd.push("attach", server.url.toString(), "--dir", args.project ? path.resolve(args.project) : process.cwd())
while (true) {
const proc = Bun.spawn({
cmd,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
})
await proc.exited
const code = proc.exitCode
if (code === 0) break
}
await server.stop(true)
await Instance.disposeAll()
},
})
function App(props: { onExit: () => void }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const command = useCommandDialog()
const keybind = useKeybind()
useKeyboard(async (evt) => {
if (keybind.match("agent_cycle", evt)) {
local.agent.move(1)
return
}
if (keybind.match("agent_cycle_reverse", evt)) {
local.agent.move(-1)
}
if (evt.meta && evt.name === "t") {
renderer.toggleDebugOverlay()
return
}
if (evt.meta && evt.name === "d") {
renderer.console.toggle()
return
}
if (keybind.match("app_exit", evt)) {
await Instance.disposeAll()
renderer.destroy()
props.onExit()
}
})
createEffect(() => {
console.log(JSON.stringify(route.data))
})
command.register(() => [
{
title: "Switch session",
value: "session.list",
keybind: "session_list",
category: "Session",
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "session.new",
keybind: "session_new",
category: "Session",
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
{
title: "Switch model",
value: "model.list",
keybind: "model_list",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogModel />)
},
},
{
title: "Switch agent",
value: "agent.list",
keybind: "agent_list",
category: "Agent",
onSelect: () => {
dialog.replace(() => <DialogAgent />)
},
},
])
createEffect(() => {
const providerID = local.model.current().providerID
if (providerID === "openrouter" && !local.kv.data.openrouter_warning) {
untrack(() => {
DialogAlert.show(
dialog,
"Warning",
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
).then(() => local.kv.set("openrouter_warning", true))
})
}
})
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={Theme.background}>
<box flexDirection="column" flexGrow={1}>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
</box>
<box
height={1}
backgroundColor={Theme.backgroundPanel}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
>
<box flexDirection="row">
<box flexDirection="row" backgroundColor={Theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>open</text>
<text attributes={TextAttributes.BOLD}>code </text>
<text fg={Theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={Theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
</box>
</box>
<box flexDirection="row" flexShrink={0}>
<text fg={Theme.textMuted} paddingRight={1}>
tab
</text>
<text fg={local.agent.color(local.agent.current().name)}></text>
<text bg={local.agent.color(local.agent.current().name)} fg={Theme.background} wrap={false}>
{" "}
<span style={{ bold: true }}>{local.agent.current().name.toUpperCase()}</span>
<span> AGENT </span>
</text>
</box>
</box>
</box>
)
}

View file

@ -13,6 +13,7 @@ import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { TuiCommand } from "./cli/cmd/tui/tui"
import { AttachCommand } from "./cli/cmd/tui/attach"
import { DebugCommand } from "./cli/cmd/debug"
import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
@ -68,6 +69,7 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command(McpCommand)
.command(TuiCommand)
.command(AttachCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)

View file

@ -47,7 +47,6 @@ export const Instance = {
async disposeAll() {
for (const [key, value] of cache) {
context.provide(value, async () => {
process.stdout.write(`disposing ${key}...`)
await Instance.dispose()
})
}

View file

@ -108,7 +108,8 @@ export namespace SystemPrompt {
const found = Array.from(paths).map((p) =>
Bun.file(p)
.text()
.catch(() => ""),
.catch(() => "")
.then((x) => "Instructions from: " + p + "\n" + x),
)
return Promise.all(found).then((result) => result.filter(Boolean))
}