feat(packages/coding-agent): added terminal protocol debug diagnostics
- Added debug menu options for terminal state and protocol checks with new handlers. - Added terminal diagnostics models and collectors to report geometry, protocol flags, and multiplexer data. - Added protocol-probe utilities to encode RGB PNGs, generate sample content, and render test panels. - Added support components for verbatim line rendering and OSC66-scaled text rows. - Added tests validating image encoding, OSC66 output, and terminal state formatting.
This commit is contained in:
@@ -6,7 +6,17 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as url from "node:url";
|
||||
import { getWorkProfile } from "@oh-my-pi/pi-natives";
|
||||
import { Container, Loader, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
import {
|
||||
Container,
|
||||
isNotificationSuppressed,
|
||||
Loader,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Spacer,
|
||||
TERMINAL,
|
||||
type TerminalNotification,
|
||||
Text,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { getSessionsDir } from "@oh-my-pi/pi-utils";
|
||||
import { DynamicBorder } from "../modes/components/dynamic-border";
|
||||
import { getSelectListTheme, getSymbolTheme, theme } from "../modes/theme/theme";
|
||||
@@ -15,10 +25,12 @@ import { formatBytes } from "../tools/render-utils";
|
||||
import { openPath } from "../utils/open";
|
||||
import { DebugLogViewerComponent } from "./log-viewer";
|
||||
import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler";
|
||||
import { buildSampleImage, ProtocolProbeComponent } from "./protocol-probe";
|
||||
import { RawSseViewerComponent } from "./raw-sse";
|
||||
import { resolveRawSseDebugBuffer } from "./raw-sse-buffer";
|
||||
import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle";
|
||||
import { collectSystemInfo, formatSystemInfo } from "./system-info";
|
||||
import { collectTerminalState, formatTerminalState } from "./terminal-info";
|
||||
|
||||
/** Debug menu options */
|
||||
const DEBUG_MENU_ITEMS: SelectItem[] = [
|
||||
@@ -29,6 +41,12 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [
|
||||
{ value: "memory", label: "Report: memory issue", description: "Heap snapshot + bundle" },
|
||||
{ value: "logs", label: "View: recent logs", description: "Show last 50 log entries" },
|
||||
{ value: "system", label: "View: system info", description: "Show environment details" },
|
||||
{ value: "terminal", label: "View: terminal state", description: "Subprotocols, geometry, scrollback strategy" },
|
||||
{
|
||||
value: "protocols",
|
||||
label: "Test: terminal protocols",
|
||||
description: "Styling, links, text sizing, graphics, notify",
|
||||
},
|
||||
{ value: "raw-sse", label: "View: raw SSE stream", description: "Show live provider SSE frames" },
|
||||
{
|
||||
value: "transcript",
|
||||
@@ -106,6 +124,12 @@ export class DebugSelectorComponent extends Container {
|
||||
case "system":
|
||||
await this.#handleViewSystemInfo();
|
||||
break;
|
||||
case "terminal":
|
||||
await this.#handleViewTerminalState();
|
||||
break;
|
||||
case "protocols":
|
||||
await this.#handleViewProtocols();
|
||||
break;
|
||||
case "transcript":
|
||||
await this.#handleTranscriptExport();
|
||||
break;
|
||||
@@ -352,6 +376,48 @@ export class DebugSelectorComponent extends Container {
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleViewTerminalState(): Promise<void> {
|
||||
const info = collectTerminalState({
|
||||
columns: this.ctx.ui.terminal.columns,
|
||||
rows: this.ctx.ui.terminal.rows,
|
||||
synchronizedOutput: this.ctx.ui.synchronizedOutput,
|
||||
});
|
||||
const formatted = formatTerminalState(info);
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
||||
this.ctx.chatContainer.addChild(new Text(formatted, 1, 0));
|
||||
this.ctx.chatContainer.addChild(new DynamicBorder());
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleViewProtocols(): Promise<void> {
|
||||
// Fire the desktop notification as a real side effect, then render a
|
||||
// panel that samples every other special protocol and reports the
|
||||
// notification outcome.
|
||||
const suppressed = isNotificationSuppressed();
|
||||
if (!suppressed) {
|
||||
const sessionName = this.ctx.sessionManager.getSessionName();
|
||||
const notification: TerminalNotification = {
|
||||
title: sessionName || "Oh My Pi",
|
||||
body: "Terminal protocol test",
|
||||
type: "test",
|
||||
actions: "focus",
|
||||
};
|
||||
TERMINAL.sendNotification(notification);
|
||||
}
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
new ProtocolProbeComponent({
|
||||
image: buildSampleImage(),
|
||||
imageBudget: this.ctx.ui.imageBudget,
|
||||
notificationSuppressed: suppressed,
|
||||
}),
|
||||
);
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async #handleTranscriptExport(): Promise<void> {
|
||||
await this.ctx.handleDebugTranscriptCommand();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 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,
|
||||
getTextSizing,
|
||||
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 = getTextSizing();
|
||||
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 with PI_TUI_TEXT_SIZING=1 on a supporting 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, imageKey: "debug:protocol-probe" },
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Terminal state collection for the debug menu.
|
||||
*
|
||||
* Surfaces the detected terminal, the established subprotocols the renderer
|
||||
* negotiated (graphics, desktop notifications, hyperlinks, true color), the
|
||||
* scrollback/erase strategy, and the live geometry — the details that decide
|
||||
* which escape sequences the renderer emits.
|
||||
*/
|
||||
import {
|
||||
getCellDimensions,
|
||||
ImageProtocol,
|
||||
isOsc99Supported,
|
||||
NotifyProtocol,
|
||||
TERMINAL,
|
||||
TERMINAL_ID,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
|
||||
/** Live values the debug view reads off the running TUI, not the static capability table. */
|
||||
export interface TerminalRuntimeState {
|
||||
columns: number;
|
||||
rows: number;
|
||||
/** Whether DEC 2026 synchronized-output wrappers are currently emitted. */
|
||||
synchronizedOutput: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalStateInfo {
|
||||
detectedId: string;
|
||||
columns: number;
|
||||
rows: number;
|
||||
cellWidthPx: number;
|
||||
cellHeightPx: number;
|
||||
trueColor: boolean;
|
||||
imageProtocol: string;
|
||||
notifyProtocol: string;
|
||||
osc99Confirmed: boolean;
|
||||
hyperlinks: boolean;
|
||||
deccara: boolean;
|
||||
screenToScrollback: boolean;
|
||||
eagerEraseScrollbackRisk: boolean;
|
||||
synchronizedOutput: boolean;
|
||||
multiplexer: string | null;
|
||||
env: { TERM?: string; TERM_PROGRAM?: string; TERM_PROGRAM_VERSION?: string; COLORTERM?: string };
|
||||
}
|
||||
|
||||
const IMAGE_PROTOCOL_NAMES: Record<ImageProtocol, string> = {
|
||||
[ImageProtocol.Kitty]: "Kitty graphics",
|
||||
[ImageProtocol.Iterm2]: "iTerm2 inline images",
|
||||
[ImageProtocol.Sixel]: "Sixel",
|
||||
};
|
||||
|
||||
const NOTIFY_PROTOCOL_NAMES: Record<NotifyProtocol, string> = {
|
||||
[NotifyProtocol.Bell]: "BEL (\\a)",
|
||||
[NotifyProtocol.Osc99]: "OSC 99 (kitty desktop notifications)",
|
||||
[NotifyProtocol.Osc9]: "OSC 9 (iTerm2/WezTerm)",
|
||||
};
|
||||
|
||||
/** Identify the multiplexer wrapping the session, if any (mirrors the renderer's gate). */
|
||||
function detectMultiplexer(env: NodeJS.ProcessEnv): string | null {
|
||||
if (env.TMUX) return "tmux";
|
||||
if (env.STY) return "screen";
|
||||
if (env.ZELLIJ) return "zellij";
|
||||
const term = env.TERM?.toLowerCase() ?? "";
|
||||
if (term.startsWith("tmux")) return "tmux";
|
||||
if (term.startsWith("screen")) return "screen";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Snapshot the active terminal capabilities and the live runtime geometry. */
|
||||
export function collectTerminalState(runtime: TerminalRuntimeState): TerminalStateInfo {
|
||||
const env = Bun.env;
|
||||
const cell = getCellDimensions();
|
||||
return {
|
||||
detectedId: TERMINAL_ID,
|
||||
columns: runtime.columns,
|
||||
rows: runtime.rows,
|
||||
cellWidthPx: cell.widthPx,
|
||||
cellHeightPx: cell.heightPx,
|
||||
trueColor: TERMINAL.trueColor,
|
||||
imageProtocol: TERMINAL.imageProtocol === null ? "none" : IMAGE_PROTOCOL_NAMES[TERMINAL.imageProtocol],
|
||||
notifyProtocol: NOTIFY_PROTOCOL_NAMES[TERMINAL.notifyProtocol],
|
||||
osc99Confirmed: isOsc99Supported(),
|
||||
hyperlinks: TERMINAL.hyperlinks,
|
||||
deccara: TERMINAL.deccara,
|
||||
screenToScrollback: TERMINAL.supportsScreenToScrollback,
|
||||
eagerEraseScrollbackRisk: TERMINAL.eagerEraseScrollbackRisk,
|
||||
synchronizedOutput: runtime.synchronizedOutput,
|
||||
multiplexer: detectMultiplexer(env),
|
||||
env: {
|
||||
TERM: env.TERM,
|
||||
TERM_PROGRAM: env.TERM_PROGRAM,
|
||||
TERM_PROGRAM_VERSION: env.TERM_PROGRAM_VERSION,
|
||||
COLORTERM: env.COLORTERM,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const yesNo = (value: boolean): string => (value ? "yes" : "no");
|
||||
|
||||
/** Format terminal state for display in the debug menu. */
|
||||
export function formatTerminalState(info: TerminalStateInfo): string {
|
||||
const lines = [
|
||||
"Terminal State",
|
||||
"━━━━━━━━━━━━━━",
|
||||
`Detected: ${info.detectedId}`,
|
||||
`Geometry: ${info.columns}x${info.rows} cells · cell ${info.cellWidthPx}x${info.cellHeightPx}px`,
|
||||
info.multiplexer ? `Multiplexer: ${info.multiplexer}` : "Multiplexer: none",
|
||||
"",
|
||||
"Subprotocols",
|
||||
` Graphics: ${info.imageProtocol}`,
|
||||
` Notify: ${info.notifyProtocol}${info.osc99Confirmed ? " · confirmed via DA" : ""}`,
|
||||
` Hyperlinks: ${yesNo(info.hyperlinks)} (OSC 8)`,
|
||||
` True color: ${yesNo(info.trueColor)} (24-bit SGR)`,
|
||||
` DECCARA: ${yesNo(info.deccara)} (rectangular-SGR background fills)`,
|
||||
` Sync output: ${yesNo(info.synchronizedOutput)} (DEC 2026)`,
|
||||
"",
|
||||
"Scrollback",
|
||||
` Screen->history clear: ${info.screenToScrollback ? "CSI 22 J" : "CSI 2 J (redraw)"}`,
|
||||
` Eager-erase risk: ${yesNo(info.eagerEraseScrollbackRisk)} (ED3 may yank scrolled readers)`,
|
||||
"",
|
||||
"Detection signals",
|
||||
` TERM: ${info.env.TERM ?? "(unset)"}`,
|
||||
` TERM_PROGRAM: ${info.env.TERM_PROGRAM ?? "(unset)"}`,
|
||||
` TERM_PROGRAM_VERSION: ${info.env.TERM_PROGRAM_VERSION ?? "(unset)"}`,
|
||||
` COLORTERM: ${info.env.COLORTERM ?? "(unset)"}`,
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { getImageDimensions } from "@oh-my-pi/pi-tui";
|
||||
import { buildLargeTextLines, buildSampleImage, encodeRgbPng } from "../../src/debug/protocol-probe";
|
||||
import { initTheme } from "../../src/modes/theme/theme";
|
||||
|
||||
beforeAll(async () => {
|
||||
// buildLargeTextLines styles the OSC 66 span through the global theme singleton.
|
||||
await initTheme();
|
||||
});
|
||||
|
||||
describe("encodeRgbPng / buildSampleImage", () => {
|
||||
it("emits a PNG whose IHDR dimensions decode back to the requested size", () => {
|
||||
// The graphics test relies on producing a *valid* PNG: Kitty/iTerm2 hand
|
||||
// the bytes to the terminal and Sixel decodes them natively, so a malformed
|
||||
// header (wrong CRC, bad chunk framing) silently breaks the whole panel.
|
||||
const sample = buildSampleImage(48, 24);
|
||||
expect(sample.mimeType).toBe("image/png");
|
||||
expect(sample.dimensions).toEqual({ widthPx: 48, heightPx: 24 });
|
||||
|
||||
const decoded = getImageDimensions(sample.base64, "image/png");
|
||||
expect(decoded).toEqual({ widthPx: 48, heightPx: 24 });
|
||||
});
|
||||
|
||||
it("begins with the PNG signature and round-trips arbitrary pixel sizes", () => {
|
||||
const png = encodeRgbPng(3, 2, new Uint8Array(3 * 2 * 3));
|
||||
expect(Array.from(png.subarray(0, 8))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
const decoded = getImageDimensions(Buffer.from(png).toString("base64"), "image/png");
|
||||
expect(decoded).toEqual({ widthPx: 3, heightPx: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLargeTextLines", () => {
|
||||
it("encodes each scale as an OSC 66 span and reserves scale-1 blank rows below it", () => {
|
||||
const lines = buildLargeTextLines([2, 3]);
|
||||
// scale 2 → 1 reserved row, scale 3 → 2 reserved rows.
|
||||
expect(lines).toHaveLength(5);
|
||||
expect(lines[0]).toContain("\x1b]66;s=2;");
|
||||
expect(lines[1]).toBe("");
|
||||
expect(lines[2]).toContain("\x1b]66;s=3;");
|
||||
expect(lines[3]).toBe("");
|
||||
expect(lines[4]).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { TERMINAL } from "@oh-my-pi/pi-tui";
|
||||
import { collectTerminalState, formatTerminalState, type TerminalStateInfo } from "../../src/debug/terminal-info";
|
||||
|
||||
const sample: TerminalStateInfo = {
|
||||
detectedId: "kitty",
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
cellWidthPx: 9,
|
||||
cellHeightPx: 18,
|
||||
trueColor: true,
|
||||
imageProtocol: "Kitty graphics",
|
||||
notifyProtocol: "OSC 99 (kitty desktop notifications)",
|
||||
osc99Confirmed: true,
|
||||
hyperlinks: false,
|
||||
deccara: true,
|
||||
screenToScrollback: true,
|
||||
eagerEraseScrollbackRisk: true,
|
||||
synchronizedOutput: false,
|
||||
multiplexer: null,
|
||||
env: { TERM: "xterm-kitty", TERM_PROGRAM: undefined, TERM_PROGRAM_VERSION: undefined, COLORTERM: "truecolor" },
|
||||
};
|
||||
|
||||
describe("formatTerminalState", () => {
|
||||
it("surfaces the negotiated subprotocols and their on/off state", () => {
|
||||
const out = formatTerminalState(sample);
|
||||
expect(out).toContain("Detected: kitty");
|
||||
expect(out).toContain("Graphics: Kitty graphics");
|
||||
expect(out).toContain("Notify: OSC 99 (kitty desktop notifications) · confirmed via DA");
|
||||
expect(out).toContain("Hyperlinks: no (OSC 8)");
|
||||
expect(out).toContain("True color: yes (24-bit SGR)");
|
||||
expect(out).toContain("DECCARA: yes (rectangular-SGR background fills)");
|
||||
expect(out).toContain("Sync output: no (DEC 2026)");
|
||||
});
|
||||
|
||||
it("reports geometry, cell size, and the scrollback strategy", () => {
|
||||
const out = formatTerminalState(sample);
|
||||
expect(out).toContain("120x40 cells · cell 9x18px");
|
||||
// supportsScreenToScrollback -> the non-destructive CSI 22 J clear.
|
||||
expect(out).toContain("Screen->history clear: CSI 22 J");
|
||||
expect(out).toContain("Eager-erase risk: yes");
|
||||
});
|
||||
|
||||
it("renders the redraw fallback when screen-to-scrollback is unsupported", () => {
|
||||
const out = formatTerminalState({ ...sample, screenToScrollback: false });
|
||||
expect(out).toContain("Screen->history clear: CSI 2 J (redraw)");
|
||||
});
|
||||
|
||||
it("drops the OSC-99-confirmed marker when the terminal never answered the probe", () => {
|
||||
const out = formatTerminalState({ ...sample, osc99Confirmed: false });
|
||||
expect(out).toContain("Notify: OSC 99 (kitty desktop notifications)");
|
||||
expect(out).not.toContain("confirmed via DA");
|
||||
});
|
||||
|
||||
it("shows 'none' for no multiplexer and '(unset)' for absent detection vars", () => {
|
||||
const out = formatTerminalState(sample);
|
||||
expect(out).toContain("Multiplexer: none");
|
||||
expect(out).toContain("TERM: xterm-kitty");
|
||||
expect(out).toContain("COLORTERM: truecolor");
|
||||
expect(out).toContain("TERM_PROGRAM: (unset)");
|
||||
});
|
||||
|
||||
it("names the multiplexer when one wraps the session", () => {
|
||||
expect(formatTerminalState({ ...sample, multiplexer: "tmux" })).toContain("Multiplexer: tmux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectTerminalState", () => {
|
||||
it("passes live geometry through and maps protocols to human-readable names (never raw escapes)", () => {
|
||||
const info = collectTerminalState({ columns: 88, rows: 25, synchronizedOutput: true });
|
||||
expect(info.columns).toBe(88);
|
||||
expect(info.rows).toBe(25);
|
||||
expect(info.synchronizedOutput).toBe(true);
|
||||
// The graphics/notify subprotocols are surfaced as readable labels, not the
|
||||
// enum's underlying escape sequences (\x1b_G, \x1b]99;;, …).
|
||||
expect(info.imageProtocol).not.toContain("\x1b");
|
||||
expect(info.notifyProtocol).not.toContain("\x1b");
|
||||
expect(info.imageProtocol.length).toBeGreaterThan(0);
|
||||
expect(info.notifyProtocol.length).toBeGreaterThan(0);
|
||||
// Capability booleans mirror the resolved TERMINAL singleton.
|
||||
expect(info.deccara).toBe(TERMINAL.deccara);
|
||||
expect(info.hyperlinks).toBe(TERMINAL.hyperlinks);
|
||||
expect(info.trueColor).toBe(TERMINAL.trueColor);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user