fix(tui): prevent cursor marker leaks (#9441)

Closes #9257, closes #9332
This commit is contained in:
muyiyr 2026-11-04 18:26:48 +08:00 • committed by GitHub
parent aba42ae6b9
commit 25490ea49b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 87 additions and 6 deletions

View file

@ -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 };
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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);

View file

@ -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);