diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ad11a36c5..fe0f2c7d8 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 }; } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 2a84d830d..7aa9b1fc7 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -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; } diff --git a/packages/tui/test/tab-width.test.ts b/packages/tui/test/tab-width.test.ts index b7396e485..3fe93942b 100644 --- a/packages/tui/test/tab-width.test.ts +++ b/packages/tui/test/tab-width.test.ts @@ -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); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 6ea551419..0428a01d2 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -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); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index c01142a4a..39fa9bb03 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -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);