Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -110,7 +111,15 @@ export function tui(input: {
}) {
// promise to prevent immediate exit
return new Promise<void>(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()
Expand Down Expand Up @@ -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()

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

win32EnforceCtrlCGuard() is started on mount but never stopped. If the TUI unmounts/remounts or if tui() is used in a context that doesn’t hard-exit the process, this will accumulate intervals. Consider capturing the interval id and clearing it in an onCleanup/exit handler.

Copilot uses AI. Check for mistakes.
batch(() => {
if (args.agent) local.agent.set(args.agent)
if (args.model) {
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions packages/opencode/src/cli/cmd/tui/win32.ts
Original file line number Diff line number Diff line change
@@ -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<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)
}

/**
* 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)

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

win32IgnoreCtrlC() permanently disables Ctrl+C delivery for the process (and there’s no corresponding restore). That can leave users unable to interrupt the program and can also affect shutdown events. Consider adding a restore function (e.g. call SetConsoleCtrlHandler(NULL, FALSE)) and ensuring it runs on cleanup/exit.

Copilot uses AI. Check for mistakes.
}

/**
* 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)

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

win32EnforceCtrlCGuard() starts a polling interval but doesn’t return a handle or provide a way to stop it. Consider returning the interval id (and possibly calling .unref() if supported) so callers can clear it on exit/unmount to avoid leaking timers.

Suggested change
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)
export function win32EnforceCtrlCGuard(): ReturnType<typeof setInterval> | undefined {
if (process.platform !== "win32") return undefined
if (!process.stdin.isTTY) return undefined
if (!load()) return undefined
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
const buf = new Uint32Array(1)
const interval = 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)
// Allow the process to exit naturally even if this interval is active.
;(interval as any)?.unref?.()
return interval

Copilot uses AI. Check for mistakes.
}
9 changes: 9 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These top-level calls make the entire CLI ignore Ctrl+C on Windows TTYs (via SetConsoleCtrlHandler), which breaks interruption for non-TUI commands like opencode serve (it awaits forever). Consider scoping this to the TUI entrypoints only and/or restoring the previous Ctrl+C behavior on exit so other commands remain stoppable.

Suggested change
// 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()
// Disable Windows CTRL_C_EVENT as early as possible, but only for TUI
// entrypoints. 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. Restricting this behavior to TUI commands
// avoids breaking Ctrl+C for non-TUI commands like `opencode serve`.
if (process.platform === "win32") {
const argv = hideBin(process.argv)
const cmd = argv[0]
const sub = argv[1]
// TUI entrypoints:
// - `opencode attach`
// - `opencode tui thread`
// - (optional alias) `opencode tui-thread`
const isTuiCommand =
cmd === "attach" ||
(cmd === "tui" && sub === "thread") ||
cmd === "tui-thread"
if (isTuiCommand) {
win32DisableProcessedInput()
// Belt-and-suspenders: even if something re-enables ENABLE_PROCESSED_INPUT
// later (opentui raw mode, libuv, etc.), ignore the generated event.
win32IgnoreCtrlC()
}
}

Copilot uses AI. Check for mistakes.
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
Expand Down
Loading