Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/src/commands/handlers/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export default Runtime.handler(Commands, () =>
const daemon = yield* Daemon.Service
const transport = yield* daemon.transport()
const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* Effect.promise(() => runTui(transport))
yield* runTui(transport)
}),
)
94 changes: 6 additions & 88 deletions packages/cli/src/tui.ts
Original file line number Diff line number Diff line change
@@ -1,102 +1,20 @@
import { createTuiBuildInfo, createTuiEnvironment, createTuiRenderer, run, type TuiHost } from "@opencode-ai/tui"
import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import type { TuiPlatform } from "@opencode-ai/tui/platform"
import os from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"

declare const OPENCODE_VERSION: string | undefined
declare const OPENCODE_CHANNEL: string | undefined

export async function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
const state = path.join(os.homedir(), ".local", "state", "opencode")
const environment = createTuiEnvironment({
cwd: process.cwd(),
platform: process.platform,
paths: {
home: os.homedir(),
state,
worktree: path.join(state, "worktree"),
},
capabilities: {
mouse: config.mouse,
copyOnSelect: true,
terminalTitle: true,
terminalSuspend: false,
workspaces: false,
showTimeToFirstDraw: false,
},
terminal: {
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
},
editor: { zedTerminal: false },
skipInitialLoading: false,
})
const build = createTuiBuildInfo({
version: typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local",
channel: typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local",
})
const renderer = await createTuiRenderer(config, { environment, build })
const handle = run({
return run({
...transport,
args: {},
config,
environment,
build,
renderer,
fetch: gracefulFetch,
pluginHost: {
async start() {},
async dispose() {},
},
host: createHost(),
})
await handle.done
}

function createHost(): TuiHost {
return {
platform,
attention() {
return {
async notify() {
return { ok: false, notification: false, sound: false, skipped: "attention_disabled" }
},
soundboard: {
registerPack: () => () => {},
activate: () => false,
current: () => "",
list: () => [],
},
dispose() {},
}
},
logger: { error: (message, extra) => console.error(message, extra ?? "") },
lifecycle: {
onSighup(handler) {
process.on("SIGHUP", handler)
return () => process.off("SIGHUP", handler)
},
writeStdout: (text) => process.stdout.write(text),
writeStderr: (text) => process.stderr.write(text),
},
formatError: () => undefined,
formatUnknownError(error) {
if (error instanceof Error) return error.message
return String(error)
},
}
}

const platform: TuiPlatform = {
files: {
readText: (file) => Bun.file(file).text(),
readBytes: async (file) => new Uint8Array(await Bun.file(file).arrayBuffer()),
async mime(file) {
return Bun.file(file).type || "application/octet-stream"
},
},
}).pipe(Effect.provide(Global.defaultLayer))
}

const legacyDefaults: Record<string, unknown> = {
Expand Down
2 changes: 0 additions & 2 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
Expand All @@ -112,7 +111,6 @@
"ai-gateway-provider": "3.1.2",
"bonjour-service": "1.3.0",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"cross-spawn": "catalog:",
"decimal.js": "10.5.0",
"diff": "catalog:",
Expand Down
83 changes: 35 additions & 48 deletions packages/opencode/src/cli/cmd/attach.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { cmd } from "./cmd"
import { UI } from "@/cli/ui"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { resolveTuiRuntime } from "../tui/runtime"

export const AttachCommand = cmd({
command: "attach <url>",
Expand Down Expand Up @@ -46,65 +44,54 @@ export const AttachCommand = cmd({
}),
handler: async (args) => {
const { TuiConfig } = await import("@/config/tui")
const unguard = win32InstallCtrlCGuard()
try {
win32DisableProcessedInput()

if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}

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 headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
const runtime = resolveTuiRuntime(config)
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}

const directory = (() => {
if (!args.dir) return undefined
try {
await validateSession({
url: args.url,
sessionID: args.session,
directory,
headers,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
process.chdir(args.dir)
return process.cwd()
} catch {
// If the directory doesn't exist locally (remote attach), pass it through.
return args.dir
}
})()
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 { createTuiRenderer, tui } = await import("@opencode-ai/tui")
const { createLegacyTuiHost } = await import("../tui/host")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
const renderer = await createTuiRenderer(config, runtime)
const handle = tui({
...runtime,
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
await Effect.runPromise(
run({
url: args.url,
config,
host: createLegacyTuiHost(renderer),
pluginHost: createLegacyTuiPluginHost(),
renderer,
args: {
continue: args.continue,
sessionID: args.session,
fork: args.fork,
},
directory,
headers,
})
await handle.done
} finally {
unguard?.()
}
}),
)
},
})
73 changes: 33 additions & 40 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network
import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "../tui/win32"
import { writeHeapSnapshot } from "v8"
import {
OPENCODE_PROCESS_ROLE,
Expand All @@ -20,7 +19,7 @@ import {
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "../tui/validate-session"
import { resolveTuiRuntime } from "../tui/runtime"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"

declare global {
const OPENCODE_WORKER_PATH: string
Expand Down Expand Up @@ -113,15 +112,9 @@ export const TuiThreadCommand = cmd({
describe: "agent to use",
}),
handler: async (args) => {
const { TuiConfig } = await import("@/config/tui")
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
// (Important when running under `bun run` wrappers on Windows.)
const unguard = win32InstallCtrlCGuard()
try {
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
// spawn or async work so the OS cannot kill the process group.
win32DisableProcessedInput()

const { TuiConfig } = await import("@/config/tui")
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
Expand Down Expand Up @@ -189,7 +182,6 @@ export const TuiThreadCommand = cmd({

const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const runtime = resolveTuiRuntime(config)

const network = resolveNetworkOptionsNoConfig(args)
const external =
Expand Down Expand Up @@ -230,42 +222,43 @@ export const TuiThreadCommand = cmd({
}, 1000).unref?.()

try {
const { createTuiRenderer, tui } = await import("@opencode-ai/tui")
const { createLegacyTuiHost } = await import("../tui/host")
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
const renderer = await createTuiRenderer(config, runtime)
const handle = tui({
...runtime,
url: transport.url,
renderer,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
host: createLegacyTuiHost(renderer),
pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
})
await handle.done
await Effect.runPromise(
run({
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
}),
)
} finally {
await stop()
}
process.exit(0)
} finally {
unguard?.()
try {
unguard?.()
} catch (error) {
Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
}
}
process.exit(0)
},
})
// scratch
Loading
Loading