diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b7026681..525d1d55 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed TUI cell size response handling to consume only exact `CSI 6 ; height ; width t` replies, so bare `Escape` is no longer swallowed while waiting for terminal image metadata ([#2661](https://github.com/badlogic/pi-mono/issues/2661)) + ## [0.63.2] - 2026-03-29 ## [0.63.1] - 2026-03-27 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3fabe53d..b0e6d86d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -223,8 +223,6 @@ export class TUI extends Container { private renderRequested = false; private cursorRow = 0; // Logical cursor row (end of rendered content) private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) - private inputBuffer = ""; // Buffer for parsing terminal responses - private cellSizeQueryPending = false; private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) @@ -439,7 +437,6 @@ export class TUI extends Container { } // Query terminal for cell size in pixels: CSI 16 t // Response format: CSI 6 ; height ; width t - this.cellSizeQueryPending = true; this.terminal.write("\x1b[16t"); } @@ -497,12 +494,9 @@ export class TUI extends Container { data = current; } - // If we're waiting for cell size response, buffer input and parse - if (this.cellSizeQueryPending) { - this.inputBuffer += data; - const filtered = this.parseCellSizeResponse(); - if (filtered.length === 0) return; - data = filtered; + // Consume terminal cell size responses without blocking unrelated input. + if (this.consumeCellSizeResponse(data)) { + return; } // Global debug key handler (Shift+Ctrl+D) @@ -537,46 +531,24 @@ export class TUI extends Container { } } - private parseCellSizeResponse(): string { + private consumeCellSizeResponse(data: string): boolean { // Response format: ESC [ 6 ; height ; width t - // Match the response pattern - const responsePattern = /\x1b\[6;(\d+);(\d+)t/; - const match = this.inputBuffer.match(responsePattern); - - if (match) { - const heightPx = parseInt(match[1], 10); - const widthPx = parseInt(match[2], 10); - - if (heightPx > 0 && widthPx > 0) { - setCellDimensions({ widthPx, heightPx }); - // Invalidate all components so images re-render with correct dimensions - this.invalidate(); - this.requestRender(); - } - - // Remove the response from buffer - this.inputBuffer = this.inputBuffer.replace(responsePattern, ""); - this.cellSizeQueryPending = false; + const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); + if (!match) { + return false; } - // Check if we have a partial cell size response starting (wait for more data) - // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet) - const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/; - if (partialCellSizePattern.test(this.inputBuffer)) { - // Check if it's actually a complete different escape sequence (ends with a letter) - // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc. - const lastChar = this.inputBuffer[this.inputBuffer.length - 1]; - if (!/[a-zA-Z~]/.test(lastChar)) { - // Doesn't end with a terminator, might be incomplete - wait for more - return ""; - } + const heightPx = parseInt(match[1], 10); + const widthPx = parseInt(match[2], 10); + if (heightPx <= 0 || widthPx <= 0) { + return true; } - // No cell size response found, return buffered data as user input - const result = this.inputBuffer; - this.inputBuffer = ""; - this.cellSizeQueryPending = false; // Give up waiting - return result; + setCellDimensions({ widthPx, heightPx }); + // Invalidate all components so images re-render with correct dimensions. + this.invalidate(); + this.requestRender(); + return true; } /** diff --git a/packages/tui/test/tui-cell-size-input.test.ts b/packages/tui/test/tui-cell-size-input.test.ts new file mode 100644 index 00000000..0ca515d4 --- /dev/null +++ b/packages/tui/test/tui-cell-size-input.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.js"; +import { type Component, TUI } from "../src/tui.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; + +class InputRecorder implements Component { + readonly inputs: string[] = []; + + render(): string[] { + return [""]; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + invalidate(): void {} +} + +function withImageTerminal(fn: () => T): T { + const prevTermProgram = process.env.TERM_PROGRAM; + const prevTerm = process.env.TERM; + const prevGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR; + + process.env.TERM_PROGRAM = "ghostty"; + delete process.env.TERM; + delete process.env.GHOSTTY_RESOURCES_DIR; + resetCapabilitiesCache(); + + try { + return fn(); + } finally { + if (prevTermProgram === undefined) delete process.env.TERM_PROGRAM; + else process.env.TERM_PROGRAM = prevTermProgram; + if (prevTerm === undefined) delete process.env.TERM; + else process.env.TERM = prevTerm; + if (prevGhosttyResourcesDir === undefined) delete process.env.GHOSTTY_RESOURCES_DIR; + else process.env.GHOSTTY_RESOURCES_DIR = prevGhosttyResourcesDir; + resetCapabilitiesCache(); + } +} + +describe("TUI cell size responses", () => { + it("forwards bare escape even when a cell size query was sent at startup", () => { + withImageTerminal(() => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const recorder = new InputRecorder(); + + tui.setFocus(recorder); + tui.start(); + + terminal.sendInput("\x1b"); + + assert.deepStrictEqual(recorder.inputs, ["\x1b"]); + tui.stop(); + }); + }); + + it("consumes cell size responses and still forwards later user input", () => { + withImageTerminal(() => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const recorder = new InputRecorder(); + + tui.setFocus(recorder); + tui.start(); + + terminal.sendInput("\x1b[6;20;10t"); + assert.deepStrictEqual(recorder.inputs, []); + assert.deepStrictEqual(getCellDimensions(), { widthPx: 10, heightPx: 20 }); + + terminal.sendInput("q"); + assert.deepStrictEqual(recorder.inputs, ["q"]); + tui.stop(); + }); + }); +});