From 1c62a51dfdf97da4eaaa4426cb1a5caec283d796 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 13 Aug 2026 00:58:40 +0200 Subject: [PATCH] fix(tui): preserve OSC 66 spacers at viewport top --- packages/tui/src/tui.ts | 28 ++++++++------ packages/tui/test/issue-8318-repro.test.ts | 43 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 921b45615..aeb39c946 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1217,12 +1217,10 @@ export class TUI extends Container { // the drag has been quiet for this long. Multiplexer sessions keep their own // debounce (`#armMultiplexerResizeTimer`, see #2088) and never take this path. static readonly #RESIZE_VIEWPORT_SETTLE_MS = 120; - // Extra rows composed above the resize viewport so a first-visible blank can - // still be identified as the reserved lower half of a scaled OSC 66 heading - // that scrolled just above the fold (issue #8318). A scale-`s` heading - // reserves `s - 1` rows and the protocol caps `s` at 7, so six rows of - // context classify every legal heading exactly. - static readonly #RESIZE_SPACER_CONTEXT_ROWS = 6; + // A scale-`s` OSC 66 heading reserves `s - 1` rows, and the protocol + // caps `s` at 7. This bounds spacer lookups and supplies enough context + // above the resize viewport to classify every legal heading exactly. + static readonly #OSC66_MAX_SPACER_ROWS = 6; // Ghostty can drop Kitty graphics commands sent during its first post-startup // settle window, leaving only Unicode placeholder cells. Hold the first image // paint until that window has passed; later images render normally. @@ -4317,7 +4315,13 @@ export class TUI extends Container { #osc66SpacerGlyphWidth(lines: readonly string[], index: number): number { if (index <= 0 || lines[index] !== "") return -1; let gap = 1; - while (index - gap > 0 && lines[index - gap] === "") gap++; + while ( + gap < TUI.#OSC66_MAX_SPACER_ROWS && + index - gap > 0 && + lines[index - gap] === "" + ) { + gap++; + } const above = lines[index - gap]; if (above === undefined || !isOsc66Line(above) || gap > osc66MaxScale(above) - 1) return -1; return visibleWidth(above); @@ -4812,7 +4816,7 @@ export class TUI extends Container { /** * Build the viewport window for a resize fast-path frame: the bottom * `height` rows of the would-be full frame, collected bottom-up across root - * children, plus up to {@link #RESIZE_SPACER_CONTEXT_ROWS} rows above the + * children, plus up to {@link #OSC66_MAX_SPACER_ROWS} rows above the * fold. {@link ViewportTailProvider}s (the transcript) yield only their tail; * the small live-region children below render in full — so every child * entirely above the fold is skipped. A frame shorter than the viewport is @@ -4832,7 +4836,7 @@ export class TUI extends Container { width: number, height: number, ): { framed: readonly string[]; viewportTop: number; contentRows: number } { - const maxRows = height + TUI.#RESIZE_SPACER_CONTEXT_ROWS; + const maxRows = height + TUI.#OSC66_MAX_SPACER_ROWS; const tail: string[] = []; // bottom-first: viewport rows plus context above const children = this.children; for (let i = children.length - 1; i >= 0 && tail.length < maxRows; i--) { @@ -5094,7 +5098,7 @@ export class TUI extends Container { const moveToBottom = height - 1 - currentScreenRow; if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`; for (let r = height - scroll; r < height; r++) { - buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(window, r))}`; + buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(frame, windowTop + r))}`; } // Rewrite any remaining changed rows after the shift. let firstChanged = -1; @@ -5117,7 +5121,7 @@ export class TUI extends Container { r, windowTop + r, chunkTo, - this.#osc66SpacerGlyphWidth(window, r), + this.#osc66SpacerGlyphWidth(frame, windowTop + r), ); } cursorFromRow = windowTop + lastChanged; @@ -5193,7 +5197,7 @@ export class TUI extends Container { r, windowTop + r, this.#committedRows, - this.#osc66SpacerGlyphWidth(window, r), + this.#osc66SpacerGlyphWidth(frame, windowTop + r), ); } buffer += fillSequence; diff --git a/packages/tui/test/issue-8318-repro.test.ts b/packages/tui/test/issue-8318-repro.test.ts index c64017b51..7e4dd2ad0 100644 --- a/packages/tui/test/issue-8318-repro.test.ts +++ b/packages/tui/test/issue-8318-repro.test.ts @@ -157,6 +157,26 @@ describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () => } }); + it("protects all six reserved rows at the maximum legal scale", async () => { + const term = new VirtualTerminal(80, 8); + const tui = new TUI(term); + tui.addChild(new RawLines([`${OSC66}s=7;Max${ST}`, "", "", "", "", "", "", "Body"])); + const writes = captureWrites(term); + try { + tui.start(); + await settle(term); + writes.length = 0; + + tui.requestRender(true, { clearScrollback: true }); + await settle(term); + + const { spacers } = headingAndSpacers(writes, 6); + for (const spacer of spacers) expectClearsRightOfGlyph(spacer, 21); + } finally { + tui.stop(); + } + }); + it("clears stale cells when a wide row reflows into the reserved spacer", async () => { const term = new VirtualTerminal(80, 6); const tui = new TUI(term); @@ -210,6 +230,29 @@ describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () => } }); + it("preserves the top spacer during an in-place viewport rewrite", async () => { + const term = new VirtualTerminal(80, 4); + const tui = new TUI(term); + // The heading is immediately above the visible window while its reserved + // lower row is window[0]. An in-place rewrite must classify that row from + // the full frame rather than the context-free window slice. + tui.addChild(new RawLines(["f0", "f1", `${OSC66}s=2;Heading${ST}`, "", "b0", "b1", "b2"])); + const writes = captureWrites(term); + try { + tui.start(); + await settle(term); + writes.length = 0; + + tui.requestRender(true); + await settle(term); + + const paint = writes.join(""); + expect(paint).toContain("\x1b[14C\x1b[K"); + } finally { + tui.stop(); + } + }); + it("preserves the top spacer when the heading scrolls above the resize viewport", async () => { const term = new VirtualTerminal(80, 4); const tui = new TUI(term);