fix(win32): use ffi to get around bun raw input/ctrl+c issues (#13052)
This commit is contained in:
@@ -3,6 +3,7 @@ import { Clipboard } from "@tui/util/clipboard"
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
|
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 { Installation } from "@/installation"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||||
@@ -110,8 +111,17 @@ export function tui(input: {
|
|||||||
}) {
|
}) {
|
||||||
// promise to prevent immediate exit
|
// promise to prevent immediate exit
|
||||||
return new Promise<void>(async (resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
|
const unguard = win32InstallCtrlCGuard()
|
||||||
|
win32DisableProcessedInput()
|
||||||
|
|
||||||
const mode = await getTerminalBackgroundColor()
|
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 () => {
|
const onExit = async () => {
|
||||||
|
unguard?.()
|
||||||
await input.onExit?.()
|
await input.onExit?.()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
@@ -730,7 +740,8 @@ function ErrorComponent(props: {
|
|||||||
const handleExit = async () => {
|
const handleExit = async () => {
|
||||||
renderer.setTerminalTitle("")
|
renderer.setTerminalTitle("")
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
props.onExit()
|
win32FlushInputBuffer()
|
||||||
|
await props.onExit()
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { tui } from "./app"
|
import { tui } from "./app"
|
||||||
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
|
|
||||||
export const AttachCommand = cmd({
|
export const AttachCommand = cmd({
|
||||||
command: "attach <url>",
|
command: "attach <url>",
|
||||||
@@ -26,27 +27,34 @@ export const AttachCommand = cmd({
|
|||||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||||
}),
|
}),
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const directory = (() => {
|
const unguard = win32InstallCtrlCGuard()
|
||||||
if (!args.dir) return undefined
|
try {
|
||||||
try {
|
win32DisableProcessedInput()
|
||||||
process.chdir(args.dir)
|
|
||||||
return process.cwd()
|
const directory = (() => {
|
||||||
} catch {
|
if (!args.dir) return undefined
|
||||||
// If the directory doesn't exist locally (remote attach), pass it through.
|
try {
|
||||||
return args.dir
|
process.chdir(args.dir)
|
||||||
}
|
return process.cwd()
|
||||||
})()
|
} catch {
|
||||||
const headers = (() => {
|
// If the directory doesn't exist locally (remote attach), pass it through.
|
||||||
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
return args.dir
|
||||||
if (!password) return undefined
|
}
|
||||||
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
})()
|
||||||
return { Authorization: auth }
|
const headers = (() => {
|
||||||
})()
|
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
|
||||||
await tui({
|
if (!password) return undefined
|
||||||
url: args.url,
|
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||||
args: { sessionID: args.session },
|
return { Authorization: auth }
|
||||||
directory,
|
})()
|
||||||
headers,
|
await tui({
|
||||||
})
|
url: args.url,
|
||||||
|
args: { sessionID: args.session },
|
||||||
|
directory,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
unguard?.()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRenderer } from "@opentui/solid"
|
import { useRenderer } from "@opentui/solid"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
import { win32FlushInputBuffer } from "../win32"
|
||||||
type Exit = ((reason?: unknown) => Promise<void>) & {
|
type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||||
message: {
|
message: {
|
||||||
set: (value?: string) => () => void
|
set: (value?: string) => () => void
|
||||||
@@ -32,6 +33,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||||||
// Reset window title before destroying renderer
|
// Reset window title before destroying renderer
|
||||||
renderer.setTerminalTitle("")
|
renderer.setTerminalTitle("")
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
|
win32FlushInputBuffer()
|
||||||
await input.onExit?.()
|
await input.onExit?.()
|
||||||
if (reason) {
|
if (reason) {
|
||||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Log } from "@/util/log"
|
|||||||
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
|
||||||
import type { Event } from "@opencode-ai/sdk/v2"
|
import type { Event } from "@opencode-ai/sdk/v2"
|
||||||
import type { EventSource } from "./context/sdk"
|
import type { EventSource } from "./context/sdk"
|
||||||
|
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
const OPENCODE_WORKER_PATH: string
|
const OPENCODE_WORKER_PATH: string
|
||||||
@@ -77,99 +78,111 @@ export const TuiThreadCommand = cmd({
|
|||||||
describe: "agent to use",
|
describe: "agent to use",
|
||||||
}),
|
}),
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
if (args.fork && !args.continue && !args.session) {
|
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||||
UI.error("--fork requires --continue or --session")
|
// (Important when running under `bun run` wrappers on Windows.)
|
||||||
process.exit(1)
|
const unguard = win32InstallCtrlCGuard()
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
process.chdir(cwd)
|
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
|
||||||
} catch (e) {
|
// spawn or async work so the OS cannot kill the process group.
|
||||||
UI.error("Failed to change directory to " + cwd)
|
win32DisableProcessedInput()
|
||||||
return
|
|
||||||
|
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
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
129
packages/opencode/src/cli/cmd/tui/win32.ts
Normal file
129
packages/opencode/src/cli/cmd/tui/win32.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user