fix(tui): stop swallowing escape during cell size detection closes #2661
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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.2] - 2026-03-29
|
||||||
|
|
||||||
## [0.63.1] - 2026-03-27
|
## [0.63.1] - 2026-03-27
|
||||||
|
|||||||
@@ -223,8 +223,6 @@ export class TUI extends Container {
|
|||||||
private renderRequested = false;
|
private renderRequested = false;
|
||||||
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
||||||
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
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 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 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)
|
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
|
// Query terminal for cell size in pixels: CSI 16 t
|
||||||
// Response format: CSI 6 ; height ; width t
|
// Response format: CSI 6 ; height ; width t
|
||||||
this.cellSizeQueryPending = true;
|
|
||||||
this.terminal.write("\x1b[16t");
|
this.terminal.write("\x1b[16t");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,12 +494,9 @@ export class TUI extends Container {
|
|||||||
data = current;
|
data = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're waiting for cell size response, buffer input and parse
|
// Consume terminal cell size responses without blocking unrelated input.
|
||||||
if (this.cellSizeQueryPending) {
|
if (this.consumeCellSizeResponse(data)) {
|
||||||
this.inputBuffer += data;
|
return;
|
||||||
const filtered = this.parseCellSizeResponse();
|
|
||||||
if (filtered.length === 0) return;
|
|
||||||
data = filtered;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global debug key handler (Shift+Ctrl+D)
|
// 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
|
// Response format: ESC [ 6 ; height ; width t
|
||||||
// Match the response pattern
|
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
||||||
const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
|
if (!match) {
|
||||||
const match = this.inputBuffer.match(responsePattern);
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const heightPx = parseInt(match[1], 10);
|
const heightPx = parseInt(match[1], 10);
|
||||||
const widthPx = parseInt(match[2], 10);
|
const widthPx = parseInt(match[2], 10);
|
||||||
|
if (heightPx <= 0 || widthPx <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (heightPx > 0 && widthPx > 0) {
|
|
||||||
setCellDimensions({ widthPx, heightPx });
|
setCellDimensions({ widthPx, heightPx });
|
||||||
// Invalidate all components so images re-render with correct dimensions
|
// Invalidate all components so images re-render with correct dimensions.
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
this.requestRender();
|
this.requestRender();
|
||||||
}
|
return true;
|
||||||
|
|
||||||
// Remove the response from buffer
|
|
||||||
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
|
||||||
this.cellSizeQueryPending = 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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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