From bd8cc8153c09762d607567abeabf80e3ff37bbe8 Mon Sep 17 00:00:00 2001 From: wgu9 <145739220+wgu9@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:06:36 -0700 Subject: [PATCH] fix: reset terminal modes on tui shutdown --- packages/tui/src/util/renderer.ts | 33 ++++++++- packages/tui/test/util/renderer.test.ts | 90 +++++++++++++++++++------ 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/packages/tui/src/util/renderer.ts b/packages/tui/src/util/renderer.ts index a0c16faa7bf5..07669de1febc 100644 --- a/packages/tui/src/util/renderer.ts +++ b/packages/tui/src/util/renderer.ts @@ -1,7 +1,38 @@ import type { CliRenderer } from "@opentui/core" -export function destroyRenderer(renderer: Pick) { +export const TERMINAL_MODE_RESETS = [ + { name: "basicMouseTracking", sequence: "\x1b[?1000l" }, + { name: "mouseButtonEventTracking", sequence: "\x1b[?1002l" }, + { name: "mouseAnyEventTracking", sequence: "\x1b[?1003l" }, + { name: "utf8MouseMode", sequence: "\x1b[?1005l" }, + { name: "sgrMouseMode", sequence: "\x1b[?1006l" }, + { name: "urxvtMouseMode", sequence: "\x1b[?1015l" }, + { name: "focusEventTracking", sequence: "\x1b[?1004l" }, + { name: "bracketedPaste", sequence: "\x1b[?2004l" }, + { name: "alternateScreen", sequence: "\x1b[?1049l" }, +] as const + +export const TERMINAL_MODE_RESET_SEQUENCE = TERMINAL_MODE_RESETS.map((mode) => mode.sequence).join("") + +type TerminalCleanupWriter = { + write(data: string): unknown +} + +function resetTerminalModes(writer: TerminalCleanupWriter) { + try { + writer.write(TERMINAL_MODE_RESET_SEQUENCE) + } catch { + // Do not let a failed best-effort terminal reset block renderer teardown. + } +} + +export function destroyRenderer( + renderer: Pick, + writer: TerminalCleanupWriter = process.stdout, +) { renderer.setTerminalTitle("") + resetTerminalModes(writer) if (renderer.isDestroyed) return renderer.destroy() + resetTerminalModes(writer) } diff --git a/packages/tui/test/util/renderer.test.ts b/packages/tui/test/util/renderer.test.ts index a5c4e7b54eb8..c056a421df1a 100644 --- a/packages/tui/test/util/renderer.test.ts +++ b/packages/tui/test/util/renderer.test.ts @@ -1,30 +1,82 @@ import { expect, test } from "bun:test" -import { destroyRenderer } from "../../src/util/renderer" +import { destroyRenderer, TERMINAL_MODE_RESET_SEQUENCE, TERMINAL_MODE_RESETS } from "../../src/util/renderer" -test("clears the terminal title before destroying the renderer", () => { +test("resets terminal modes before and after destroying the renderer", () => { const calls: string[] = [] - destroyRenderer({ - isDestroyed: false, - setTerminalTitle(title) { - calls.push(`title:${title}`) + destroyRenderer( + { + isDestroyed: false, + setTerminalTitle(title) { + calls.push(`title:${title}`) + }, + destroy() { + calls.push("destroy") + }, }, - destroy() { - calls.push("destroy") + { + write(data) { + calls.push(`write:${data}`) + }, }, - }) - expect(calls).toEqual(["title:", "destroy"]) + ) + expect(calls).toEqual([ + "title:", + `write:${TERMINAL_MODE_RESET_SEQUENCE}`, + "destroy", + `write:${TERMINAL_MODE_RESET_SEQUENCE}`, + ]) }) -test("still clears the title after renderer destruction", () => { +test("still resets terminal modes after renderer destruction", () => { const calls: string[] = [] - destroyRenderer({ - isDestroyed: true, - setTerminalTitle(title) { - calls.push(`title:${title}`) + destroyRenderer( + { + isDestroyed: true, + setTerminalTitle(title) { + calls.push(`title:${title}`) + }, + destroy() { + calls.push("destroy") + }, }, - destroy() { - calls.push("destroy") + { + write(data) { + calls.push(`write:${data}`) + }, }, - }) - expect(calls).toEqual(["title:"]) + ) + expect(calls).toEqual(["title:", `write:${TERMINAL_MODE_RESET_SEQUENCE}`]) +}) + +test("still destroys the renderer when terminal reset writes fail", () => { + const calls: string[] = [] + destroyRenderer( + { + isDestroyed: false, + setTerminalTitle(title) { + calls.push(`title:${title}`) + }, + destroy() { + calls.push("destroy") + }, + }, + { + write(data) { + calls.push(`write:${data}`) + throw new Error("write failed") + }, + }, + ) + expect(calls).toEqual([ + "title:", + `write:${TERMINAL_MODE_RESET_SEQUENCE}`, + "destroy", + `write:${TERMINAL_MODE_RESET_SEQUENCE}`, + ]) +}) + +test("terminal mode reset sequences are unique and generated from one registry", () => { + expect(new Set(TERMINAL_MODE_RESETS.map((mode) => mode.name)).size).toBe(TERMINAL_MODE_RESETS.length) + expect(new Set(TERMINAL_MODE_RESETS.map((mode) => mode.sequence)).size).toBe(TERMINAL_MODE_RESETS.length) + expect(TERMINAL_MODE_RESET_SEQUENCE).toBe(TERMINAL_MODE_RESETS.map((mode) => mode.sequence).join("")) })