Skip to content

Commit d3a8268

Browse files
committed
feat(ui): always restore the terminal on exit, crash, or signal
If the React tree throws or the process is killed with raw mode still on, the user gets a dead shell — no cursor, no echo, attributes glued on. Install exit/SIGINT/SIGTERM/SIGHUP/uncaughtException/ unhandledRejection handlers that unmount Ink, write the reset escape, and disable raw mode. Production TUIs treat this as table-stakes.
1 parent 84bf18d commit d3a8268

2 files changed

Lines changed: 85 additions & 0 deletions

File tree

src/cli.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { loadDotEnv } from "./dotenv/loader.js";
77
import { type HeadlessOutputFormat, runHeadless } from "./headless/run.js";
88
import { runProjectSubcommand } from "./projects/cli.js";
99
import { App } from "./ui/App.js";
10+
import { installTerminalRestoreHandlers } from "./ui/terminal-restore.js";
1011

1112
// Auto-load .env files before any subsystem reads process.env.
1213
loadDotEnv();
@@ -58,6 +59,7 @@ if (argv[0] === "--version" || argv[0] === "-v") {
5859
runHeadless({ prompt, outputFormat, autoApprove }).then((code) => process.exit(code));
5960
} else {
6061
const instance = render(<App />);
62+
installTerminalRestoreHandlers(instance);
6163
instance.waitUntilExit().catch(() => {
6264
process.exit(1);
6365
});

src/ui/terminal-restore.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { Instance } from "ink";
2+
3+
/**
4+
* Restore the terminal to a sane state on exit, including the unhappy
5+
* paths Ink doesn't catch — uncaught exceptions, SIGINT, SIGTERM, and
6+
* any unmount that happens with raw mode still engaged.
7+
*
8+
* Without this, a thrown error inside the React tree (or a kill -9 to
9+
* a parent shell) leaves the terminal in raw mode with the cursor
10+
* hidden and ANSI attributes still set, and the user has to type
11+
* `stty sane` blind to recover. Production-grade TUIs always install
12+
* these handlers; this is table-stakes hygiene.
13+
*/
14+
15+
const RESET_SEQUENCE =
16+
// Show cursor.
17+
"\x1b[?25h" +
18+
// Reset all SGR attributes (color, bold, italic, etc.).
19+
"\x1b[0m" +
20+
// Disable bracketed paste mode if we ever enabled it.
21+
"\x1b[?2004l";
22+
23+
let installed = false;
24+
25+
export function installTerminalRestoreHandlers(instance?: Instance): void {
26+
if (installed) return;
27+
installed = true;
28+
29+
let restored = false;
30+
const restore = (): void => {
31+
if (restored) return;
32+
restored = true;
33+
try {
34+
instance?.unmount();
35+
} catch {
36+
// unmount can throw if Ink is already torn down; ignore.
37+
}
38+
try {
39+
// Belt and suspenders: write the reset directly to the TTY in
40+
// case Ink's unmount didn't (e.g. because we got here via
41+
// uncaughtException before Ink even mounted).
42+
if (process.stdout.isTTY) process.stdout.write(RESET_SEQUENCE);
43+
} catch {
44+
// Best-effort; the process is going down regardless.
45+
}
46+
try {
47+
// If raw mode is somehow still on (Ink should disable it on
48+
// unmount, but uncaught exceptions can skip that path), turn
49+
// it off so the user gets a usable shell back.
50+
if (process.stdin.isTTY && process.stdin.setRawMode) {
51+
process.stdin.setRawMode(false);
52+
}
53+
} catch {
54+
// Ignore — stdin may already be detached.
55+
}
56+
};
57+
58+
process.once("exit", restore);
59+
process.once("SIGINT", () => {
60+
restore();
61+
// Mirror the default SIGINT exit code so parent shells see it.
62+
process.exit(130);
63+
});
64+
process.once("SIGTERM", () => {
65+
restore();
66+
process.exit(143);
67+
});
68+
process.once("SIGHUP", () => {
69+
restore();
70+
process.exit(129);
71+
});
72+
process.on("uncaughtException", (err) => {
73+
restore();
74+
process.stderr.write(`\nuncaught exception: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
75+
process.exit(1);
76+
});
77+
process.on("unhandledRejection", (reason) => {
78+
restore();
79+
const msg = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
80+
process.stderr.write(`\nunhandled rejection: ${msg}\n`);
81+
process.exit(1);
82+
});
83+
}

0 commit comments

Comments
 (0)