mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): prevent cursor marker leaks (#9441)
Closes #9257, closes #9332
This commit is contained in:
parent
aba42ae6b9
commit
25490ea49b
5 changed files with 87 additions and 6 deletions
|
|
@ -1458,7 +1458,8 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!isImageLine(line)) {
|
||||
lines[i] = normalizeTerminalOutput(line) + reset;
|
||||
// Cursor markers are internal render metadata and must never reach terminal output.
|
||||
lines[i] = normalizeTerminalOutput(line.replaceAll(CURSOR_MARKER, "")) + reset;
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
|
|
@ -1493,8 +1494,9 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
const beforeMarker = line.slice(0, markerIndex);
|
||||
const col = visibleWidth(beforeMarker);
|
||||
|
||||
// Strip marker from the line
|
||||
lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
|
||||
// Strip all markers from the selected line. The first marker determines the
|
||||
// hardware cursor position; any others are stale internal sentinels.
|
||||
lines[row] = line.replaceAll(CURSOR_MARKER, "");
|
||||
|
||||
return { row, col };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1347,11 +1347,13 @@ export function sliceWithWidth(
|
|||
while (i < line.length) {
|
||||
const ansi = extractAnsiCode(line, i);
|
||||
if (ansi) {
|
||||
// APC sequences (cursor markers) are positional one-shot commands, not persistent style
|
||||
// state, so codes before the range carry over only when they are not APC.
|
||||
if (currentCol >= startCol && currentCol < endCol) {
|
||||
// Keep original order: codes from before the range must precede codes at the boundary
|
||||
result += pendingAnsi + ansi.code;
|
||||
pendingAnsi = "";
|
||||
} else if (currentCol < startCol) pendingAnsi += ansi.code;
|
||||
} else if (currentCol < startCol && !ansi.code.startsWith("\x1b_")) pendingAnsi += ansi.code;
|
||||
i += ansi.length;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { type Component, CURSOR_MARKER, type TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { extractSegments, normalizeTerminalOutput, sliceWithWidth, visibleWidth } from "../src/utils.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
|
@ -44,6 +44,21 @@ describe("tab width accounting", () => {
|
|||
assert.strictEqual(visibleWidth(slice.text), slice.width);
|
||||
});
|
||||
|
||||
it("does not carry cursor markers into later slices", () => {
|
||||
// Regression for #9332: APC markers are positional metadata, not persistent style.
|
||||
const line = `ab${CURSOR_MARKER}cdefghij`;
|
||||
|
||||
assert.deepStrictEqual(sliceWithWidth(line, 4, 2, true), { text: "ef", width: 2 });
|
||||
assert.deepStrictEqual(sliceWithWidth(line, 6, 4, true), { text: "ghij", width: 4 });
|
||||
});
|
||||
|
||||
it("continues to carry persistent styling into later slices", () => {
|
||||
// Regression for #9332: excluding APC must not disable persistent SGR styling.
|
||||
const line = "\x1b[31mabcdefghij\x1b[0m";
|
||||
|
||||
assert.deepStrictEqual(sliceWithWidth(line, 4, 2, true), { text: "\x1b[31mef", width: 2 });
|
||||
});
|
||||
|
||||
it("keeps overlay segment widths consistent with visible width", () => {
|
||||
const text = "out 192M\t.pi/skill-tests/results-ha";
|
||||
const segments = extractSegments(text, 10, 13, 10, true);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
} from "../src/terminal-image.ts";
|
||||
import { renderFakeCursor, type TuiMouseEvent } from "../src/tui.ts";
|
||||
import { CURSOR_MARKER, renderFakeCursor, type TuiMouseEvent } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
|
||||
import { defaultEditorTheme } from "./test-themes.ts";
|
||||
|
|
@ -65,6 +65,34 @@ class RecordingTerminal extends VirtualTerminal {
|
|||
}
|
||||
|
||||
describe("TuiAltScreen", () => {
|
||||
it("does not leak cursor markers while selecting after the caret", async () => {
|
||||
const terminal = new RecordingTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, { copyOnSelect: false });
|
||||
// Regression for #9257 and #9332: selection slicing must not duplicate cursor markers.
|
||||
tui.addChild({
|
||||
render: () => [`ab${CURSOR_MARKER}cdefghij`],
|
||||
invalidate() {},
|
||||
});
|
||||
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const eventCount = terminal.events.length;
|
||||
|
||||
terminal.sendInput("\x1b[<0;5;1M");
|
||||
terminal.sendInput("\x1b[<32;7;1M");
|
||||
await terminal.waitForRender();
|
||||
|
||||
const output = terminal.events
|
||||
.slice(eventCount)
|
||||
.filter((event) => event.type === "write")
|
||||
.map((event) => event.data)
|
||||
.join("");
|
||||
assert.ok(output.includes("\x1b[7m"), "dragged text should be highlighted");
|
||||
assert.ok(!output.includes(CURSOR_MARKER), "internal cursor markers must not reach the terminal");
|
||||
terminal.sendInput("\x1b[<0;7;1m");
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders a terminal-height viewport and preserves manual scroll position", async () => {
|
||||
const terminal = new VirtualTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
|
|
|
|||
|
|
@ -165,6 +165,40 @@ describe("TUI debug logging", () => {
|
|||
});
|
||||
|
||||
describe("TUI bounded render output", () => {
|
||||
it("does not write duplicate cursor markers to the terminal", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
// Regression for #9257: terminals may render the payload of a leaked APC marker.
|
||||
component.lines = [`\x1b[31m界🙂\x1b[0m${CURSOR_MARKER} world${CURSOR_MARKER}`];
|
||||
tui.addChild(component);
|
||||
|
||||
tui.renderNow();
|
||||
|
||||
const output = terminal.writes.join("");
|
||||
assert.ok(output.includes("\x1b[31m界🙂\x1b[0m world"), "visible styled text should be preserved");
|
||||
assert.ok(!output.includes(CURSOR_MARKER), "internal cursor markers must not reach the terminal");
|
||||
assert.ok(output.includes("\x1b[5G"), "the first marker should keep the cursor at visible column 4");
|
||||
});
|
||||
|
||||
it("does not write cursor markers from other visible or scrollback lines", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
terminal.rows = 2;
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
// Regression for #9257: cursor extraction must not leave markers on other rendered lines.
|
||||
component.lines = [`offscreen${CURSOR_MARKER}`, `a${CURSOR_MARKER}`, `visible-b${CURSOR_MARKER}`];
|
||||
tui.addChild(component);
|
||||
|
||||
tui.renderNow();
|
||||
|
||||
const output = terminal.writes.join("");
|
||||
assert.ok(output.includes("offscreen"), "full renders should preserve scrollback content");
|
||||
assert.ok(output.includes("a"), "full renders should preserve other visible lines");
|
||||
assert.ok(!output.includes(CURSOR_MARKER), "internal cursor markers must not reach the terminal");
|
||||
assert.ok(output.includes("\x1b[10G"), "the bottom visible marker should determine the cursor column");
|
||||
});
|
||||
|
||||
it("splits a large full render without changing its output", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue