From 996ebb12bb063ff0f556aaa7c34e36ff0f02cfb9 Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 14 Sep 2026 01:48:35 +0200 Subject: [PATCH] fix(tui): force terminal reset on exit for Windows ConPTY --- packages/tui/src/app.tsx | 20 ++++++++-- packages/tui/src/util/renderer.ts | 62 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 3f1da522bb06..b7219f7bea3b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -84,7 +84,7 @@ import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" import * as TuiAudio from "./audio" import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" -import { destroyRenderer } from "./util/renderer" +import { destroyRenderer, terminalReset } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" registerOpencodeSpinner() @@ -229,9 +229,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose)) const shutdown = yield* Deferred.make() const onSighup = () => destroyRenderer(renderer) + const onSigterm = () => { + destroyRenderer(renderer) + process.exit(143) + } + const onProcessExit = () => terminalReset() yield* Effect.acquireRelease( - Effect.sync(() => process.on("SIGHUP", onSighup)), - () => Effect.sync(() => process.off("SIGHUP", onSighup)), + Effect.sync(() => { + process.on("SIGHUP", onSighup) + process.on("SIGTERM", onSigterm) + process.on("exit", onProcessExit) + }), + () => + Effect.sync(() => { + process.off("SIGHUP", onSighup) + process.off("SIGTERM", onSigterm) + process.off("exit", onProcessExit) + }), ) renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void)) const pluginRuntime = createPluginRuntime() diff --git a/packages/tui/src/util/renderer.ts b/packages/tui/src/util/renderer.ts index a0c16faa7bf5..6204b080fe68 100644 --- a/packages/tui/src/util/renderer.ts +++ b/packages/tui/src/util/renderer.ts @@ -1,7 +1,69 @@ import type { CliRenderer } from "@opentui/core" +import * as fs from "node:fs" + +// Escape sequences written synchronously on exit so the terminal is left in a +// clean state even on ConPTY stacks (e.g. Alacritty + zellij) where raw-mode +// modes can otherwise leak (opencode #45938 / #48776). +// +// Note: we intentionally do NOT emit `\x1b[?1049l`. Leaving the alternate +// screen buffer on Windows ConPTY races with process teardown and terminates +// the parent shell / zellij pane, so we clear the screen instead. +const TERMINAL_RESET = [ + "\x1b[0m", + "\x1b[?25h", + "\x1b[?1l", + "\x1b[?1000l", + "\x1b[?1002l", + "\x1b[?1003l", + "\x1b[?1004l", + "\x1b[?1005l", + "\x1b[?1006l", + "\x1b[?1015l", + "\x1b[?2026l", + "\x1b[?9001l", + "\x1b[?2004l", + "\x1b[) { renderer.setTerminalTitle("") if (renderer.isDestroyed) return + // On Windows, @opentui's native renderer teardown leaves the shared console + // (ConPTY) in a state that terminates the parent shell / zellij pane. The OS + // releases the native handle when the process exits, so skip that call and + // let `terminalReset()` clear the screen and restore the terminal state. + if (process.platform === "win32") { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- @opentui/core does not expose lib on CliRenderer's public type. + const native = (renderer as Record).lib + if (native && typeof native === "object") { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Only destroyRenderer is touched, guarded by typeof below. + const opts = native as Record + if (typeof opts.destroyRenderer === "function") { + try { + opts.destroyRenderer = () => {} + } catch {} + } + } + } renderer.destroy() + terminalReset() }