fix(tui): preserve OSC 66 spacers at viewport top

This commit is contained in:
can1357
2026-08-13 00:58:40 +02:00
parent a8897e7012
commit 1c62a51dfd
2 changed files with 59 additions and 12 deletions
+16 -12
View File
@@ -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;
@@ -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);