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:
can1357
2026-06-04 00:50:46 +02:00
parent cb6ca2115b
commit 48017b5f67
5 changed files with 591 additions and 1 deletions
+67 -1
View File
@@ -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);
});
});