From db3432ac3e34e9172b70901d97677666e463abad Mon Sep 17 00:00:00 2001 From: Reflex Date: Fri, 19 Jun 2026 08:08:41 +0000 Subject: [PATCH 1/2] fix(cli): only exit alternate screen buffer on SIGINT if it was entered The global SIGINT handler unconditionally sent \x1b[?1049l even when no TUI was active (e.g. `rli d ssh` waiting for a provisioning devbox). Terminals restore their saved cursor position on that sequence, so Ctrl+C during a plain-text wait loop jumped the cursor to the top of the screen and left output garbled. Track whether the alternate screen buffer is actually active in screen.ts and guard the SIGINT handler so it only exits the buffer when needed. Co-Authored-By: Claude Sonnet 4.6 --- src/cli.ts | 14 +++++++++++--- src/utils/screen.ts | 9 +++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index cc2fe9fe..45e06b7e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,14 +1,22 @@ #!/usr/bin/env node -import { exitAlternateScreenBuffer } from "./utils/screen.js"; +import { + exitAlternateScreenBuffer, + isInAlternateScreenBuffer, +} from "./utils/screen.js"; import { processUtils } from "./utils/processUtils.js"; import { createProgram } from "./utils/commands.js"; import { getApiKeyErrorMessage, checkBaseDomain } from "./utils/config.js"; // Global Ctrl+C handler to ensure it always exits processUtils.on("SIGINT", () => { - // Force exit immediately, clearing alternate screen buffer - exitAlternateScreenBuffer(); + // Only restore the alternate screen buffer if we actually entered it. + // Unconditionally sending the exit sequence when no TUI is active causes + // the terminal to restore a stale saved-cursor position, jumping the + // cursor and garbling any plain-text output (e.g. `rli d ssh` wait loop). + if (isInAlternateScreenBuffer()) { + exitAlternateScreenBuffer(); + } processUtils.stdout.write("\n"); processUtils.exit(130); // Standard exit code for SIGINT }); diff --git a/src/utils/screen.ts b/src/utils/screen.ts index e89a93d5..18cae86f 100644 --- a/src/utils/screen.ts +++ b/src/utils/screen.ts @@ -9,12 +9,20 @@ import { processUtils } from "./processUtils.js"; +let _inAlternateScreenBuffer = false; + +/** Returns true if the alternate screen buffer is currently active. */ +export function isInAlternateScreenBuffer(): boolean { + return _inAlternateScreenBuffer; +} + /** * Enter the alternate screen buffer. * This provides a fullscreen experience where content won't mix with * previous terminal output. Like vim or top. */ export function enterAlternateScreenBuffer(): void { + _inAlternateScreenBuffer = true; processUtils.stdout.write("\x1b[?1049h"); } @@ -23,6 +31,7 @@ export function enterAlternateScreenBuffer(): void { * This returns the terminal to its original state before enterAlternateScreen() was called. */ export function exitAlternateScreenBuffer(): void { + _inAlternateScreenBuffer = false; processUtils.stdout.write("\x1b[?1049l"); } From ac87f8b219759e4fba305909cc1dc7d3ff702967 Mon Sep 17 00:00:00 2001 From: Reflex Date: Thu, 2 Jul 2026 15:30:12 +0000 Subject: [PATCH 2/2] test(cli): cover alternate screen buffer tracking; migrate benchmark-job watch to shared helpers Add a test verifying isInAlternateScreenBuffer() transitions false -> true -> false around enter/exit. Migrate benchmark-job watch off its own raw escape sequences to the shared screen helpers so isInAlternateScreenBuffer() correctly reports the alt-screen state, letting the global SIGINT handler restore the screen. Co-Authored-By: Claude Opus 4.8 --- src/commands/benchmark-job/watch.ts | 24 +++++++++--------- tests/__tests__/utils/screen.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 tests/__tests__/utils/screen.test.ts diff --git a/src/commands/benchmark-job/watch.ts b/src/commands/benchmark-job/watch.ts index 307f0433..6de79393 100644 --- a/src/commands/benchmark-job/watch.ts +++ b/src/commands/benchmark-job/watch.ts @@ -9,6 +9,13 @@ import { type BenchmarkJob, } from "../../services/benchmarkJobService.js"; import { outputError } from "../../utils/output.js"; +import { + enterAlternateScreenBuffer, + exitAlternateScreenBuffer, + hideCursor, + showCursor, + clearScreen, +} from "../../utils/screen.js"; import { isJobCompleted, fetchAllRunsProgress, @@ -25,28 +32,23 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -// Terminal control utilities for full-screen mode +// Terminal control utilities for in-place rendering const ANSI = { - hideCursor: "\x1b[?25l", - showCursor: "\x1b[?25h", - clearScreen: "\x1b[2J", moveTo: (row: number, col: number) => `\x1b[${row};${col}H`, clearLine: "\x1b[2K", - enterAltScreen: "\x1b[?1049h", - exitAltScreen: "\x1b[?1049l", }; // Enter full-screen mode function enterFullScreen(): void { - process.stdout.write(ANSI.enterAltScreen); - process.stdout.write(ANSI.hideCursor); - process.stdout.write(ANSI.clearScreen); + enterAlternateScreenBuffer(); + hideCursor(); + clearScreen(); } // Exit full-screen mode function exitFullScreen(): void { - process.stdout.write(ANSI.showCursor); - process.stdout.write(ANSI.exitAltScreen); + showCursor(); + exitAlternateScreenBuffer(); } // Track how many lines the last render wrote so we can clear stale lines diff --git a/tests/__tests__/utils/screen.test.ts b/tests/__tests__/utils/screen.test.ts new file mode 100644 index 00000000..7749f640 --- /dev/null +++ b/tests/__tests__/utils/screen.test.ts @@ -0,0 +1,37 @@ +/** + * Tests for terminal screen buffer utilities. + */ + +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { processUtils } from "../../../src/utils/processUtils.js"; +import { + isInAlternateScreenBuffer, + enterAlternateScreenBuffer, + exitAlternateScreenBuffer, +} from "../../../src/utils/screen.js"; + +describe("alternate screen buffer tracking", () => { + let originalWrite: typeof processUtils.stdout.write; + + beforeEach(() => { + // Swallow the escape sequences so they don't leak into test output. + originalWrite = processUtils.stdout.write; + processUtils.stdout.write = () => true; + }); + + afterEach(() => { + // Leave the buffer flag in a clean state for other tests. + exitAlternateScreenBuffer(); + processUtils.stdout.write = originalWrite; + }); + + it("tracks enter and exit transitions", () => { + expect(isInAlternateScreenBuffer()).toBe(false); + + enterAlternateScreenBuffer(); + expect(isInAlternateScreenBuffer()).toBe(true); + + exitAlternateScreenBuffer(); + expect(isInAlternateScreenBuffer()).toBe(false); + }); +});