This commit is contained in:
LukeParkerDev
2026-02-12 12:57:16 +10:00
parent 56dfbbbc93
commit b4a78c53c8
6 changed files with 158 additions and 142 deletions

View File

@@ -3,7 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32IgnoreCtrlC, win32InstallCtrlCGuard } from "./win32"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -113,7 +113,6 @@ export function tui(input: {
return new Promise<void>(async (resolve) => {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
win32IgnoreCtrlC()
const mode = await getTerminalBackgroundColor()
@@ -741,7 +740,8 @@ function ErrorComponent(props: {
const handleExit = async () => {
renderer.setTerminalTitle("")
renderer.destroy()
props.onExit()
win32FlushInputBuffer()
await props.onExit()
}
useKeyboard((evt) => {

View File

@@ -1,5 +1,6 @@
import { cmd } from "../cmd"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -26,27 +27,34 @@ export const AttachCommand = cmd({
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
}),
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 headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
await tui({
url: args.url,
args: { sessionID: args.session },
directory,
headers,
})
const unguard = win32InstallCtrlCGuard()
try {
win32DisableProcessedInput()
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 = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
await tui({
url: args.url,
args: { sessionID: args.session },
directory,
headers,
})
} finally {
unguard?.()
}
},
})

View File

@@ -1,6 +1,7 @@
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { win32FlushInputBuffer } from "../win32"
type Exit = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
@@ -32,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
// Reset window title before destroying renderer
renderer.setTerminalTitle("")
renderer.destroy()
win32FlushInputBuffer()
await input.onExit?.()
if (reason) {
const formatted = FormatError(reason) ?? FormatUnknownError(reason)

View File

@@ -80,105 +80,109 @@ export const TuiThreadCommand = cmd({
handler: async (args) => {
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
// (Important when running under `bun run` wrappers on Windows.)
win32InstallCtrlCGuard()
// 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()
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exit(1)
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
const localWorker = new URL("./worker.ts", import.meta.url)
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const workerPath = await iife(async () => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (await Bun.file(distWorker).exists()) return distWorker
return localWorker
})
const unguard = win32InstallCtrlCGuard()
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
// 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()
if (args.fork && !args.continue && !args.session) {
UI.error("--fork requires --continue or --session")
process.exitCode = 1
return
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
const localWorker = new URL("./worker.ts", import.meta.url)
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const workerPath = await iife(async () => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (await Bun.file(distWorker).exists()) return distWorker
return localWorker
})
try {
process.chdir(cwd)
} catch (e) {
UI.error("Failed to change directory to " + cwd)
return
}
const worker = new Worker(workerPath, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
worker.onerror = (e) => {
Log.Default.error(e)
}
const client = Rpc.client<typeof rpc>(worker)
process.on("uncaughtException", (e) => {
Log.Default.error(e)
})
process.on("unhandledRejection", (e) => {
Log.Default.error(e)
})
process.on("SIGUSR2", async () => {
await client.call("reload", undefined)
})
const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
const shouldStartServer =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
networkOpts.mdns ||
networkOpts.port !== 0 ||
networkOpts.hostname !== "127.0.0.1"
let url: string
let customFetch: typeof fetch | undefined
let events: EventSource | undefined
if (shouldStartServer) {
// Start HTTP server for external access
const server = await client.call("server", networkOpts)
url = server.url
} else {
// Use direct RPC communication (no HTTP)
url = "http://opencode.internal"
customFetch = createWorkerFetch(client)
events = createEventSource(client)
}
const tuiPromise = tui({
url,
fetch: customFetch,
events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
onExit: async () => {
await client.call("shutdown", undefined)
},
})
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000)
await tuiPromise
} finally {
unguard?.()
}
const worker = new Worker(workerPath, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
worker.onerror = (e) => {
Log.Default.error(e)
}
const client = Rpc.client<typeof rpc>(worker)
process.on("uncaughtException", (e) => {
Log.Default.error(e)
})
process.on("unhandledRejection", (e) => {
Log.Default.error(e)
})
process.on("SIGUSR2", async () => {
await client.call("reload", undefined)
})
const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
const shouldStartServer =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
networkOpts.mdns ||
networkOpts.port !== 0 ||
networkOpts.hostname !== "127.0.0.1"
let url: string
let customFetch: typeof fetch | undefined
let events: EventSource | undefined
if (shouldStartServer) {
// Start HTTP server for external access
const server = await client.call("server", networkOpts)
url = server.url
} else {
// Use direct RPC communication (no HTTP)
url = "http://opencode.internal"
customFetch = createWorkerFetch(client)
events = createEventSource(client)
}
const tuiPromise = tui({
url,
fetch: customFetch,
events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
onExit: async () => {
await client.call("shutdown", undefined)
},
})
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000)
await tuiPromise
},
})

View File

@@ -8,7 +8,7 @@ const kernel = () =>
GetStdHandle: { args: ["i32"], returns: "ptr" },
GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" },
SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" },
SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" },
FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
})
let k32: ReturnType<typeof kernel> | undefined
@@ -41,18 +41,15 @@ export function win32DisableProcessedInput() {
}
/**
* Tell Windows to ignore CTRL_C_EVENT for this process.
*
* SetConsoleCtrlHandler(NULL, TRUE) makes the process ignore Ctrl+C
* signals at the OS level. Belt-and-suspenders alongside disabling
* ENABLE_PROCESSED_INPUT.
* Discard any queued console input (mouse events, key presses, etc.).
*/
export function win32IgnoreCtrlC() {
export function win32FlushInputBuffer() {
if (process.platform !== "win32") return
if (!process.stdin.isTTY) return
if (!load()) return
k32!.symbols.SetConsoleCtrlHandler(null, 1)
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
k32!.symbols.FlushConsoleInputBuffer(handle)
}
let unhook: (() => void) | undefined
@@ -80,6 +77,9 @@ export function win32InstallCtrlCGuard() {
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const initial = buf[0]!
const enforce = () => {
if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return
const mode = buf[0]!
@@ -93,24 +93,35 @@ export function win32InstallCtrlCGuard() {
setImmediate(enforce)
}
let wrapped: ((mode: boolean) => unknown) | undefined
if (typeof original === "function") {
stdin.setRawMode = (mode: boolean) => {
wrapped = (mode: boolean) => {
const result = original.call(stdin, mode)
later()
return result
}
stdin.setRawMode = wrapped
}
// Ensure it's cleared immediately too (covers any earlier mode changes).
later()
const interval = setInterval(enforce, 100)
interval.unref()
let done = false
unhook = () => {
if (done) return
done = true
clearInterval(interval)
if (typeof original === "function") {
if (wrapped && stdin.setRawMode === wrapped) {
stdin.setRawMode = original
}
k32!.symbols.SetConsoleMode(handle, initial)
unhook = undefined
}

View File

@@ -23,7 +23,6 @@ import { AttachCommand } from "./cli/cmd/tui/attach"
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
import { win32DisableProcessedInput, win32IgnoreCtrlC } from "./cli/cmd/tui/win32"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
@@ -40,14 +39,6 @@ process.on("uncaughtException", (e) => {
})
})
// Disable Windows CTRL_C_EVENT as early as possible. When running under
// `bun run` (e.g. `bun dev`), the parent bun process shares this console
// and would be killed by the OS before any JS signal handler fires.
win32DisableProcessedInput()
// Belt-and-suspenders: even if something re-enables ENABLE_PROCESSED_INPUT
// later (opentui raw mode, libuv, etc.), ignore the generated event.
win32IgnoreCtrlC()
const cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")