diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b37be2916..4fa2f8365 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed repeated pane-width changes corrupting native scrollback: direct HerdR panes now use the same ED3 source replay as direct terminals, preventing settled viewport paints from hardening host-reflowed soft wraps, while ED3-unsafe multiplexers retain opaque logical width epochs that append only source output queued during resize ([#6147](https://github.com/can1357/oh-my-pi/issues/6147), [#7026](https://github.com/can1357/oh-my-pi/issues/7026)). +- Fixed scaled OSC 66 Markdown headings ("Large Headings" on Kitty) rendering as an invisible placeholder after a redraw or terminal resize: the blank row a `s`-scaled heading flows into was rewritten with an erase, which cleared the multicell glyph's lower half. The renderer now treats those reserved rows as untouchable across every repaint path — full replay, incremental diff, and the resize viewport — and covers all `s - 1` rows of scale ≥ 3 headings ([#8318](https://github.com/can1357/oh-my-pi/issues/8318)). ## [17.2.13] - 2026-08-11 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index b94f7305c..dfdd7ab8a 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -23,6 +23,7 @@ import { encodeTextSized, getPaddingX, getSegmenter, + isOsc66Line, padding, replaceTabs, truncateToWidth, @@ -43,15 +44,11 @@ function normalizeOsc8Terminators(text: string): string { } // OSC 66 (Kitty text-sizing) heading spans are emitted as a single indivisible -// unit by the H1 render path. Like image-protocol lines, they must bypass -// ANSI wrapping and width padding: re-wrapping splits/normalizes the sized span -// (recomputing the explicit `w=` cell count and hoisting SGR out of the OSC -// payload), and padding would append trailing cells past the doubled glyph. -const OSC66_LINE_PREFIX = "\x1b]66;"; - -function isOsc66Line(line: string): boolean { - return line.includes(OSC66_LINE_PREFIX); -} +// unit by the H1 render path. Like image-protocol lines, they bypass ANSI +// wrapping and width padding (see `isOsc66Line` in ../utils): re-wrapping +// splits/normalizes the sized span (recomputing the explicit `w=` cell count +// and hoisting SGR out of the OSC payload), and padding would append trailing +// cells past the doubled glyph. function normalizeHtmlEntitiesForTerminal(raw: string): string { const parseCodePoint = (value: number): string => { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 28ad55d67..921b45615 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -39,7 +39,9 @@ import { import { Ellipsis, extractSegments, + isOsc66Line, normalizeTerminalOutput, + osc66MaxScale, sliceByColumn, sliceWithWidth, truncateToWidth, @@ -1215,6 +1217,12 @@ 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; // 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. @@ -2611,6 +2619,7 @@ export class TUI extends Container { screenStart + i, segment.start + i, this.#committedRows, + this.#osc66SpacerGlyphWidth(this.#preparedFrame, segment.start + i), ); } const cursorControl = this.#cursorControlSequence( @@ -4296,7 +4305,42 @@ export class TUI extends Container { return col; } - #lineRewriteSequence(line: string, width: number, screenRow = -1, frameRow = -1, committedTo = -1): string { + /** + * Columns to preserve when `lines[index]` is a blank row that a scaled OSC 66 + * heading flows into, or `-1` when it is not such a row. A scale-`s` heading + * occupies `s` rows and `visibleWidth` columns, so the `s - 1` blank rows + * beneath it hold the multicell glyph's lower half; those columns must never + * be erased or overdrawn or the glyph vanishes, leaving reserved-but-invisible + * space (issue #8318). Scans upward across the contiguous blank run so every + * reserved row of a scale ≥ 3 heading is covered, not just the first. + */ + #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++; + const above = lines[index - gap]; + if (above === undefined || !isOsc66Line(above) || gap > osc66MaxScale(above) - 1) return -1; + return visibleWidth(above); + } + + #lineRewriteSequence( + line: string, + width: number, + screenRow = -1, + frameRow = -1, + committedTo = -1, + spacerGlyphWidth = -1, + ): string { + // Reserved lower half of a scaled OSC 66 heading. The glyph re-emitted on + // the row above owns columns `[0, spacerGlyphWidth)` here, so preserve + // them (any erase there clears the glyph — issue #8318) but still clear + // stale cells to their right: a row can reflow from wider text into this + // spacer, and the glyph write never covers those columns. Leading reset + // keeps the erase on the default background (BCE). + if (spacerGlyphWidth >= 0) { + if (spacerGlyphWidth >= width) return ""; + return `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`; + } if (TERMINAL.isImageLine(line)) { return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo); } @@ -4631,7 +4675,14 @@ export class TUI extends Container { if (i > 0) buffer += "\r\n"; const writeRow = Math.min(i, height - 1); buffer += options.clearScrollback - ? this.#lineRewriteSequence(frame[i] ?? "", width, writeRow, i, chunkTo) + ? this.#lineRewriteSequence( + frame[i] ?? "", + width, + writeRow, + i, + chunkTo, + this.#osc66SpacerGlyphWidth(frame, i), + ) : this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo); } for (let screenRow = 0; screenRow < height; screenRow++) { @@ -4640,7 +4691,14 @@ export class TUI extends Container { const writeRow = Math.min(chunkTo + screenRow, height - 1); const frameRow = windowTop + screenRow; buffer += options.clearScrollback - ? this.#lineRewriteSequence(line, width, writeRow, frameRow, chunkTo) + ? this.#lineRewriteSequence( + line, + width, + writeRow, + frameRow, + chunkTo, + this.#osc66SpacerGlyphWidth(frame, frameRow), + ) : this.#terminalLine(line, writeRow, frameRow, chunkTo); } } else { @@ -4652,7 +4710,14 @@ export class TUI extends Container { const line = visibleTexts && i >= visibleStart ? visibleTexts[i - visibleStart] : (paintLines[i] ?? ""); const writeRow = Math.min(i, height - 1); buffer += options.clearScrollback - ? this.#lineRewriteSequence(line, width, writeRow, -1, chunkTo) + ? this.#lineRewriteSequence( + line, + width, + writeRow, + -1, + chunkTo, + this.#osc66SpacerGlyphWidth(paintLines, i), + ) : this.#terminalLine(line, writeRow, -1, chunkTo); } } @@ -4739,43 +4804,59 @@ export class TUI extends Container { // off a partial walk. The settle paint's own beginPass()/endPass() is the // authoritative accounting, and its beginPass() wipes these frames. this.#imageBudget.beginPass(true); - const { window, contentRows } = this.#composeResizeViewport(width, height); - this.#emitResizeViewport(window, height, contentRows, width); + const { framed, viewportTop, contentRows } = this.#composeResizeViewport(width, height); + this.#emitResizeViewport(framed, viewportTop, height, contentRows, width); this.#resizeViewportPaintCount += 1; } /** * 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. {@link ViewportTailProvider}s (the transcript) yield only their - * tail; the small live-region children below render in full — so every child + * children, plus up to {@link #RESIZE_SPACER_CONTEXT_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 * top-aligned with blank rows below, matching the full-paint window geometry * (windowTop = max(0, frameLength - height)). Cursor markers are stripped * (the drag hides the hardware cursor) and rows are width-fitted via the * stateless preparer, so no persistent prepared-frame cache is touched. + * + * Returns the visible rows preceded by the context rows in frame order + * (`framed`), the index where the viewport begins (`viewportTop`), and the + * visible content count. The context rows are never emitted; they only let + * {@link #osc66SpacerGlyphWidth} see a scaled heading that scrolled just + * above the fold, so its reserved rows are preserved instead of erased + * (issue #8318). */ - #composeResizeViewport(width: number, height: number): { window: readonly string[]; contentRows: number } { - const tail: string[] = []; // bottom-first + #composeResizeViewport( + width: number, + height: number, + ): { framed: readonly string[]; viewportTop: number; contentRows: number } { + const maxRows = height + TUI.#RESIZE_SPACER_CONTEXT_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 < height; i--) { + for (let i = children.length - 1; i >= 0 && tail.length < maxRows; i--) { const child = children[i]!; const provider = asViewportTailProvider(child); - const rows = provider ? provider.renderViewportTail(width, height - tail.length) : child.render(width); - for (let r = rows.length - 1; r >= 0 && tail.length < height; r--) { + const rows = provider ? provider.renderViewportTail(width, maxRows - tail.length) : child.render(width); + for (let r = rows.length - 1; r >= 0 && tail.length < maxRows; r--) { tail.push(rows[r]!); } } - const count = tail.length; + const contentRows = Math.min(tail.length, height); + const extra = tail.length - contentRows; // context rows above the fold const window: string[] = new Array(height); for (let screenRow = 0; screenRow < height; screenRow++) { - // `tail` holds the bottom `count` frame rows, bottom-first. They fill - // the viewport when the frame overflows it and sit at the top (blanks - // below) when it underflows. - window[screenRow] = screenRow < count ? tail[count - 1 - screenRow]! : ""; + // `tail` holds the bottom rows first. The bottom `contentRows` fill the + // viewport (top-aligned with blanks below on underflow). + window[screenRow] = screenRow < contentRows ? tail[contentRows - 1 - screenRow]! : ""; } this.#extractCursorMarkers(window); - return { window: this.#prepareLinesArray(window, width), contentRows: count }; + // Frame order: context rows above the fold (top-first) then the window. + const framed: string[] = new Array(extra + height); + for (let k = 0; k < extra; k++) framed[k] = tail[tail.length - 1 - k]!; + for (let screenRow = 0; screenRow < height; screenRow++) framed[extra + screenRow] = window[screenRow]!; + return { framed: this.#prepareLinesArray(framed, width), viewportTop: extra, contentRows }; } /** @@ -4845,13 +4926,30 @@ export class TUI extends Container { * flash, #5854). Normal-screen history is rebuilt once at settle via * `#emitFullPaint`. */ - #emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void { + #emitResizeViewport( + framed: readonly string[], + viewportTop: number, + height: number, + contentRows: number, + width: number, + ): void { const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width; const altEnter = widthChanged ? this.#enterResizeAltSequence() : ""; let buffer = `${this.#paintBeginSequence + altEnter}\x1b[H`; for (let r = 0; r < height; r++) { if (r > 0) buffer += "\r\n"; - buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, -1, this.#committedRows); + // `framed` carries context rows above the fold; the visible window + // starts at `viewportTop`, and the spacer lookup scans within `framed` + // so a heading just above the fold is still seen (issue #8318). + const idx = viewportTop + r; + buffer += this.#lineRewriteSequence( + framed[idx] ?? "", + width, + r, + -1, + this.#committedRows, + this.#osc66SpacerGlyphWidth(framed, idx), + ); } // Park the hardware cursor at the real content bottom, not the padded // viewport bottom: a later height shrink would otherwise scroll the live @@ -4917,7 +5015,7 @@ export class TUI extends Container { let buffer = `${this.#paintBeginSequence}\x1b[H`; for (let r = 0; r < height; r++) { if (r > 0) buffer += "\r\n"; - buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1); + buffer += this.#lineRewriteSequence(fitted[r], width, r, -1, -1, this.#osc66SpacerGlyphWidth(fitted, r)); } buffer += this.#paintEndSequence; this.terminal.write(buffer); @@ -4996,7 +5094,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)}`; + buffer += `\r\n${this.#lineRewriteSequence(window[r] ?? "", width, height - 1, windowTop + r, chunkTo, this.#osc66SpacerGlyphWidth(window, r))}`; } // Rewrite any remaining changed rows after the shift. let firstChanged = -1; @@ -5013,7 +5111,14 @@ export class TUI extends Container { buffer += "\r"; for (let r = firstChanged; r <= lastChanged; r++) { if (r > firstChanged) buffer += "\r\n"; - buffer += this.#lineRewriteSequence(window[r] ?? "", width, r, windowTop + r, chunkTo); + buffer += this.#lineRewriteSequence( + window[r] ?? "", + width, + r, + windowTop + r, + chunkTo, + this.#osc66SpacerGlyphWidth(window, r), + ); } cursorFromRow = windowTop + lastChanged; } @@ -5088,6 +5193,7 @@ export class TUI extends Container { r, windowTop + r, this.#committedRows, + this.#osc66SpacerGlyphWidth(window, r), ); } buffer += fillSequence; @@ -5119,7 +5225,14 @@ export class TUI extends Container { let wroteLine = false; for (let i = chunkFrom; i < chunkTo; i++) { if (wroteLine) buffer += "\r\n"; - buffer += this.#lineRewriteSequence(frame[i] ?? "", width, Math.min(i - chunkFrom, height - 1), i, chunkTo); + buffer += this.#lineRewriteSequence( + frame[i] ?? "", + width, + Math.min(i - chunkFrom, height - 1), + i, + chunkTo, + this.#osc66SpacerGlyphWidth(frame, i), + ); wroteLine = true; } for (let screenRow = 0; screenRow < height; screenRow++) { @@ -5130,6 +5243,7 @@ export class TUI extends Container { Math.min(chunkTo - chunkFrom + screenRow, height - 1), windowTop + screenRow, chunkTo, + this.#osc66SpacerGlyphWidth(frame, windowTop + screenRow), ); wroteLine = true; } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index f0ee07cc8..9a4f31297 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -371,6 +371,34 @@ export function visibleWidth(str: string): number { return correctHangulCompatibilityJamoWidth(width, str); } +/** + * True when a row carries a Kitty OSC 66 text-sizing span (`\x1b]66;…`). + * Scaled spans must bypass wrapping/padding and, when scaled up, reserve the + * terminal rows their multicell glyphs flow into. + */ +export function isOsc66Line(line: string): boolean { + return line.includes(OSC66_PREFIX); +} + +/** + * Largest `s=` scale among the OSC 66 spans in a line (1 when none is scaled). + * A scale-`s` heading occupies `s` terminal rows, so the `s - 1` blank rows + * beneath it are the glyph's lower half and must never be erased or overdrawn. + */ +export function osc66MaxScale(line: string): number { + if (!line.includes(OSC66_PREFIX)) return 1; + let max = 1; + OSC66_SPAN_REGEX.lastIndex = 0; + for (let m = OSC66_SPAN_REGEX.exec(line); m !== null; m = OSC66_SPAN_REGEX.exec(line)) { + for (const part of m[1].split(":")) { + if (part.indexOf("=") !== 1 || part[0] !== "s") continue; + const value = Number.parseInt(part.slice(2), 10); + if (Number.isFinite(value) && value > max && value <= 7) max = value; + } + } + return max; +} + const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g; /** diff --git a/packages/tui/test/issue-8318-repro.test.ts b/packages/tui/test/issue-8318-repro.test.ts new file mode 100644 index 000000000..c64017b51 --- /dev/null +++ b/packages/tui/test/issue-8318-repro.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from "bun:test"; +import { type Component, type NativeScrollbackLiveRegion, TUI } from "@oh-my-pi/pi-tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +// Kitty OSC 66 text-sizing marker and the erase sequences the renderer emits. +// A scale-`s` heading renders `s` cells tall and `visibleWidth` cells wide, so +// the blank rows beneath it hold the multicell glyph's lower half: those +// columns must survive every repaint or the glyph vanishes and leaves +// reserved-but-invisible space (issue #8318). The `s=2` "Heading" glyph is +// 2 * 7 = 14 cells wide; the `s=3` "Big" glyph is 3 * 3 = 9. +const OSC66 = "\x1b]66;"; +const ST = "\x1b\\"; +const ERASE_LINE = "\x1b[2K"; + +class RawLines implements Component { + #lines: string[]; + constructor(lines: string[]) { + this.#lines = lines; + } + setLines(lines: string[]): void { + this.#lines = lines; + } + invalidate(): void {} + render(): string[] { + return this.#lines; + } +} + +class SeamRawLines extends RawLines implements NativeScrollbackLiveRegion { + getNativeScrollbackLiveRegionStart(): number { + return Number.POSITIVE_INFINITY; + } +} + +// Flush the real render scheduler. Its throttle and post-paint settle windows +// are driven by the platform clock, so these integration tests wait real time +// (the suite-wide convention in deccara/image-budget tests) rather than mock a +// scheduler that would not exercise the resize-settle full paint under test. +async function settle(term: VirtualTerminal): Promise { + const nextTick = Promise.withResolvers(); + process.nextTick(nextTick.resolve); + await nextTick.promise; + await Bun.sleep(40); + await term.flush(); +} + +// A non-multiplexer resize paints the viewport immediately and defers the +// authoritative full paint until the drag settles (120 ms window). +async function settleResize(term: VirtualTerminal): Promise { + await Bun.sleep(160); + await settle(term); +} + +function captureWrites(term: VirtualTerminal): string[] { + const writes: string[] = []; + const realWrite = term.write.bind(term); + vi.spyOn(term, "write").mockImplementation((data: string) => { + writes.push(data); + realWrite(data); + }); + return writes; +} + +/** + * Split the paint write that carries the sized heading into terminal rows and + * return the heading row plus the `spacerCount` rows written directly beneath + * it. Rows are `\r\n`-separated in the emitted buffer; the OSC 66 ST (`ESC \\`) + * never contains a newline, so the split keeps each span intact. + */ +function headingAndSpacers(writes: string[], spacerCount: number): { heading: string; spacers: string[] } { + const paint = writes.find(write => write.includes(OSC66)); + expect(paint).toBeDefined(); + const rows = paint!.split("\r\n"); + const idx = rows.findIndex(row => row.includes(OSC66)); + expect(idx).toBeGreaterThanOrEqual(0); + return { heading: rows[idx]!, spacers: rows.slice(idx + 1, idx + 1 + spacerCount) }; +} + +/** + * A reserved spacer row must preserve the glyph's own columns `[0, glyphWidth)` + * while clearing any stale cells to their right (a row can reflow from wider + * text into the spacer). So: no whole-line erase, no erase-to-end before the + * glyph, and exactly one cursor-forward to `glyphWidth` followed by erase-to-end. + */ +function expectClearsRightOfGlyph(spacer: string, glyphWidth: number): void { + expect(spacer).not.toContain(ERASE_LINE); + expect(spacer).not.toMatch(/^(?:\x1b\[0m)?\x1b\[K/); + const match = spacer.match(/\x1b\[(\d+)C\x1b\[K/); + expect(match).not.toBeNull(); + expect(Number(match![1])).toBe(glyphWidth); +} + +describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () => { + it("re-emits the heading and preserves its reserved row on a full repaint", async () => { + const term = new VirtualTerminal(80, 6); + const tui = new TUI(term); + tui.addChild(new RawLines([`${OSC66}s=2;Heading${ST}`, "", "Body"])); + const writes = captureWrites(term); + try { + tui.start(); + await settle(term); + writes.length = 0; + + // Destructive full replay — the same gesture a redraw/session replace + // uses, routed through the per-row erase path (#lineRewriteSequence). + tui.requestRender(true, { clearScrollback: true }); + await settle(term); + + const { heading, spacers } = headingAndSpacers(writes, 1); + expect(heading).toContain("Heading"); + expectClearsRightOfGlyph(spacers[0]!, 14); + expect(writes.find(write => write.includes(OSC66))).toContain("Body"); + } finally { + tui.stop(); + } + }); + + it("preserves the reserved row across a resize repaint", async () => { + const term = new VirtualTerminal(80, 6); + const tui = new TUI(term); + tui.addChild(new RawLines([`${OSC66}s=2;Heading${ST}`, "", "Body"])); + const writes = captureWrites(term); + try { + tui.start(); + await settle(term); + writes.length = 0; + + term.resize(70, 6); + await settleResize(term); + + const { heading, spacers } = headingAndSpacers(writes, 1); + expect(heading).toContain("Heading"); + expectClearsRightOfGlyph(spacers[0]!, 14); + } finally { + tui.stop(); + } + }); + + it("protects every reserved row of a scale-3 heading (the /debug probe case)", async () => { + const term = new VirtualTerminal(80, 6); + const tui = new TUI(term); + tui.addChild(new RawLines([`${OSC66}s=3;Big${ST}`, "", "", "Body"])); + const writes = captureWrites(term); + try { + tui.start(); + await settle(term); + writes.length = 0; + + tui.requestRender(true, { clearScrollback: true }); + await settle(term); + + const { heading, spacers } = headingAndSpacers(writes, 2); + expect(heading).toContain("Big"); + for (const spacer of spacers) expectClearsRightOfGlyph(spacer, 9); + } 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); + // Row 1 starts as text far wider than the eventual 14-cell glyph. + const content = new RawLines(["intro", `wide prior text ${"x".repeat(40)}`, "tail"]); + tui.addChild(content); + const writes = captureWrites(term); + try { + tui.start(); + await settle(term); + writes.length = 0; + + // Reflow: row 0 becomes the sized heading, row 1 becomes its reserved + // spacer. The glyph write covers only columns [0, 14); the stale wide + // text to the right must still be erased. + content.setLines([`${OSC66}s=2;Heading${ST}`, "", "tail"]); + tui.requestRender(); + await settle(term); + + const { heading, spacers } = headingAndSpacers(writes, 1); + expect(heading).toContain("Heading"); + expectClearsRightOfGlyph(spacers[0]!, 14); + } finally { + tui.stop(); + } + }); + + it("uses full-frame context when the spacer is the first row below the commit seam", async () => { + const term = new VirtualTerminal(80, 4); + const tui = new TUI(term); + const content = new SeamRawLines(["old heading row", `wide prior text ${"x".repeat(40)}`, "tail-0", "tail-1"]); + tui.addChild(content); + const writes = captureWrites(term); + try { + tui.start(); + await settle(term); + writes.length = 0; + + // Appending one row commits frame[0] through the chunk loop. The + // reserved frame[1] row becomes window[0], so window-local context + // cannot see the heading immediately above the commit seam. + content.setLines([`${OSC66}s=2;Heading${ST}`, "", "tail-0", "tail-1", "tail-2"]); + tui.requestRender(); + await settle(term); + + const { heading, spacers } = headingAndSpacers(writes, 1); + expect(heading).toContain("Heading"); + expectClearsRightOfGlyph(spacers[0]!, 14); + } 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); + // windowTop = frameLength - height = 7 - 4 = 3. The heading sits at row 2 + // (just above the fold) and its reserved spacer at row 3 = window[0], so + // the resize fast path composes it as the first visible row. + 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; + + // A width drag paints the viewport synchronously via #emitResizeViewport + // before the settle full paint. Capture that throwaway frame directly. + term.resize(70, 4); + const viewportPaint = writes.find( + write => write.includes("\x1b[H") && !write.includes("\x1b[2J") && !write.includes("\x1b[3J"), + ); + expect(viewportPaint).toBeDefined(); + // Row 0 (the spacer) is emitted right after the final cursor-home. + const seg0 = viewportPaint!.split("\r\n")[0]!; + const row0 = seg0.slice(seg0.lastIndexOf("\x1b[H") + 3); + expectClearsRightOfGlyph(row0, 14); + } finally { + tui.stop(); + } + }); +});