mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): restore terminal modes when the process exits without stop()
Registers a process exit fallback that synchronously writes mouse, focus, bracketed paste, Kitty keyboard, and alternate-screen resets. closes #11170
This commit is contained in:
parent
2ed3118b00
commit
9df819de39
7 changed files with 108 additions and 0 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
- Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it. A block-math token no longer spans a GFM table, and the streaming pending-math fallback no longer treats a following table as pending math ([#11159](https://github.com/earendil-works/pi/issues/11159))
|
||||
- Fixed Markdown bold and italic not rendering next to CJK punctuation ([#10730](https://github.com/earendil-works/pi/pull/10730) by [@theBucky](https://github.com/theBucky))
|
||||
- Fixed terminal modes (mouse tracking, focus reporting, Kitty keyboard protocol, bracketed paste, alternate screen) staying enabled when the process exits without calling `stop()`. A process `exit` handler now restores them synchronously ([#11170](https://github.com/earendil-works/pi/issues/11170))
|
||||
|
||||
## [1.3.0] - 2026-10-15
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ export interface Terminal {
|
|||
// Stop the terminal and restore state
|
||||
stop(): void;
|
||||
|
||||
/**
|
||||
* Synchronously restore terminal modes from a process `exit` handler when stop() did not run.
|
||||
* Writes `before`, then this terminal's own mode resets, then `after`.
|
||||
*/
|
||||
restoreOnExit?(before: string, after: string): void;
|
||||
|
||||
/**
|
||||
* Drain stdin before exiting to prevent Kitty key release events from
|
||||
* leaking to the parent shell over slow SSH connections.
|
||||
|
|
@ -518,6 +524,21 @@ export class ProcessTerminal implements Terminal {
|
|||
}
|
||||
}
|
||||
|
||||
restoreOnExit(before: string, after: string): void {
|
||||
let sequence = before;
|
||||
if (this.progressInterval) sequence += TERMINAL_PROGRESS_CLEAR_SEQUENCE;
|
||||
sequence += "\x1b[?2004l";
|
||||
if (this.keyboardProtocolPushed || this._kittyProtocolActive) sequence += "\x1b[<u";
|
||||
if (this._modifyOtherKeysActive) sequence += "\x1b[>4;0m";
|
||||
sequence += after;
|
||||
try {
|
||||
// process.stdout.write() may be asynchronous (e.g. Windows TTYs) and would not flush during exit.
|
||||
fs.writeSync(process.stdout.fd, sequence);
|
||||
} catch {
|
||||
// The terminal may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
process.stdout.write(data);
|
||||
if (this.writeLogPath) {
|
||||
|
|
|
|||
|
|
@ -427,6 +427,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
}
|
||||
}
|
||||
|
||||
protected override getExitRestorePrefix(): string {
|
||||
const prefix = super.getExitRestorePrefix();
|
||||
if (!this.altScreenActive) return prefix;
|
||||
return `${END_SYNCHRONIZED_OUTPUT}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${prefix}`;
|
||||
}
|
||||
|
||||
protected override getExitRestoreSuffix(): string {
|
||||
// Kitty keyboard flags are per screen, so the terminal pops them before the alternate screen is left.
|
||||
return `${super.getExitRestoreSuffix()}${this.altScreenActive ? `${EXIT_ALT_SCREEN}\x1b[?25h` : ""}`;
|
||||
}
|
||||
|
||||
private deleteKittyImages(): string {
|
||||
return this.imageProtocol === "kitty" ? deleteAllKittyImages() : "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -561,6 +561,29 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
|
||||
protected afterTerminalStop(_options: TuiStopOptions): void {}
|
||||
|
||||
/** Mode resets written before the terminal's own resets when the process exits without stop(). */
|
||||
protected getExitRestorePrefix(): string {
|
||||
return `${this.terminalColorSchemeNotificationsEnabled ? "\x1b[?2031l" : ""}\x1b[?25h`;
|
||||
}
|
||||
|
||||
/** Mode resets written after the terminal's own resets when the process exits without stop(). */
|
||||
protected getExitRestoreSuffix(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort restore for exit paths that skip stop(), e.g. a direct process.exit() (#11170).
|
||||
* Hard kills and native aborts run no JavaScript and cannot be covered.
|
||||
*/
|
||||
private readonly exitRestoreHandler = (): void => {
|
||||
if (this.stopped) return;
|
||||
try {
|
||||
this.terminal.restoreOnExit?.(this.getExitRestorePrefix(), this.getExitRestoreSuffix());
|
||||
} catch {
|
||||
// Never throw from an exit handler.
|
||||
}
|
||||
};
|
||||
|
||||
get fullRedraws(): number {
|
||||
return this.fullRedrawCount;
|
||||
}
|
||||
|
|
@ -926,6 +949,8 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
process.off("exit", this.exitRestoreHandler);
|
||||
process.on("exit", this.exitRestoreHandler);
|
||||
this.beforeTerminalStart();
|
||||
this.terminal.start(
|
||||
(data) => this.handleTerminalInput(data),
|
||||
|
|
@ -980,6 +1005,7 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
|
||||
stop(options: TuiStopOptions = {}): void {
|
||||
this.stopped = true;
|
||||
process.off("exit", this.exitRestoreHandler);
|
||||
this.cancelRenderTimer();
|
||||
if (this.terminalColorSchemeNotificationsEnabled) {
|
||||
this.terminal.write("\x1b[?2031l");
|
||||
|
|
|
|||
41
packages/tui/test/exit-restore.test.ts
Normal file
41
packages/tui/test/exit-restore.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import assert from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const fixture = fileURLToPath(new URL("./fixtures/exit-without-stop.ts", import.meta.url));
|
||||
|
||||
function runFixture(mode: "exit" | "stop"): string {
|
||||
const result = spawnSync(process.execPath, [fixture, mode], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, TMUX: undefined, STY: undefined, ZELLIJ: undefined, TERM: "xterm-256color" },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 10_000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function count(haystack: string, needle: string): number {
|
||||
return haystack.split(needle).length - 1;
|
||||
}
|
||||
|
||||
// Regression for #11170: exit paths that skip stop() left mouse tracking, focus reporting,
|
||||
// the Kitty keyboard protocol, bracketed paste, and the alternate screen enabled.
|
||||
describe("terminal restore on process exit", () => {
|
||||
it("restores terminal modes when the process exits without stop()", () => {
|
||||
const output = runFixture("exit");
|
||||
for (const sequence of ["\x1b[?1006l", "\x1b[?1004l", "\x1b[?1003l", "\x1b[?1000l", "\x1b[<u", "\x1b[?2004l"]) {
|
||||
assert.equal(count(output, sequence), 1, JSON.stringify(sequence));
|
||||
}
|
||||
assert.ok(output.indexOf("\x1b[<u") < output.indexOf("\x1b[?1049l"), "Kitty pop must precede leaving alt screen");
|
||||
assert.ok(output.endsWith("\x1b[?1049l\x1b[?25h"));
|
||||
});
|
||||
|
||||
it("does not restore twice after a normal stop()", () => {
|
||||
const output = runFixture("stop");
|
||||
for (const sequence of ["\x1b[?1006l", "\x1b[?1004l", "\x1b[<u", "\x1b[?2004l", "\x1b[?1049l"]) {
|
||||
assert.equal(count(output, sequence), 1, JSON.stringify(sequence));
|
||||
}
|
||||
});
|
||||
});
|
||||
7
packages/tui/test/fixtures/exit-without-stop.ts
vendored
Normal file
7
packages/tui/test/fixtures/exit-without-stop.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ProcessTerminal } from "../../src/terminal.ts";
|
||||
import { TuiAltScreen } from "../../src/tui-alt-screen.ts";
|
||||
|
||||
const tui = new TuiAltScreen(new ProcessTerminal(), false, undefined, { mouse: true });
|
||||
tui.start();
|
||||
if (process.argv[2] === "stop") tui.stop({ preserveScreen: true });
|
||||
process.exit(0);
|
||||
Loading…
Add table
Add a link
Reference in a new issue