fix(tui): keep scaled OSC 66 heading reserved rows intact on repaint

The "Large Headings" feature renders Markdown H1 at 2x via the Kitty OSC 66
text-sizing protocol; a scale-s heading is s rows tall, so the s-1 blank rows
beneath it hold the multicell glyph's lower half. The render engine gave Kitty
image lines a dedicated re-emit path but had no OSC 66 awareness, so every
repaint through #lineRewriteSequence (incremental diff, full replay, resize
viewport) rewrote those reserved rows as reset + erase-to-end-of-line, clearing
the glyph and leaving reserved-but-invisible space.

Detect reserved rows from the heading's s= scale (#isOsc66SpacerRow) and emit
them untouched across every repaint path, covering all s-1 rows for scale >= 3.
Consolidate isOsc66Line into pi-tui/utils alongside a new osc66MaxScale.

Fixes #8318
This commit is contained in:
roboomp
2026-08-12 05:56:59 +00:00
parent 06aecdd51f
commit dfb6ecfb18
5 changed files with 261 additions and 18 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- 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
### Fixed
+6 -9
View File
@@ -18,6 +18,7 @@ import {
encodeTextSized,
getPaddingX,
getSegmenter,
isOsc66Line,
padding,
replaceTabs,
truncateToWidth,
@@ -38,15 +39,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 => {
+77 -9
View File
@@ -39,7 +39,9 @@ import {
import {
Ellipsis,
extractSegments,
isOsc66Line,
normalizeTerminalOutput,
osc66MaxScale,
sliceByColumn,
sliceWithWidth,
truncateToWidth,
@@ -2142,6 +2144,7 @@ export class TUI extends Container {
screenStart + i,
segment.start + i,
this.#committedRows,
this.#isOsc66SpacerRow(this.#preparedFrame, segment.start + i),
);
}
const cursorControl = this.#cursorControlSequence(
@@ -3552,7 +3555,35 @@ export class TUI extends Container {
return col;
}
#lineRewriteSequence(line: string, width: number, screenRow = -1, frameRow = -1, committedTo = -1): string {
/**
* True when `lines[index]` is a blank row that the scaled OSC 66 heading
* above it flows into. A scale-`s` heading occupies `s` rows, so the `s - 1`
* blank rows beneath it hold the multicell glyph's lower half; erasing or
* overdrawing them clears the glyph and leaves 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.
*/
#isOsc66SpacerRow(lines: readonly string[], index: number): boolean {
if (index <= 0 || lines[index] !== "") return false;
let gap = 1;
while (index - gap > 0 && lines[index - gap] === "") gap++;
const above = lines[index - gap];
return above !== undefined && isOsc66Line(above) && gap <= osc66MaxScale(above) - 1;
}
#lineRewriteSequence(
line: string,
width: number,
screenRow = -1,
frameRow = -1,
committedTo = -1,
spacer = false,
): string {
// The lower half of a scaled OSC 66 heading. The glyph re-emitted on the
// row above already owns these cells, so leave the row untouched — the
// caller's `\r\n` advanced the cursor past it. Any erase here would clear
// the glyph (issue #8318).
if (spacer) return "";
if (TERMINAL.isImageLine(line)) {
return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
}
@@ -3771,7 +3802,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.#isOsc66SpacerRow(frame, i),
)
: this.#terminalLine(frame[i] ?? "", writeRow, i, chunkTo);
}
for (let screenRow = 0; screenRow < height; screenRow++) {
@@ -3780,7 +3818,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.#isOsc66SpacerRow(frame, frameRow),
)
: this.#terminalLine(line, writeRow, frameRow, chunkTo);
}
} else {
@@ -3792,7 +3837,7 @@ 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.#isOsc66SpacerRow(paintLines, i))
: this.#terminalLine(line, writeRow, -1, chunkTo);
}
}
@@ -3991,7 +4036,14 @@ export class TUI extends Container {
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);
buffer += this.#lineRewriteSequence(
window[r] ?? "",
width,
r,
-1,
this.#committedRows,
this.#isOsc66SpacerRow(window, r),
);
}
// Park the hardware cursor at the real content bottom, not the padded
// viewport bottom: a later height shrink would otherwise scroll the live
@@ -4057,7 +4109,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.#isOsc66SpacerRow(fitted, r));
}
buffer += this.#paintEndSequence;
this.terminal.write(buffer);
@@ -4136,7 +4188,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.#isOsc66SpacerRow(window, r))}`;
}
// Rewrite any remaining changed rows after the shift.
let firstChanged = -1;
@@ -4153,7 +4205,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.#isOsc66SpacerRow(window, r),
);
}
cursorFromRow = windowTop + lastChanged;
}
@@ -4228,6 +4287,7 @@ export class TUI extends Container {
r,
windowTop + r,
this.#committedRows,
this.#isOsc66SpacerRow(window, r),
);
}
buffer += fillSequence;
@@ -4259,7 +4319,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.#isOsc66SpacerRow(frame, i),
);
wroteLine = true;
}
for (let screenRow = 0; screenRow < height; screenRow++) {
@@ -4270,6 +4337,7 @@ export class TUI extends Container {
Math.min(chunkTo - chunkFrom + screenRow, height - 1),
windowTop + screenRow,
chunkTo,
this.#isOsc66SpacerRow(window, screenRow),
);
wroteLine = true;
}
+28
View File
@@ -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;
/**
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "bun:test";
import { type Component, TUI } from "@oh-my-pi/pi-tui";
import { VirtualTerminal } from "./virtual-terminal";
// Kitty OSC 66 text-sizing marker and the two erase sequences the renderer
// emits for ordinary rows. A scale-`s` heading renders `s` cells tall, so the
// blank rows beneath it hold the multicell glyph's lower half — erasing them
// clears the glyph and leaves reserved-but-invisible space (issue #8318).
const OSC66 = "\x1b]66;";
const ST = "\x1b\\";
const ERASE_TO_EOL = "\x1b[K";
const ERASE_LINE = "\x1b[2K";
class RawLines implements Component {
#lines: string[];
constructor(lines: string[]) {
this.#lines = lines;
}
invalidate(): void {}
render(): string[] {
return this.#lines;
}
}
// 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<void> {
const nextTick = Promise.withResolvers<void>();
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<void> {
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) };
}
describe("issue #8318: scaled OSC 66 headings survive repaint and resize", () => {
it("re-emits the heading but never erases 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);
// The glyph is re-emitted, not relied upon from a stale frame.
expect(heading).toContain("Heading");
// The reserved lower-half row carries no erase.
expect(spacers[0]).toBe("");
expect(spacers[0]).not.toContain(ERASE_TO_EOL);
expect(spacers[0]).not.toContain(ERASE_LINE);
// Content below the heading is still repainted.
expect(writes.find(write => write.includes(OSC66))).toContain("Body");
} finally {
tui.stop();
}
});
it("keeps the reserved row intact 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");
expect(spacers[0]).toBe("");
expect(spacers[0]).not.toContain(ERASE_TO_EOL);
expect(spacers[0]).not.toContain(ERASE_LINE);
} 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");
// Both rows the scale-3 glyph flows into must stay untouched.
for (const spacer of spacers) {
expect(spacer).toBe("");
expect(spacer).not.toContain(ERASE_TO_EOL);
expect(spacer).not.toContain(ERASE_LINE);
}
} finally {
tui.stop();
}
});
});