53aaced81f
- Added `textSizing` terminal capability and `tui.textSizing` setting for Kitty OSC66 scaling. - Updated OSC66 range and visible-width handling to preserve grapheme slice behavior. - Updated markdown rendering to apply heading text sizing only when enabled. - Fixed DECCARA trailing-background fill tracking and DECRPM status 3/4 recognition.
268 lines
8.9 KiB
TypeScript
268 lines
8.9 KiB
TypeScript
/**
|
|
* Terminal protocol smoke-test panel for the debug menu.
|
|
*
|
|
* Exercises every "special" escape protocol the renderer can emit so a human
|
|
* can eyeball which ones the active terminal actually honors:
|
|
* - SGR text styling + 24-bit truecolor,
|
|
* - OSC 8 hyperlinks,
|
|
* - OSC 66 text sizing (large text),
|
|
* - inline graphics (Kitty / iTerm2 / Sixel),
|
|
* - OSC 9 / OSC 99 desktop notifications (fired by the caller).
|
|
*
|
|
* The sample image is generated in-process (a deterministic RGB gradient PNG)
|
|
* so the graphics test needs no asset on disk and works across all three image
|
|
* protocols, each of which decodes a standard PNG.
|
|
*/
|
|
import * as zlib from "node:zlib";
|
|
import {
|
|
type Component,
|
|
Container,
|
|
encodeTextSized,
|
|
Image,
|
|
type ImageBudget,
|
|
ImageProtocol,
|
|
NotifyProtocol,
|
|
Spacer,
|
|
TERMINAL,
|
|
Text,
|
|
type TextSizingScale,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { DynamicBorder } from "../modes/components/dynamic-border";
|
|
import { theme } from "../modes/theme/theme";
|
|
|
|
const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
|
|
|
|
/** Frame a PNG chunk: 4-byte big-endian length, type+data, then the CRC-32 of type+data. */
|
|
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
|
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
const out = Buffer.alloc(body.length + 8);
|
|
out.writeUInt32BE(data.length, 0);
|
|
body.copy(out, 4);
|
|
out.writeUInt32BE(Bun.hash.crc32(body) >>> 0, out.length - 4);
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Encode raw 8-bit RGB pixels (`width * height * 3` bytes, row-major) as a PNG
|
|
* (color type 2, no interlacing). The IDAT payload is a real zlib stream from
|
|
* {@link zlib.deflateSync}, so the output is a fully valid PNG that every image
|
|
* protocol — including Sixel, which decodes the bytes natively — accepts.
|
|
*/
|
|
export function encodeRgbPng(width: number, height: number, rgb: Uint8Array): Uint8Array {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 2; // color type: truecolor RGB
|
|
// compression (0), filter (0), interlace (0) are already zeroed.
|
|
|
|
const stride = width * 3;
|
|
// Each scanline is prefixed with a filter-type byte (0 = None).
|
|
const raw = Buffer.alloc((stride + 1) * height);
|
|
for (let y = 0; y < height; y++) {
|
|
raw.set(rgb.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
|
|
}
|
|
const idat = zlib.deflateSync(raw);
|
|
|
|
return Buffer.concat([
|
|
PNG_SIGNATURE,
|
|
pngChunk("IHDR", ihdr),
|
|
pngChunk("IDAT", idat),
|
|
pngChunk("IEND", new Uint8Array(0)),
|
|
]);
|
|
}
|
|
|
|
export interface SampleImage {
|
|
base64: string;
|
|
mimeType: string;
|
|
dimensions: { widthPx: number; heightPx: number };
|
|
}
|
|
|
|
/** Build a deterministic RGB gradient PNG (red across, green down, constant blue). */
|
|
export function buildSampleImage(width = 192, height = 128): SampleImage {
|
|
const denomX = Math.max(1, width - 1);
|
|
const denomY = Math.max(1, height - 1);
|
|
const rgb = new Uint8Array(width * height * 3);
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const i = (y * width + x) * 3;
|
|
rgb[i] = Math.round((x / denomX) * 255);
|
|
rgb[i + 1] = Math.round((y / denomY) * 255);
|
|
rgb[i + 2] = 128;
|
|
}
|
|
}
|
|
const png = encodeRgbPng(width, height, rgb);
|
|
return {
|
|
base64: Buffer.from(png).toString("base64"),
|
|
mimeType: "image/png",
|
|
dimensions: { widthPx: width, heightPx: height },
|
|
};
|
|
}
|
|
|
|
const LARGE_TEXT_SAMPLE = "Aa Bb 123";
|
|
|
|
/**
|
|
* OSC 66 text-sizing sample lines, one scaled span per requested scale. Each
|
|
* scaled row is followed by `scale - 1` blank rows that reserve the vertical
|
|
* cells its multi-cell glyphs occupy — mirroring the markdown H1 renderer so
|
|
* the next line does not paint over the bottom of the glyphs.
|
|
*/
|
|
export function buildLargeTextLines(scales: readonly TextSizingScale[] = [2, 3]): string[] {
|
|
const lines: string[] = [];
|
|
for (const scale of scales) {
|
|
lines.push(` ${theme.fg("accent", encodeTextSized(`${LARGE_TEXT_SAMPLE} (${scale}x)`, { scale }))}`);
|
|
for (let reserved = 1; reserved < scale; reserved++) lines.push("");
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
/** HSV (h in degrees, s/v in 0..1) to 8-bit RGB, for the truecolor demo bar. */
|
|
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
|
const c = v * s;
|
|
const hp = (((h % 360) + 360) % 360) / 60;
|
|
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
let r = 0;
|
|
let g = 0;
|
|
let b = 0;
|
|
if (hp < 1) [r, g, b] = [c, x, 0];
|
|
else if (hp < 2) [r, g, b] = [x, c, 0];
|
|
else if (hp < 3) [r, g, b] = [0, c, x];
|
|
else if (hp < 4) [r, g, b] = [0, x, c];
|
|
else if (hp < 5) [r, g, b] = [x, 0, c];
|
|
else [r, g, b] = [c, 0, x];
|
|
const m = v - c;
|
|
const to8 = (n: number) => Math.round((n + m) * 255);
|
|
return [to8(r), to8(g), to8(b)];
|
|
}
|
|
|
|
/** A 24-bit-color hue sweep rendered as background-painted cells (one space each). */
|
|
function truecolorBar(cells: number): string {
|
|
let out = "";
|
|
for (let i = 0; i < cells; i++) {
|
|
const [r, g, b] = hsvToRgb((i / cells) * 360, 0.85, 1);
|
|
out += `\x1b[48;2;${r};${g};${b}m `;
|
|
}
|
|
return `${out}\x1b[0m`;
|
|
}
|
|
|
|
function notifyProtocolLabel(): string {
|
|
switch (TERMINAL.notifyProtocol) {
|
|
case NotifyProtocol.Osc99:
|
|
return "OSC 99 (kitty)";
|
|
case NotifyProtocol.Osc9:
|
|
return "OSC 9 (iTerm2/WezTerm)";
|
|
default:
|
|
return "BEL";
|
|
}
|
|
}
|
|
|
|
function imageProtocolLabel(): string {
|
|
switch (TERMINAL.imageProtocol) {
|
|
case ImageProtocol.Kitty:
|
|
return "Kitty graphics";
|
|
case ImageProtocol.Iterm2:
|
|
return "iTerm2 inline images";
|
|
case ImageProtocol.Sixel:
|
|
return "Sixel";
|
|
default:
|
|
return "none — text fallback";
|
|
}
|
|
}
|
|
|
|
/** Emits a fixed list of lines verbatim — no wrapping or padding. Used for OSC 66 spans. */
|
|
class RawLines implements Component {
|
|
#lines: readonly string[];
|
|
constructor(lines: readonly string[]) {
|
|
this.#lines = lines;
|
|
}
|
|
invalidate(): void {}
|
|
render(): string[] {
|
|
return [...this.#lines];
|
|
}
|
|
}
|
|
|
|
export interface ProtocolProbeOptions {
|
|
image: SampleImage;
|
|
imageBudget: ImageBudget;
|
|
/** Whether the desktop notification was suppressed (e.g. `PI_NOTIFICATIONS=off`). */
|
|
notificationSuppressed: boolean;
|
|
}
|
|
|
|
/**
|
|
* Self-contained panel that renders one sample of every special terminal
|
|
* protocol into the chat transcript.
|
|
*/
|
|
export class ProtocolProbeComponent extends Container {
|
|
constructor(options: ProtocolProbeOptions) {
|
|
super();
|
|
const hyperlinksOn = TERMINAL.hyperlinks;
|
|
const sizingOn = TERMINAL.textSizing;
|
|
const yesNo = (on: boolean) => (on ? theme.fg("success", "supported") : theme.fg("muted", "unsupported"));
|
|
|
|
this.addChild(new DynamicBorder());
|
|
this.addChild(new Text(theme.bold(theme.fg("accent", "Terminal Protocol Test")), 1, 0));
|
|
|
|
// Styling: SGR attributes, themed foregrounds, and a truecolor sweep.
|
|
const styling = [
|
|
theme.fg("muted", "Styling (SGR)"),
|
|
` ${theme.bold("bold")} ${theme.italic("italic")} ${theme.underline("underline")} ${theme.strikethrough("strike")} ${theme.inverse(" inverse ")} ${theme.fg("dim", "dim")}`,
|
|
` ${theme.fg("accent", "accent")} ${theme.fg("success", "success")} ${theme.fg("warning", "warning")} ${theme.fg("error", "error")}`,
|
|
` truecolor: ${truecolorBar(32)} (${theme.fg("muted", `24-bit ${TERMINAL.trueColor ? "on" : "off"}`)})`,
|
|
].join("\n");
|
|
this.addChild(new Text(styling, 1, 0));
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Hyperlinks: OSC 8. Renders as plain text where unsupported.
|
|
this.addChild(
|
|
new Text(
|
|
[
|
|
`${theme.fg("muted", "Hyperlinks (OSC 8)")} — ${yesNo(hyperlinksOn)}`,
|
|
` \x1b]8;;https://github.com/can1357/oh-my-pi\x07oh-my-pi repo\x1b]8;;\x07`,
|
|
].join("\n"),
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Text sizing: OSC 66.
|
|
this.addChild(new Text(`${theme.fg("muted", "Text sizing (OSC 66)")} — ${yesNo(sizingOn)}`, 1, 0));
|
|
if (sizingOn) {
|
|
this.addChild(new RawLines(buildLargeTextLines()));
|
|
} else {
|
|
this.addChild(
|
|
new Text(theme.fg("dim", " (enable via the tui.textSizing setting on a Kitty terminal)"), 1, 0),
|
|
);
|
|
}
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Graphics: Kitty / iTerm2 / Sixel, with a text fallback baked into Image.
|
|
this.addChild(new Text(`${theme.fg("muted", "Graphics")} — ${theme.fg("dim", imageProtocolLabel())}`, 1, 0));
|
|
this.addChild(
|
|
new Image(
|
|
options.image.base64,
|
|
options.image.mimeType,
|
|
{ fallbackColor: (text: string) => theme.fg("toolOutput", text) },
|
|
// Fixed modest caps (not the user's inline-image setting) keep the
|
|
// swatch a crisp, bounded preview rather than an upscaled wall.
|
|
{ maxWidthCells: 20, maxHeightCells: 16, budget: options.imageBudget },
|
|
options.image.dimensions,
|
|
),
|
|
);
|
|
this.addChild(new Spacer(1));
|
|
|
|
// Notifications: fired by the caller; this line reports the outcome.
|
|
const notifyStatus = options.notificationSuppressed
|
|
? theme.fg("warning", "suppressed (PI_NOTIFICATIONS)")
|
|
: theme.fg("success", "sent — check your desktop / titlebar");
|
|
this.addChild(
|
|
new Text(
|
|
`${theme.fg("muted", "Notification")} (${theme.fg("dim", notifyProtocolLabel())}) — ${notifyStatus}`,
|
|
1,
|
|
0,
|
|
),
|
|
);
|
|
this.addChild(new DynamicBorder());
|
|
}
|
|
}
|