fix(tui): stop swallowing escape during cell size detection closes #2661
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
81
packages/tui/test/tui-cell-size-input.test.ts
Normal file
81
packages/tui/test/tui-cell-size-input.test.ts
Normal file
@@ -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<T>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user