diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index b074a3a2..602cb8dd 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -274,14 +274,35 @@ export function visibleWidth(str: string): number { * Normalize text for terminal output without changing logical editor content. * Some terminals render precomposed Thai/Lao AM vowels inconsistently during * differential repaint. Their compatibility decompositions have the same cell - * width but avoid stale-cell artifacts in terminal renderers. + * width but avoid stale-cell artifacts in terminal renderers. Visible tabs are + * expanded to the fixed width used by layout so terminal tab stops cannot wrap + * a logical line, while tabs inside terminal string sequences stay untouched. */ const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/; const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g; export function normalizeTerminalOutput(str: string): string { - if (!THAI_LAO_AM_REGEX.test(str)) return str; - return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2")); + let normalized = str; + if (THAI_LAO_AM_REGEX.test(normalized)) { + normalized = normalized.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => + char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2", + ); + } + if (!normalized.includes("\t")) return normalized; + + let result = ""; + let i = 0; + while (i < normalized.length) { + const ansi = extractAnsiCode(normalized, i); + if (ansi) { + result += ansi.code; + i += ansi.length; + continue; + } + result += normalized[i] === "\t" ? " " : normalized[i]; + i++; + } + return result; } /** diff --git a/packages/tui/test/tab-width.test.ts b/packages/tui/test/tab-width.test.ts index 427a77db..883fa335 100644 --- a/packages/tui/test/tab-width.test.ts +++ b/packages/tui/test/tab-width.test.ts @@ -1,6 +1,37 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { extractSegments, sliceWithWidth, visibleWidth } from "../src/utils.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { extractSegments, normalizeTerminalOutput, sliceWithWidth, visibleWidth } from "../src/utils.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class FullViewportContent implements Component { + render(width: number): string[] { + return ["base 0", "base 1", "base 2"].map((line) => line.padEnd(width)); + } + + invalidate(): void {} +} + +class CapturingVirtualTerminal extends VirtualTerminal { + private output = ""; + + override write(data: string): void { + this.output += data; + super.write(data); + } + + getOutput(): string { + return this.output; + } +} + +class TabStatusOverlay implements Component { + render(): string[] { + return ["\tX"]; + } + + invalidate(): void {} +} describe("tab width accounting", () => { it("keeps slice helper widths consistent with visible width", () => { @@ -25,4 +56,32 @@ describe("tab width accounting", () => { assert.strictEqual(tabFits.beforeWidth, 11); assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth); }); + + it("keeps tabs inside terminal control sequences byte-identical", () => { + const controlSequences = [ + "\x1b]8;;https://example.test/a\tb\x07", + "\x1b]0;window\ttitle\x1b\\", + "\x1b_payload\tdata\x1b\\", + ]; + + for (const controlSequence of controlSequences) { + assert.strictEqual(normalizeTerminalOutput(`${controlSequence}label\ttext`), `${controlSequence}label text`); + } + }); + + it("keeps tab-containing overlays on one physical terminal row", async () => { + const terminal = new CapturingVirtualTerminal(16, 3); + const tui = new TUI(terminal); + tui.addChild(new FullViewportContent()); + tui.showOverlay(new TabStatusOverlay(), { width: 4, row: 1, col: 4 }); + tui.start(); + + try { + await terminal.waitForRender(); + assert.deepStrictEqual(terminal.getViewport(), ["base 0 ", "base X ", "base 2 "]); + assert.ok(!terminal.getOutput().includes("\t")); + } finally { + tui.stop(); + } + }); });