mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
multiprocess
This commit is contained in:
parent
46d98858d2
commit
b3177271d8
7 changed files with 253 additions and 292 deletions
|
|
@ -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'`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
212
packages/opencode/src/cli/cmd/tui/attach.tsx
Normal file
212
packages/opencode/src/cli/cmd/tui/attach.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue