diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index bbb8af903..eece0d938 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -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 { + 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 { + // 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 { await this.ctx.handleDebugTranscriptCommand(); } diff --git a/packages/coding-agent/src/debug/protocol-probe.ts b/packages/coding-agent/src/debug/protocol-probe.ts new file mode 100644 index 000000000..1d3ea4726 --- /dev/null +++ b/packages/coding-agent/src/debug/protocol-probe.ts @@ -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()); + } +} diff --git a/packages/coding-agent/src/debug/terminal-info.ts b/packages/coding-agent/src/debug/terminal-info.ts new file mode 100644 index 000000000..7d33a70b0 --- /dev/null +++ b/packages/coding-agent/src/debug/terminal-info.ts @@ -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.Kitty]: "Kitty graphics", + [ImageProtocol.Iterm2]: "iTerm2 inline images", + [ImageProtocol.Sixel]: "Sixel", +}; + +const NOTIFY_PROTOCOL_NAMES: Record = { + [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"); +} diff --git a/packages/coding-agent/test/debug/protocol-probe.test.ts b/packages/coding-agent/test/debug/protocol-probe.test.ts new file mode 100644 index 000000000..d2d7c1c30 --- /dev/null +++ b/packages/coding-agent/test/debug/protocol-probe.test.ts @@ -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(""); + }); +}); diff --git a/packages/coding-agent/test/debug/terminal-info.test.ts b/packages/coding-agent/test/debug/terminal-info.test.ts new file mode 100644 index 000000000..fdc69144d --- /dev/null +++ b/packages/coding-agent/test/debug/terminal-info.test.ts @@ -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); + }); +});