summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-02-12 15:39:31 +1000
committerGitHub <[email protected]>2026-02-12 15:39:31 +1000
commit8f9742d9886b4bfb5ac36a49810b7533985487ad (patch)
tree485a8ad4a80da4f44395a75e608cbb17fda58bcd
parentf6e7aefa728585832b6ac737c0fb2bc97461dc16 (diff)
downloadopencode-8f9742d9886b4bfb5ac36a49810b7533985487ad.tar.gz
opencode-8f9742d9886b4bfb5ac36a49810b7533985487ad.zip
fix(win32): use ffi to get around bun raw input/ctrl+c issues (#13052)
-rw-r--r--packages/opencode/src/cli/cmd/tui/app.tsx13
-rw-r--r--packages/opencode/src/cli/cmd/tui/attach.ts52
-rw-r--r--packages/opencode/src/cli/cmd/tui/context/exit.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts183
-rw-r--r--packages/opencode/src/cli/cmd/tui/win32.ts129
5 files changed, 271 insertions, 108 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 0d5aefe7b..dbad3f699 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, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
@@ -110,8 +111,17 @@ export function tui(input: {
}) {
// promise to prevent immediate exit
return new Promise<void>(async (resolve) => {
+ const unguard = win32InstallCtrlCGuard()
+ win32DisableProcessedInput()
+
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 () => {
+ unguard?.()
await input.onExit?.()
resolve()
}
@@ -730,7 +740,8 @@ function ErrorComponent(props: {
const handleExit = async () => {
renderer.setTerminalTitle("")
renderer.destroy()
- props.onExit()
+ win32FlushInputBuffer()
+ await props.onExit()
}
useKeyboard((evt) => {
diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts
index e852cb73d..8b8979c83 100644
--- a/packages/opencode/src/cli/cmd/tui/attach.ts
+++ b/packages/opencode/src/cli/cmd/tui/attach.ts
@@ -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?.()
+ }
},
})
diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx
index 2aac15220..3eb2edf72 100644
--- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx
@@ -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)
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 2ea49ff6b..6d41fe857 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, win32InstallCtrlCGuard } from "./win32"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -77,99 +78,111 @@ export const TuiThreadCommand = cmd({
describe: "agent to use",
}),
handler: async (args) => {
- 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
- })
+ // Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
+ // (Important when running under `bun run` wrappers on Windows.)
+ 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()
- 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)
- })
+ if (args.fork && !args.continue && !args.session) {
+ UI.error("--fork requires --continue or --session")
+ process.exitCode = 1
+ return
+ }
- 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
- })
+ // 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
+ }
- // 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"
+ 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)
+ })
- let url: string
- let customFetch: typeof fetch | undefined
- let events: EventSource | 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
+ })
- 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)
- }
+ // 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"
- 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)
- },
- })
+ 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)
+ }
- setTimeout(() => {
- client.call("checkUpgrade", { directory: cwd }).catch(() => {})
- }, 1000)
+ 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)
+ },
+ })
- await tuiPromise
+ setTimeout(() => {
+ client.call("checkUpgrade", { directory: cwd }).catch(() => {})
+ }, 1000)
+
+ await tuiPromise
+ } finally {
+ unguard?.()
+ }
},
})
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 000000000..23e9f4485
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/win32.ts
@@ -0,0 +1,129 @@
+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" },
+ FlushConsoleInputBuffer: { args: ["ptr"], returns: "i32" },
+ })
+
+let k32: ReturnType<typeof kernel> | 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)
+}
+
+/**
+ * Discard any queued console input (mouse events, key presses, etc.).
+ */
+export function win32FlushInputBuffer() {
+ if (process.platform !== "win32") return
+ if (!process.stdin.isTTY) return
+ if (!load()) return
+
+ const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
+ k32!.symbols.FlushConsoleInputBuffer(handle)
+}
+
+let unhook: (() => void) | undefined
+
+/**
+ * Keep ENABLE_PROCESSED_INPUT disabled.
+ *
+ * On Windows, Ctrl+C becomes a CTRL_C_EVENT (instead of stdin input) when
+ * ENABLE_PROCESSED_INPUT is set. Various runtimes can re-apply console modes
+ * (sometimes on a later tick), and the flag is console-global, not per-process.
+ *
+ * We combine:
+ * - A `setRawMode(...)` hook to re-clear after known raw-mode toggles.
+ * - A low-frequency poll as a backstop for native/external mode changes.
+ */
+export function win32InstallCtrlCGuard() {
+ if (process.platform !== "win32") return
+ if (!process.stdin.isTTY) return
+ if (!load()) return
+ if (unhook) return unhook
+
+ const stdin = process.stdin as any
+ const original = stdin.setRawMode
+
+ 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]!
+ if ((mode & ENABLE_PROCESSED_INPUT) === 0) return
+ k32!.symbols.SetConsoleMode(handle, mode & ~ENABLE_PROCESSED_INPUT)
+ }
+
+ // Some runtimes can re-apply console modes on the next tick; enforce twice.
+ const later = () => {
+ enforce()
+ setImmediate(enforce)
+ }
+
+ let wrapped: ((mode: boolean) => unknown) | undefined
+
+ if (typeof original === "function") {
+ 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 (wrapped && stdin.setRawMode === wrapped) {
+ stdin.setRawMode = original
+ }
+
+ k32!.symbols.SetConsoleMode(handle, initial)
+ unhook = undefined
+ }
+
+ return unhook
+}