diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0d5aefe7bc..bec17d48ef 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -3,6 +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, win32EnforceCtrlCGuard } from "./win32" import { Installation } from "@/installation" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -110,7 +111,15 @@ export function tui(input: { }) { // promise to prevent immediate exit return new Promise(async (resolve) => { + win32DisableProcessedInput() + win32IgnoreCtrlC() + const mode = await getTerminalBackgroundColor() + + // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores + // the original console mode which re-enables ENABLE_PROCESSED_INPUT. + win32DisableProcessedInput() + const onExit = async () => { await input.onExit?.() resolve() @@ -238,6 +247,10 @@ function App() { const args = useArgs() onMount(() => { + // opentui reconfigures console mode via native calls which re-enable + // ENABLE_PROCESSED_INPUT. Poll and re-clear it so the parent `bun run` + // wrapper isn't killed by CTRL_C_EVENT. + win32EnforceCtrlCGuard() batch(() => { if (args.agent) local.agent.set(args.agent) if (args.model) { diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 2ea49ff6b2..87987a45f0 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -9,6 +9,7 @@ import { Log } from "@/util/log" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import type { Event } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" +import { win32DisableProcessedInput } from "./win32" declare global { const OPENCODE_WORKER_PATH: string @@ -77,6 +78,10 @@ export const TuiThreadCommand = cmd({ describe: "agent to use", }), handler: async (args) => { + // 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) diff --git a/packages/opencode/src/cli/cmd/tui/win32.ts b/packages/opencode/src/cli/cmd/tui/win32.ts new file mode 100644 index 0000000000..ddd8913f19 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/win32.ts @@ -0,0 +1,86 @@ +import { dlopen, ptr } from "bun:ffi" + +const STD_INPUT_HANDLE = -10 +const ENABLE_PROCESSED_INPUT = 0x0001 + +const kernel = () => + dlopen("kernel32.dll", { + GetStdHandle: { args: ["i32"], returns: "ptr" }, + GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" }, + SetConsoleMode: { args: ["ptr", "u32"], returns: "i32" }, + SetConsoleCtrlHandler: { args: ["ptr", "i32"], returns: "i32" }, + }) + +let k32: ReturnType | undefined + +function load() { + if (process.platform !== "win32") return false + try { + k32 ??= kernel() + return true + } catch { + return false + } +} + +/** + * Clear ENABLE_PROCESSED_INPUT on the console stdin handle. + */ +export function win32DisableProcessedInput() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const buf = new Uint32Array(1) + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + + const mode = buf[0]! + if ((mode & ENABLE_PROCESSED_INPUT) === 0) return + k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) +} + +/** + * 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. + */ +export function win32IgnoreCtrlC() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + + k32!.symbols.SetConsoleCtrlHandler(null, 1) +} + +/** + * Continuously enforce ENABLE_PROCESSED_INPUT=off on the console. + * + * opentui reconfigures the console mode through native calls (not + * process.stdin.setRawMode) so we cannot intercept them. Instead we + * poll at a low frequency and re-clear the flag when needed. + * + * Because ENABLE_PROCESSED_INPUT is a console-level flag (not per-process), + * keeping it cleared protects every process attached to this console, + * including the parent `bun run` wrapper that we can't otherwise control. + * + * The fast-path (GetConsoleMode + bitmask check) is sub-microsecond; + * SetConsoleMode only fires when something re-enabled the flag. + */ +export function win32EnforceCtrlCGuard() { + if (process.platform !== "win32") return + if (!process.stdin.isTTY) return + if (!load()) return + + const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) + const buf = new Uint32Array(1) + + setInterval(() => { + if (k32!.symbols.GetConsoleMode(handle, ptr(buf)) === 0) return + const mode = buf[0]! + if ((mode & ENABLE_PROCESSED_INPUT) === 0) return + k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT) + }, 100) +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91..96759cc139 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -26,6 +26,15 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { win32DisableProcessedInput, win32IgnoreCtrlC } from "./cli/cmd/tui/win32" + +// 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() process.on("unhandledRejection", (e) => { Log.Default.error("rejection", {