feat(packages/tui): added OSC 66 text sizing for markdown headings

- Added OSC 66 text-sizing utilities and `encodeTextSized` for safe, width-aware span emission.
- Enabled scale-2 OSC 66 output for H1 headings when text sizing is enabled and width permits.
- Handled OSC 66 heading rows as raw output, skipping wrap/background and preserving the reserved blank row.
- Mapped inline heading tokens to plain text to gate OSC 66 heading rendering by measured width.
This commit is contained in:
can1357
2026-06-04 00:50:46 +02:00
parent 86c56d6472
commit fe6a94cb2a
4 changed files with 259 additions and 11 deletions
+112 -8
View File
@@ -1,12 +1,31 @@
import { LRUCache } from "lru-cache/raw";
import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
import type { SymbolTheme } from "../symbols";
import { TERMINAL } from "../terminal-capabilities";
import { getTextSizing, TERMINAL } from "../terminal-capabilities";
import type { Component } from "../tui";
import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
import {
applyBackgroundToLine,
encodeTextSized,
getSegmenter,
padding,
replaceTabs,
visibleWidth,
wrapTextWithAnsi,
} from "../utils";
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
// 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);
}
class StrictStrikethroughTokenizer extends Tokenizer {
override del(src: string): Tokens.Del | undefined {
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
@@ -130,6 +149,59 @@ function formatHyperlink(text: string, target: string): string {
return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
}
function isAsciiTextSizingPayload(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 0x20 || code > 0x7e) return false;
}
return true;
}
function encodeTextSizedHeading(text: string, scale: 1 | 2 | 3): string {
let out = "";
let asciiRun = "";
const flushAscii = () => {
if (asciiRun === "") return;
out += encodeTextSized(asciiRun, { scale });
asciiRun = "";
};
for (const { segment } of getSegmenter().segment(text)) {
if (isAsciiTextSizingPayload(segment)) {
asciiRun += segment;
continue;
}
flushAscii();
out += encodeTextSized(segment, { scale, widthCells: visibleWidth(segment) });
}
flushAscii();
return out;
}
function plainInlineTokens(tokens: Token[]): string {
let result = "";
for (const token of tokens) {
switch (token.type) {
case "text":
result += token.tokens && token.tokens.length > 0 ? plainInlineTokens(token.tokens) : token.text;
break;
case "strong":
case "em":
case "del":
case "link":
result += plainInlineTokens(token.tokens || []);
break;
case "codespan":
result += token.text;
break;
default:
if ("text" in token && typeof token.text === "string") result += token.text;
break;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Inline hex-color swatches
// ---------------------------------------------------------------------------
@@ -285,7 +357,7 @@ export class Markdown implements Component {
// by MarkdownTheme and is one of the most styling-sensitive entries.
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
const headingProbe = this.#theme.heading("");
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${getTextSizing() ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
const cached = renderCache.get(cacheKey);
if (cached !== undefined) {
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
@@ -311,8 +383,9 @@ export class Markdown implements Component {
// Wrap lines (NO padding, NO background yet)
const wrappedLines: string[] = [];
for (const line of renderedLines) {
// Skip wrapping for image protocol lines (would corrupt escape sequences)
if (TERMINAL.isImageLine(line)) {
// Skip wrapping for image protocol lines and OSC 66 sized headings
// (would corrupt escape sequences / split the indivisible sized span).
if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
wrappedLines.push(line);
} else {
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
@@ -325,13 +398,29 @@ export class Markdown implements Component {
const bgFn = this.#defaultTextStyle?.bgColor;
const contentLines: string[] = [];
let previousLineWasOsc66 = false;
for (const line of wrappedLines) {
// Image lines must be output raw - no margins or background
if (TERMINAL.isImageLine(line)) {
contentLines.push(line);
// The first empty row after a scale>1 OSC 66 heading is structural:
// it reserves the lower cells occupied by the multicell glyphs. Do
// not pad or background-fill it, because real spaces on that row can
// interact with Kitty's multicell overwrite rules during the first
// paint. Leave it as a cursor-only newline.
if (previousLineWasOsc66 && line === "") {
contentLines.push("");
previousLineWasOsc66 = false;
continue;
}
// Image lines and OSC 66 sized headings must be output raw - no margins or background
if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
contentLines.push(line);
previousLineWasOsc66 = isOsc66Line(line);
continue;
}
previousLineWasOsc66 = false;
const lineWithMargins = leftMargin + line + rightMargin;
if (bgFn) {
@@ -459,7 +548,22 @@ export class Markdown implements Component {
const headingLevel = token.depth;
const headingPrefix = `${"#".repeat(headingLevel)} `;
const headingText = this.#renderInlineTokens(token.tokens || [], styleContext);
const headingPlainText = plainInlineTokens(token.tokens || []);
let styledHeading: string;
if (headingLevel === 1 && getTextSizing()) {
const plainWidth = visibleWidth(headingPlainText);
if (plainWidth > 0 && 2 * plainWidth <= width) {
const sizedHeading = encodeTextSizedHeading(headingPlainText, 2);
lines.push(this.#theme.heading(this.#theme.bold(this.#theme.underline(sizedHeading))));
if (nextTokenType) {
lines.push(""); // reserve the heading's second visual row
if (nextTokenType !== "space") {
lines.push(""); // Add spacing after headings (unless space token follows)
}
}
break;
}
}
if (headingLevel === 1) {
styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText)));
} else if (headingLevel === 2) {
+60
View File
@@ -14,6 +14,66 @@ export { Ellipsis } from "@oh-my-pi/pi-natives";
export { getDefaultTabWidth, getIndentation } from "@oh-my-pi/pi-utils";
export type TextSizingScale = 1 | 2 | 3;
export type TextSizingVerticalAlign = "top" | "bottom" | "center";
export type TextSizingHorizontalAlign = "left" | "right" | "center";
export interface TextSizingOptions {
scale?: TextSizingScale;
widthCells?: number;
verticalAlign?: TextSizingVerticalAlign;
horizontalAlign?: TextSizingHorizontalAlign;
}
const OSC66_UNSAFE = /[\x00-\x1f\x7f-\x9f]/u;
const OSC66_UNSAFE_GLOBAL = /[\x00-\x1f\x7f-\x9f]/gu;
function textSizingVerticalAlignValue(align: TextSizingVerticalAlign | undefined): number | undefined {
switch (align) {
case "top":
return 0;
case "bottom":
return 1;
case "center":
return 2;
default:
return undefined;
}
}
function textSizingHorizontalAlignValue(align: TextSizingHorizontalAlign | undefined): number | undefined {
switch (align) {
case "left":
return 0;
case "right":
return 1;
case "center":
return 2;
default:
return undefined;
}
}
/**
* Encode a plain-text span using Kitty's OSC 66 text-sizing protocol. The TUI
* emits only safe UTF-8 payloads and ST terminators so its ANSI parser and the
* terminal agree on span boundaries.
*/
export function encodeTextSized(text: string, options: TextSizingOptions = {}): string {
const metadata: string[] = [];
if (options.scale !== undefined) metadata.push(`s=${options.scale}`);
if (options.widthCells !== undefined && Number.isFinite(options.widthCells)) {
metadata.push(`w=${Math.max(0, Math.trunc(options.widthCells))}`);
}
const verticalAlign = textSizingVerticalAlignValue(options.verticalAlign);
if (verticalAlign !== undefined) metadata.push(`v=${verticalAlign}`);
const horizontalAlign = textSizingHorizontalAlignValue(options.horizontalAlign);
if (horizontalAlign !== undefined) metadata.push(`h=${horizontalAlign}`);
const safeText = OSC66_UNSAFE.test(text) ? text.replace(OSC66_UNSAFE_GLOBAL, " ") : text;
return `\x1b]66;${metadata.join(":")};${safeText}\x1b\\`;
}
export function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult {
return nativeSliceWithWidth(line, startCol, length, strict ?? null, getDefaultTabWidth());
}
+68 -2
View File
@@ -1,10 +1,11 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { Chalk } from "chalk";
import { Markdown, renderInlineMarkdown } from "../src/components/markdown.js";
import { TERMINAL } from "../src/terminal-capabilities.js";
import { getTextSizing, setTextSizing, TERMINAL } from "../src/terminal-capabilities.js";
import { type Component, TUI } from "../src/tui.js";
import { visibleWidth } from "../src/utils.js";
import { defaultMarkdownTheme } from "./test-themes.js";
import { VirtualTerminal } from "./virtual-terminal.js";
@@ -1247,3 +1248,68 @@ describe("Module-level LRU render cache", () => {
expect(lines2).toEqual(lines1);
});
});
describe("OSC 66 text-sizing headings", () => {
const OSC66_INTRO = "\x1b]66;";
afterEach(() => {
// The capability gate is process-global; never let it leak into other suites.
setTextSizing(false);
});
it("keeps H1 as plain ANSI when text-sizing is disabled (default)", () => {
expect(getTextSizing()).toBe(false);
const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(80);
expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false);
expect(lines.some(line => stripVTControlCharacters(line).includes("Hello"))).toBe(true);
});
it("emits a scale-2 OSC 66 span for H1 when text-sizing is enabled and width allows", () => {
setTextSizing(true);
const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(80);
const oscLine = lines.find(line => line.includes(OSC66_INTRO));
expect(oscLine).toBeTruthy();
expect(oscLine!).toContain("s=2");
// The heading text rides inside the OSC 66 payload, so it survives in the
// raw bytes (stripVTControlCharacters would drop the whole OSC span).
expect(oscLine!.includes("Hello")).toBe(true);
// Native + emit agree: a scale-2 span measures exactly twice the plain
// heading width regardless of how the span is internally encoded.
expect(visibleWidth(oscLine!)).toBe(2 * visibleWidth("Hello"));
});
it("leaves the reserved row after a scale-2 H1 as a cursor-only blank", () => {
setTextSizing(true);
const lines = new Markdown("# Hello\n\nBody", 0, 0, defaultMarkdownTheme).render(80);
const oscIndex = lines.findIndex(line => line.includes(OSC66_INTRO));
expect(oscIndex).toBeGreaterThanOrEqual(0);
expect(lines[oscIndex + 1]).toBe("");
expect(lines.some(line => stripVTControlCharacters(line).includes("Body"))).toBe(true);
});
it("doubles the measured width for wide/emoji H1 glyphs", () => {
setTextSizing(true);
const lines = new Markdown("# 🚀 Hi", 0, 0, defaultMarkdownTheme).render(80);
const oscLine = lines.find(line => line.includes(OSC66_INTRO));
expect(oscLine).toBeTruthy();
expect(visibleWidth(oscLine!)).toBe(2 * visibleWidth("🚀 Hi"));
});
it("falls back to ANSI when the doubled H1 width would overflow the render width", () => {
setTextSizing(true);
// "Hello" is 5 cells; 2*5 = 10 > 8 render columns, so the OSC path is skipped.
const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(8);
expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false);
expect(lines.some(line => stripVTControlCharacters(line).includes("Hello"))).toBe(true);
});
it("keeps H2 as plain ANSI even when text-sizing is enabled", () => {
setTextSizing(true);
const lines = new Markdown("## Sub", 0, 0, defaultMarkdownTheme).render(80);
expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false);
expect(lines.some(line => stripVTControlCharacters(line).includes("Sub"))).toBe(true);
});
});
+19 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from "bun:test";
import { extractSegments, sliceWithWidth, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils";
import {
encodeTextSized,
extractSegments,
sliceWithWidth,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui/utils";
describe("text utils", () => {
it("computes visible width for ANSI and tabs", () => {
@@ -78,6 +84,18 @@ describe("text utils", () => {
expect(result.afterWidth).toBeGreaterThan(0);
});
it("encodes OSC 66 text sizing spans with ST terminators", () => {
const encoded = encodeTextSized("Hi", {
scale: 2,
widthCells: 3,
verticalAlign: "center",
horizontalAlign: "right",
});
expect(encoded).toBe("\x1b]66;s=2:w=3:v=2:h=1;Hi\x1b\\");
expect(visibleWidth(encoded)).toBe(6);
expect(encodeTextSized("A\nB", { scale: 1 })).toBe("\x1b]66;s=1;A B\x1b\\");
});
it("counts OSC 66 text-sizing spans as visible text", () => {
expect(visibleWidth("\x1b]66;s=2;Hi\x1b\\")).toBe(4);
expect(visibleWidth("\x1b]66;w=5;Hi\x1b\\")).toBe(5);