diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 6852311f9e84..cc0d61b12dc8 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() @@ -186,6 +186,9 @@ function isVersionGreater(left: string, right: string) { export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const global = yield* Global.Service const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } + // Ensure terminal modes are restored even when the process exits without + // going through the scoped renderer teardown (e.g. an uncaught error). + process.on("exit", terminalReset) const result = yield* Effect.scoped( Effect.gen(function* () { const renderer = yield* Effect.acquireRelease( @@ -360,7 +363,14 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n") process.exitCode = 1 } - if (result.epilogue) process.stdout.write(result.epilogue + "\n") + if (result.epilogue) { + // After leaving the alternate screen the cursor position depends on the + // terminal emulator (Apple Terminal restores it to the top-left corner), + // so the epilogue can overprint restored shell content. Start from a + // clean screen for a consistent result. + process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write(result.epilogue + "\n") + } }) }) diff --git a/packages/tui/src/util/renderer.ts b/packages/tui/src/util/renderer.ts index a0c16faa7bf5..06433bf0be3b 100644 --- a/packages/tui/src/util/renderer.ts +++ b/packages/tui/src/util/renderer.ts @@ -1,7 +1,20 @@ import type { CliRenderer } from "@opentui/core" +// opentui's own teardown leaves several terminal modes enabled (mouse +// reporting, application cursor keys, kitty keyboard, bracketed paste), +// which corrupts the shell session after the TUI exits. See issues +// #48776 and #38860. +const TERMINAL_RESET = + "\x1b[?1l\x1b[?25h\x1b[0 q\x1b[>1u\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[0m" + +export function terminalReset() { + if (!process.stdout.isTTY) return + process.stdout.write(TERMINAL_RESET) +} + export function destroyRenderer(renderer: Pick) { renderer.setTerminalTitle("") if (renderer.isDestroyed) return renderer.destroy() + terminalReset() }