diff --git a/Cargo.lock b/Cargo.lock index f13afe04e..ab98fcaac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "bon" version = "3.9.0" @@ -396,11 +408,31 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder" @@ -839,6 +871,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.3.0" @@ -970,6 +1008,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -1378,6 +1422,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icy_sixel" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5" +dependencies = [ + "quantette", + "thiserror 2.0.18", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1826,6 +1880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1929,6 +1984,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "os_display" version = "0.1.4" @@ -1948,6 +2012,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "bytemuck", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2124,6 +2212,7 @@ dependencies = [ "grep-searcher", "heapless", "html-to-markdown-rs", + "icy_sixel", "ignore", "image", "inferno", @@ -2273,6 +2362,26 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +[[package]] +name = "quantette" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c98fecda8b16396ff9adac67644a523dd1778c42b58606a29df5c31ca925d174" +dependencies = [ + "bitvec", + "bytemuck", + "image", + "libm", + "num-traits", + "ordered-float", + "palette", + "rand 0.9.2", + "rand_xoshiro", + "rayon", + "ref-cast", + "wide", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -2312,6 +2421,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -2356,6 +2471,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rayon" version = "1.11.0" @@ -2394,6 +2518,26 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -2475,6 +2619,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "safe_arch" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629516c85c29fe757770fa03f2074cf1eac43d44c02a3de9fc2ef7b0e207dfdd" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2746,6 +2899,12 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tendril" version = "0.5.0" @@ -3545,6 +3704,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wide" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ca908d26e4786149c48efcf6c0ea09ab0e06d1fe3c17dc1b4b0f1ca4a7e788" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "widestring" version = "1.2.1" @@ -3959,6 +4128,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11rb" version = "0.13.2" diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index eadd46347..db5d82e65 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -41,6 +41,7 @@ image = { version = "0.25", default-features = false, features = [ "webp", ] } arboard = { version = "3.5.0", features = ["wayland-data-control"] } +icy_sixel = "0.5" bstr = "1" unicode-segmentation = "1.11" unicode-width = "0.2" diff --git a/crates/pi-natives/src/image.rs b/crates/pi-natives/src/image.rs index 506751d58..a543f48c8 100644 --- a/crates/pi-natives/src/image.rs +++ b/crates/pi-natives/src/image.rs @@ -8,6 +8,7 @@ use std::{io::Cursor, sync::Arc}; +use icy_sixel::{EncodeOptions, sixel_encode}; use image::{ DynamicImage, ImageFormat, ImageReader, codecs::{jpeg::JpegEncoder, webp::WebPEncoder}, @@ -65,14 +66,7 @@ impl PhotonImage { pub fn parse(bytes: Uint8Array) -> ImageTask { let bytes = bytes.as_ref().to_vec(); task::blocking("image.decode", (), move |_| -> Result { - let reader = ImageReader::new(Cursor::new(bytes)) - .with_guessed_format() - .map_err(|e| Error::from_reason(format!("Failed to detect image format: {e}")))?; - - let img = reader - .decode() - .map_err(|e| Error::from_reason(format!("Failed to decode image: {e}")))?; - + let img = decode_image_from_bytes(&bytes)?; Ok(Self { img: Arc::new(img) }) }) } @@ -116,6 +110,44 @@ impl PhotonImage { } } +/// Encode image bytes into a SIXEL escape sequence for terminal rendering. +/// +/// The input image is decoded and resized to the requested pixel dimensions +/// before encoding. +/// +/// # Errors +/// Returns an error if decoding, resizing, or SIXEL encoding fails. +#[napi(js_name = "encodeSixel")] +pub fn encode_sixel( + bytes: Uint8Array, + target_width_px: u32, + target_height_px: u32, +) -> Result { + if target_width_px == 0 || target_height_px == 0 { + return Err(Error::from_reason("Target SIXEL dimensions must be greater than zero")); + } + + let source = decode_image_from_bytes(bytes.as_ref())?; + let resized = if source.width() == target_width_px && source.height() == target_height_px { + source + } else { + source.resize_exact(target_width_px, target_height_px, FilterType::Lanczos3) + }; + let rgba = resized.to_rgba8(); + let options = EncodeOptions::default(); + sixel_encode(rgba.as_raw(), target_width_px as usize, target_height_px as usize, &options) + .map_err(|err| Error::from_reason(format!("Failed to encode SIXEL: {err}"))) +} + +fn decode_image_from_bytes(bytes: &[u8]) -> Result { + let reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| Error::from_reason(format!("Failed to detect image format: {e}")))?; + + reader + .decode() + .map_err(|e| Error::from_reason(format!("Failed to decode image: {e}"))) +} fn encode_image(img: &DynamicImage, format: u8, quality: u8) -> Result> { let (w, h) = (img.width(), img.height()); diff --git a/packages/coding-agent/src/modes/components/bash-execution.ts b/packages/coding-agent/src/modes/components/bash-execution.ts index f1f954e89..49695a3c3 100644 --- a/packages/coding-agent/src/modes/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/components/bash-execution.ts @@ -3,9 +3,10 @@ */ import { sanitizeText } from "@oh-my-pi/pi-natives"; -import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@oh-my-pi/pi-tui"; import { getSymbolTheme, theme } from "../../modes/theme/theme"; import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta"; +import { getSixelLineMask, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel"; import { DynamicBorder } from "./dynamic-border"; import { truncateToVisualLines } from "./visual-truncate"; @@ -75,18 +76,18 @@ export class BashExecutionComponent extends Container { } appendOutput(chunk: string): void { - const clean = sanitizeText(chunk); + const clean = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText); // Append to output lines - const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line)); - if (this.#outputLines.length > 0 && newLines.length > 0) { - // Append first chunk to last line (incomplete line continuation) - this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine( - `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`, - ); - this.#outputLines.push(...newLines.slice(1)); + const incomingLines = clean.split("\n"); + if (this.#outputLines.length > 0 && incomingLines.length > 0) { + const lastIndex = this.#outputLines.length - 1; + const mergedLines = [`${this.#outputLines[lastIndex]}${incomingLines[0]}`, ...incomingLines.slice(1)]; + const clampedMergedLines = this.#clampLinesPreservingSixel(mergedLines); + this.#outputLines[lastIndex] = clampedMergedLines[0] ?? ""; + this.#outputLines.push(...clampedMergedLines.slice(1)); } else { - this.#outputLines.push(...newLines); + this.#outputLines.push(...this.#clampLinesPreservingSixel(incomingLines)); } this.#updateDisplay(); @@ -120,6 +121,9 @@ export class BashExecutionComponent extends Container { // Apply preview truncation based on expanded state const previewLogicalLines = availableLines.slice(-PREVIEW_LINES); const hiddenLineCount = availableLines.length - previewLogicalLines.length; + const sixelLineMask = + TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(availableLines) : undefined; + const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false; // Rebuild content container this.#contentContainer.clear(); @@ -130,9 +134,10 @@ export class BashExecutionComponent extends Container { // Output if (availableLines.length > 0) { - if (this.#expanded) { - // Show all lines - const displayText = availableLines.map(line => theme.fg("muted", line)).join("\n"); + if (this.#expanded || hasSixelOutput) { + const displayText = availableLines + .map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line))) + .join("\n"); this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); } else { // Use shared visual truncation utility, recomputed per render width @@ -155,7 +160,7 @@ export class BashExecutionComponent extends Container { const statusParts: string[] = []; // Show how many lines are hidden (collapsed preview) - if (hiddenLineCount > 0) { + if (hiddenLineCount > 0 && !hasSixelOutput) { statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); } @@ -183,9 +188,18 @@ export class BashExecutionComponent extends Container { return `${line.slice(0, MAX_DISPLAY_LINE_CHARS)}… [${omitted} chars omitted]`; } + #clampLinesPreservingSixel(lines: string[]): string[] { + if (lines.length === 0) return []; + const sixelLineMask = getSixelLineMask(lines); + if (!sixelLineMask.some(Boolean)) { + return lines.map(line => this.#clampDisplayLine(line)); + } + return lines.map((line, index) => (sixelLineMask[index] ? line : this.#clampDisplayLine(line))); + } + #setOutput(output: string): void { - const clean = sanitizeText(output); - this.#outputLines = clean ? clean.split("\n").map(line => this.#clampDisplayLine(line)) : []; + const clean = sanitizeWithOptionalSixelPassthrough(output, sanitizeText); + this.#outputLines = clean ? this.#clampLinesPreservingSixel(clean.split("\n")) : []; } /** diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index e71645129..813f7f233 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -34,6 +34,7 @@ import { formatExpandHint, truncateToWidth } from "../../tools/render-utils"; import { toolRenderers } from "../../tools/renderers"; import { renderStatusLine } from "../../tui"; import { convertToPng } from "../../utils/image-convert"; +import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel"; import { renderDiff } from "./diff"; function ensureInvalidate(component: unknown): Component { @@ -589,7 +590,7 @@ export class ToolExecutionComponent extends Container { let output = textBlocks .map((c: any) => { - return sanitizeText(c.text || ""); + return sanitizeWithOptionalSixelPassthrough(c.text || "", sanitizeText); }) .join("\n"); diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts index a79ac624b..c3ce3e741 100644 --- a/packages/coding-agent/src/session/streaming-output.ts +++ b/packages/coding-agent/src/session/streaming-output.ts @@ -1,5 +1,6 @@ import { sanitizeText } from "@oh-my-pi/pi-natives"; import { formatBytes } from "../tools/render-utils"; +import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel"; // ============================================================================= // Constants @@ -571,7 +572,7 @@ export class OutputSink { } async push(chunk: string): Promise { - chunk = sanitizeText(chunk); + chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText); this.#onChunk?.(chunk); const dataBytes = Buffer.byteLength(chunk, "utf-8"); diff --git a/packages/coding-agent/src/tools/bash-interactive.ts b/packages/coding-agent/src/tools/bash-interactive.ts index d98e0139e..fee1f177f 100644 --- a/packages/coding-agent/src/tools/bash-interactive.ts +++ b/packages/coding-agent/src/tools/bash-interactive.ts @@ -14,6 +14,7 @@ import xterm from "@xterm/headless"; import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env"; import type { Theme } from "../modes/theme/theme"; import { OutputSink, type OutputSummary } from "../session/streaming-output"; +import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel"; import { formatStatusIcon, replaceTabs } from "./render-utils"; export interface BashInteractiveResult extends OutputSummary { @@ -24,7 +25,7 @@ export interface BashInteractiveResult extends OutputSummary { function normalizeCaptureChunk(chunk: string): string { const normalized = chunk.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n"); - return sanitizeText(normalized); + return sanitizeWithOptionalSixelPassthrough(normalized, sanitizeText); } const XtermTerminal = xterm.Terminal; diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index faca98752..a0f230b00 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; -import { Text } from "@oh-my-pi/pi-tui"; +import { ImageProtocol, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { $env, getProjectDir, isEnoent } from "@oh-my-pi/pi-utils"; import { Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; @@ -15,6 +15,7 @@ import bashDescription from "../prompts/tools/bash.md" with { type: "text" }; import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output"; import { renderStatusLine } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; +import { getSixelLineMask } from "../utils/sixel"; import type { ToolSession } from "."; import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive"; import { checkBashInterception } from "./bash-interceptor"; @@ -414,14 +415,21 @@ export const bashToolRenderer = { const outputLines: string[] = []; const hasOutput = displayOutput.trim().length > 0; + const rawOutputLines = displayOutput.split("\n"); + const sixelLineMask = + TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined; + const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false; if (hasOutput) { - if (expanded) { + if (hasSixelOutput) { outputLines.push( - ...displayOutput.split("\n").map(line => uiTheme.fg("toolOutput", replaceTabs(line))), + ...rawOutputLines.map((line, index) => + sixelLineMask?.[index] ? line : uiTheme.fg("toolOutput", replaceTabs(line)), + ), ); + } else if (expanded) { + outputLines.push(...rawOutputLines.map(line => uiTheme.fg("toolOutput", replaceTabs(line)))); } else { - const styledOutput = displayOutput - .split("\n") + const styledOutput = rawOutputLines .map(line => uiTheme.fg("toolOutput", replaceTabs(line))) .join("\n"); const textContent = styledOutput; diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index 54808d837..88766a73d 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -1,8 +1,9 @@ /** * Bordered output container with optional header and sections. */ -import { padding, visibleWidth } from "@oh-my-pi/pi-tui"; +import { ImageProtocol, padding, TERMINAL, visibleWidth } from "@oh-my-pi/pi-tui"; import type { Theme } from "../modes/theme/theme"; +import { getSixelLineMask } from "../utils/sixel"; import type { State } from "./types"; import type { RenderCache } from "./utils"; import { getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils"; @@ -80,7 +81,13 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st ); } const allLines = section.lines.flatMap(l => l.split("\n")); - for (const line of allLines) { + const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(allLines) : undefined; + for (let lineIndex = 0; lineIndex < allLines.length; lineIndex++) { + const line = allLines[lineIndex]!; + if (sixelLineMask?.[lineIndex]) { + lines.push(line); + continue; + } // Sections may receive content that was already padded to terminal width // (e.g. from Text.render()). Trailing spaces would trigger truncateToWidth() // to append an ellipsis even when the *semantic* content fits. diff --git a/packages/coding-agent/src/utils/sixel.ts b/packages/coding-agent/src/utils/sixel.ts new file mode 100644 index 000000000..54e901790 --- /dev/null +++ b/packages/coding-agent/src/utils/sixel.ts @@ -0,0 +1,69 @@ +import { $env } from "@oh-my-pi/pi-utils"; + +const SIXEL_START_REGEX = /\x1bP(?:[0-9;]*)q/u; +const SIXEL_END_SEQUENCE = "\x1b\\"; +const SIXEL_END_BELL = "\x07"; +const SIXEL_SEQUENCE_REGEX = /\x1bP(?:[0-9;]*)q[\s\S]*?(?:\x1b\\|\x07)/gu; +const SIXEL_PLACEHOLDER_PREFIX = "__OMP_SIXEL_SEQUENCE_"; + +/** + * Returns whether SIXEL passthrough is explicitly enabled. + * + * Both gates must be enabled to preserve SIXEL control sequences: + * - PI_FORCE_IMAGE_PROTOCOL=sixel + * - PI_ALLOW_SIXEL_PASSTHROUGH=1 + */ +export function isSixelPassthroughEnabled(): boolean { + const forcedProtocol = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase(); + return forcedProtocol === "sixel" && $env.PI_ALLOW_SIXEL_PASSTHROUGH === "1"; +} +/** Returns true when the text contains a SIXEL start sequence. */ +export function containsSixelSequence(text: string): boolean { + return SIXEL_START_REGEX.test(text); +} + +/** + * Returns a boolean mask indicating which lines belong to a SIXEL sequence block. + * Supports multi-line SIXEL payloads generated by libsixel. + */ +export function getSixelLineMask(lines: string[]): boolean[] { + let inSequence = false; + return lines.map(line => { + const hasStart = containsSixelSequence(line); + if (hasStart) { + inSequence = true; + } + const isSixelLine = inSequence; + if (inSequence && (line.includes(SIXEL_END_SEQUENCE) || line.includes(SIXEL_END_BELL))) { + inSequence = false; + } + return isSixelLine; + }); +} + +/** Returns true when the line contains a SIXEL start sequence. */ +export function isSixelLine(line: string): boolean { + return containsSixelSequence(line); +} + +/** + * Sanitizes text while preserving embedded SIXEL sequences when passthrough is enabled. + */ +export function sanitizeWithOptionalSixelPassthrough(text: string, sanitize: (text: string) => string): string { + if (!isSixelPassthroughEnabled() || !containsSixelSequence(text)) { + return sanitize(text); + } + + const preservedSequences: string[] = []; + const tokenized = text.replace(SIXEL_SEQUENCE_REGEX, match => { + const token = `${SIXEL_PLACEHOLDER_PREFIX}${preservedSequences.length}__`; + preservedSequences.push(match); + return token; + }); + + const sanitized = sanitize(tokenized); + return sanitized.replace(/__OMP_SIXEL_SEQUENCE_(\d+)__/gu, (_, indexText: string) => { + const index = Number.parseInt(indexText, 10); + return preservedSequences[index] ?? ""; + }); +} diff --git a/packages/coding-agent/test/bash-execution-sixel.test.ts b/packages/coding-agent/test/bash-execution-sixel.test.ts new file mode 100644 index 000000000..23af6bc22 --- /dev/null +++ b/packages/coding-agent/test/bash-execution-sixel.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { BashExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/bash-execution"; +import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { TUI } from "@oh-my-pi/pi-tui"; + +const SIXEL = "\x1bPqabc\x1b\\"; + +describe("BashExecutionComponent SIXEL sanitization", () => { + const originalForceProtocol = Bun.env.PI_FORCE_IMAGE_PROTOCOL; + const originalAllowPassthrough = Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH; + const ui = { requestRender: () => {} } as unknown as TUI; + + beforeEach(async () => { + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + setThemeInstance(theme!); + }); + afterEach(() => { + if (originalForceProtocol === undefined) delete Bun.env.PI_FORCE_IMAGE_PROTOCOL; + else Bun.env.PI_FORCE_IMAGE_PROTOCOL = originalForceProtocol; + if (originalAllowPassthrough === undefined) delete Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH; + else Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = originalAllowPassthrough; + }); + + it("preserves SIXEL output when passthrough gates are enabled", () => { + Bun.env.PI_FORCE_IMAGE_PROTOCOL = "sixel"; + Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = "1"; + + const component = new BashExecutionComponent("echo sixel", ui, false); + component.appendOutput(SIXEL); + component.setComplete(0, false); + + expect(component.getOutput()).toContain(SIXEL); + }); + + it("does not truncate long SIXEL payload lines", () => { + Bun.env.PI_FORCE_IMAGE_PROTOCOL = "sixel"; + Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = "1"; + + const payload = `\x1bPq${"A".repeat(5000)}\x1b\\`; + const component = new BashExecutionComponent("echo sixel", ui, false); + component.appendOutput(payload); + component.setComplete(0, false); + + const output = component.getOutput(); + expect(output).toContain("\x1bPq"); + expect(output).toContain("\x1b\\"); + expect(output).not.toContain("chars omitted"); + }); + + it("still truncates long non-SIXEL lines", () => { + Bun.env.PI_FORCE_IMAGE_PROTOCOL = "sixel"; + Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = "1"; + + const longText = "x".repeat(5000); + const component = new BashExecutionComponent("echo text", ui, false); + component.appendOutput(longText); + component.setComplete(0, false); + + const output = component.getOutput(); + expect(output).toContain("chars omitted"); + expect(output).not.toContain("\x1bPq"); + }); + + it("strips SIXEL control escapes when passthrough gates are disabled", () => { + delete Bun.env.PI_FORCE_IMAGE_PROTOCOL; + delete Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH; + + const component = new BashExecutionComponent("echo sixel", ui, false); + component.appendOutput(SIXEL); + component.setComplete(0, false); + + expect(component.getOutput()).not.toContain("\x1bPq"); + expect(component.getOutput()).toBe(""); + }); +}); diff --git a/packages/coding-agent/test/output-block.test.ts b/packages/coding-agent/test/output-block.test.ts new file mode 100644 index 000000000..bec2419ee --- /dev/null +++ b/packages/coding-agent/test/output-block.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { renderOutputBlock } from "@oh-my-pi/pi-coding-agent/tui/output-block"; +import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; + +type MutableTerminalInfo = { + imageProtocol: ImageProtocol | null; +}; + +const terminal = TERMINAL as unknown as MutableTerminalInfo; + +describe("renderOutputBlock", () => { + const originalProtocol = TERMINAL.imageProtocol; + + afterEach(() => { + terminal.imageProtocol = originalProtocol; + }); + + it("passes SIXEL lines through without trimming or padding", async () => { + terminal.imageProtocol = ImageProtocol.Sixel; + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const uiTheme = theme!; + const sixel = "\x1bPqabc\x1b\\"; + const lines = renderOutputBlock( + { + width: 40, + sections: [{ label: "Output", lines: ["regular line", sixel] }], + }, + uiTheme, + ); + + expect(lines.filter(line => line === sixel)).toHaveLength(1); + const regularLine = lines.find(line => line.includes("regular line")); + expect(regularLine).toBeDefined(); + expect(regularLine).not.toBe("regular line"); + }); +}); diff --git a/packages/coding-agent/test/streaming-output.test.ts b/packages/coding-agent/test/streaming-output.test.ts index 9eab0a4a1..cc673a79e 100644 --- a/packages/coding-agent/test/streaming-output.test.ts +++ b/packages/coding-agent/test/streaming-output.test.ts @@ -18,6 +18,8 @@ import { } from "../src/session/streaming-output"; const createdTempDirs: string[] = []; +const originalForceProtocol = Bun.env.PI_FORCE_IMAGE_PROTOCOL; +const originalAllowPassthrough = Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH; async function createTempDir(): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "streaming-output-test-")); @@ -33,6 +35,10 @@ afterEach(async () => { for (const dir of createdTempDirs.splice(0)) { await fs.rm(dir, { recursive: true, force: true }); } + if (originalForceProtocol === undefined) delete Bun.env.PI_FORCE_IMAGE_PROTOCOL; + else Bun.env.PI_FORCE_IMAGE_PROTOCOL = originalForceProtocol; + if (originalAllowPassthrough === undefined) delete Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH; + else Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = originalAllowPassthrough; }); describe("streaming-output exports", () => { @@ -227,6 +233,30 @@ describe("OutputSink", () => { expect(chunks).toEqual(["abc", "def"]); }); + test("preserves SIXEL chunks when passthrough gates are enabled", async () => { + const sixel = "\x1bPqabc\x1b\\"; + Bun.env.PI_FORCE_IMAGE_PROTOCOL = "sixel"; + Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH = "1"; + const chunks: string[] = []; + const sink = new OutputSink({ onChunk: chunk => chunks.push(chunk) }); + await sink.push(`before\n${sixel}\nafter`); + const dumped = await sink.dump(); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toContain(sixel); + expect(dumped.output).toContain(sixel); + }); + + test("strips SIXEL chunks when passthrough gates are disabled", async () => { + const sixel = "\x1bPqabc\x1b\\"; + delete Bun.env.PI_FORCE_IMAGE_PROTOCOL; + delete Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH; + const sink = new OutputSink(); + await sink.push(sixel); + const dumped = await sink.dump(); + expect(dumped.output).not.toContain("\x1bPq"); + expect(dumped.output).toBe(""); + }); + test("truncates in-memory output when spill threshold is exceeded", async () => { const sink = new OutputSink({ spillThreshold: 5 }); await sink.push("abc"); diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts new file mode 100644 index 000000000..d12d3c71b --- /dev/null +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; +import { getThemeByName } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { bashToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/bash"; +import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; + +type MutableTerminalInfo = { + imageProtocol: ImageProtocol | null; +}; + +const terminal = TERMINAL as unknown as MutableTerminalInfo; + +describe("bashToolRenderer", () => { + const originalProtocol = TERMINAL.imageProtocol; + + afterEach(() => { + terminal.imageProtocol = originalProtocol; + }); + + it("bypasses truncation/styling for SIXEL lines", async () => { + terminal.imageProtocol = ImageProtocol.Sixel; + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const uiTheme = theme!; + const sixel = "\x1bPqabc\x1b\\"; + const renderOptions: RenderResultOptions & { + renderContext: { + output: string; + expanded: boolean; + previewLines: number; + }; + } = { + expanded: false, + isPartial: false, + renderContext: { + output: `line one\n${sixel}\nline two`, + expanded: false, + previewLines: 1, + }, + }; + + const component = bashToolRenderer.renderResult( + { content: [{ type: "text", text: "" }], details: {}, isError: false }, + renderOptions, + uiTheme, + { command: "echo sixel" }, + ); + const lines = component.render(80); + + expect(lines.filter(line => line === sixel)).toHaveLength(1); + expect(lines.some(line => line.includes("ctrl+o to expand"))).toBe(false); + }); +}); diff --git a/packages/natives/src/image/index.ts b/packages/natives/src/image/index.ts index 4abaea416..4b5a35f9a 100644 --- a/packages/natives/src/image/index.ts +++ b/packages/natives/src/image/index.ts @@ -9,5 +9,8 @@ export { ImageFormat, type PhotonImageConstructor, SamplingFilter } from "./type /** PhotonImage class for image manipulation. Use PhotonImage.parse() to create instances. */ export const PhotonImage = native.PhotonImage; +/** Encode image bytes into a SIXEL escape sequence at target pixel size. */ +export const encodeSixel = native.encodeSixel; + /** PhotonImage instance type. */ export type PhotonImage = import("./types").PhotonImage; diff --git a/packages/natives/src/image/types.ts b/packages/natives/src/image/types.ts index 2b4522148..dba08aa8c 100644 --- a/packages/natives/src/image/types.ts +++ b/packages/natives/src/image/types.ts @@ -61,5 +61,7 @@ declare module "../bindings" { SamplingFilter: typeof SamplingFilter; /** Photon image constructor exposed by the native module. */ PhotonImage: PhotonImageConstructor; + /** Encode image bytes to SIXEL escape sequence at target pixel size. */ + encodeSixel(bytes: Uint8Array, targetWidthPx: number, targetHeightPx: number): string; } } diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index cf894e8b8..0f3b7b961 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -242,6 +242,7 @@ function validateNative(bindings: NativeBindings, source: string): void { }; checkFn("copyToClipboard"); checkFn("readImageFromClipboard"); + checkFn("encodeSixel"); checkFn("glob"); checkFn("fuzzyFind"); checkFn("grep"); diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 12afaf9ad..75b55a331 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -1,8 +1,10 @@ +import { encodeSixel } from "@oh-my-pi/pi-natives"; import { $env } from "@oh-my-pi/pi-utils"; export enum ImageProtocol { Kitty = "\x1b_G", Iterm2 = "\x1b]1337;File=", + Sixel = "\x1bPq", } export enum NotifyProtocol { @@ -13,6 +15,7 @@ export enum NotifyProtocol { export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "base" | "trueColor"; +const SIXEL_DCS_START_REGEX = /\x1bP(?:[0-9;]*)q/u; /** Terminal capability details used for rendering and protocol selection. */ export class TerminalInfo { constructor( @@ -25,6 +28,9 @@ export class TerminalInfo { isImageLine(line: string): boolean { if (!this.imageProtocol) return false; + if (this.imageProtocol === ImageProtocol.Sixel) { + return SIXEL_DCS_START_REGEX.test(line.slice(0, 128)); + } return line.slice(0, 64).includes(this.imageProtocol); } @@ -52,10 +58,39 @@ function getForcedImageProtocol(): ImageProtocol | null | undefined { if (!raw) return undefined; if (raw === "kitty") return ImageProtocol.Kitty; if (raw === "iterm2" || raw === "iterm") return ImageProtocol.Iterm2; + if (raw === "sixel") return ImageProtocol.Sixel; if (raw === "off" || raw === "none" || raw === "0" || raw === "false") return null; return null; } +function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null { + if (!versionRaw) return null; + const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim()); + if (!match) return null; + const major = Number.parseInt(match[1] ?? "", 10); + const minor = Number.parseInt(match[2] ?? "", 10); + if (!Number.isFinite(major) || !Number.isFinite(minor)) return null; + return { major, minor }; +} + +/** + * Returns true when running in Windows Terminal with known SIXEL support. + * + * Windows Terminal introduced SIXEL support in preview 1.22. + */ +export function isWindowsTerminalPreviewSixelSupported( + env: NodeJS.ProcessEnv = Bun.env, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform !== "win32") return false; + if (!env.WT_SESSION) return false; + if (env.TERM_PROGRAM && env.TERM_PROGRAM.toLowerCase() !== "windows_terminal") { + return false; + } + const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION); + if (!version) return false; + return version.major > 1 || (version.major === 1 && version.minor >= 22); +} function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null { if (!process.stdout.isTTY) return null; if (terminalId === "vscode" || terminalId === "alacritty") return null; @@ -146,6 +181,17 @@ export const TERMINAL = (() => { return terminal; })(); +type MutableTerminalInfo = { + imageProtocol: ImageProtocol | null; +}; + +/** + * Override terminal image protocol at runtime after capability probes complete. + */ +export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void { + (TERMINAL as unknown as MutableTerminalInfo).imageProtocol = imageProtocol; +} + export function getTerminalInfo(terminalId: TerminalId): TerminalInfo { return KNOWN_TERMINALS[terminalId]; } @@ -436,7 +482,8 @@ export function renderImage( return null; } - const fit = calculateImageFit(imageDimensions, options, getCellDimensions()); + const cellDims = getCellDimensions(); + const fit = calculateImageFit(imageDimensions, options, cellDims); if (TERMINAL.imageProtocol === ImageProtocol.Kitty) { const sequence = encodeKitty(base64Data, { @@ -446,6 +493,17 @@ export function renderImage( return { sequence, rows: fit.rows }; } + if (TERMINAL.imageProtocol === ImageProtocol.Sixel) { + try { + const targetWidthPx = Math.max(1, fit.columns * cellDims.widthPx); + const targetHeightPx = Math.max(1, fit.rows * cellDims.heightPx); + const decoded = new Uint8Array(Buffer.from(base64Data, "base64")); + const sequence = encodeSixel(decoded, targetWidthPx, targetHeightPx); + return { sequence, rows: fit.rows }; + } catch { + return null; + } + } if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) { const sequence = encodeITerm2(base64Data, { width: fit.columns, diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5381ade6f..9f5d90b26 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -6,7 +6,7 @@ import * as path from "node:path"; import { getCrashLogPath, getDebugLogPath } from "@oh-my-pi/pi-utils"; import { isKeyRelease, matchesKey } from "./keys"; import type { Terminal } from "./terminal"; -import { setCellDimensions, TERMINAL } from "./terminal-capabilities"; +import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities"; import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils"; const SEGMENT_RESET = "\x1b[0m"; @@ -216,6 +216,11 @@ export class TUI extends Container { #viewportTopRow = 0; // Content row currently mapped to screen row 0 #inputBuffer = ""; // Buffer for parsing terminal responses #cellSizeQueryPending = false; + #sixelProbePendingDa = false; + #sixelProbePendingGraphics = false; + #sixelProbeBuffer = ""; + #sixelProbeTimeout?: NodeJS.Timeout; + #sixelProbeUnsubscribe?: () => void; #showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; #clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) #maxLinesRendered = 0; // High-water line count used for clear-on-shrink policy @@ -381,6 +386,7 @@ export class TUI extends Container { () => this.requestRender(), ); this.terminal.hideCursor(); + this.#querySixelSupport(); this.#queryCellSize(); this.requestRender(true); } @@ -396,6 +402,131 @@ export class TUI extends Container { this.#inputListeners.delete(listener); } + #querySixelSupport(): void { + if (TERMINAL.imageProtocol) return; + if (process.platform !== "win32") return; + if (!Bun.env.WT_SESSION) return; + if (!process.stdin.isTTY || !process.stdout.isTTY) return; + + this.#clearSixelProbeState(); + this.#sixelProbePendingDa = true; + this.#sixelProbePendingGraphics = true; + this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data)); + this.terminal.write("\x1b[c"); + this.terminal.write("\x1b[?2;1;0S"); + this.#sixelProbeTimeout = setTimeout(() => { + this.#finishSixelProbe(false); + }, 250); + } + + #handleSixelProbeInput(data: string): InputListenerResult { + if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) { + return undefined; + } + + this.#sixelProbeBuffer += data; + let passthrough = ""; + let probeOutcome: boolean | null = null; + + while (this.#sixelProbeBuffer.length > 0) { + const daMatch = this.#sixelProbeBuffer.match(/\x1b\[\?([0-9;]+)c/u); + const graphicsMatch = this.#sixelProbeBuffer.match(/\x1b\[\?2;(\d+);([0-9;]+)S/u); + + if (!daMatch && !graphicsMatch) break; + + const daIndex = daMatch?.index ?? Number.POSITIVE_INFINITY; + const graphicsIndex = graphicsMatch?.index ?? Number.POSITIVE_INFINITY; + const useDa = daIndex <= graphicsIndex; + const match = useDa ? daMatch : graphicsMatch; + if (!match || match.index === undefined) break; + + passthrough += this.#sixelProbeBuffer.slice(0, match.index); + this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(match.index + match[0].length); + + if (useDa && this.#sixelProbePendingDa) { + this.#sixelProbePendingDa = false; + const attributes = (match[1] ?? "") + .split(";") + .map(value => Number.parseInt(value, 10)) + .filter(value => Number.isFinite(value)); + const hasSixelAttribute = attributes.includes(4); + if (hasSixelAttribute) { + this.#sixelProbePendingGraphics = false; + probeOutcome = true; + } else if (!this.#sixelProbePendingGraphics) { + probeOutcome = false; + } + } else if (!useDa && this.#sixelProbePendingGraphics) { + this.#sixelProbePendingGraphics = false; + const status = Number.parseInt(match[1] ?? "", 10); + const supportsSixel = !Number.isNaN(status) && status !== 0; + if (supportsSixel) { + this.#sixelProbePendingDa = false; + probeOutcome = true; + } else if (!this.#sixelProbePendingDa) { + probeOutcome = false; + } + } + } + + if (this.#sixelProbePendingDa || this.#sixelProbePendingGraphics) { + const partialStart = this.#getSixelProbePartialStart(this.#sixelProbeBuffer); + if (partialStart >= 0) { + passthrough += this.#sixelProbeBuffer.slice(0, partialStart); + this.#sixelProbeBuffer = this.#sixelProbeBuffer.slice(partialStart); + } else { + passthrough += this.#sixelProbeBuffer; + this.#sixelProbeBuffer = ""; + } + } else { + passthrough += this.#sixelProbeBuffer; + this.#sixelProbeBuffer = ""; + } + + if (probeOutcome !== null) { + this.#finishSixelProbe(probeOutcome); + } + + if (passthrough.length === 0) { + return { consume: true }; + } + + return { data: passthrough }; + } + + #getSixelProbePartialStart(buffer: string): number { + const lastEsc = buffer.lastIndexOf("\x1b"); + if (lastEsc < 0) return -1; + const tail = buffer.slice(lastEsc); + if (/^\x1b\[\?[0-9;]*$/u.test(tail)) { + return lastEsc; + } + return -1; + } + + #clearSixelProbeState(): void { + if (this.#sixelProbeTimeout) { + clearTimeout(this.#sixelProbeTimeout); + this.#sixelProbeTimeout = undefined; + } + if (this.#sixelProbeUnsubscribe) { + this.#sixelProbeUnsubscribe(); + this.#sixelProbeUnsubscribe = undefined; + } + this.#sixelProbePendingDa = false; + this.#sixelProbePendingGraphics = false; + this.#sixelProbeBuffer = ""; + } + + #finishSixelProbe(supported: boolean): void { + this.#clearSixelProbeState(); + if (!supported || TERMINAL.imageProtocol) return; + + setTerminalImageProtocol(ImageProtocol.Sixel); + this.#queryCellSize(); + this.invalidate(); + this.requestRender(true); + } #queryCellSize(): void { // Only query if terminal supports images (cell size is only used for image rendering) if (!TERMINAL.imageProtocol) { @@ -408,6 +539,7 @@ export class TUI extends Container { } stop(): void { + this.#clearSixelProbeState(); this.#stopped = true; // Move cursor below the visible working area to prevent overwriting/artifacts on exit if (this.#previousLines.length > 0) { diff --git a/packages/tui/test/image-render.test.ts b/packages/tui/test/image-render.test.ts index 55b7a8a6b..0bcb14fed 100644 --- a/packages/tui/test/image-render.test.ts +++ b/packages/tui/test/image-render.test.ts @@ -4,6 +4,7 @@ import { type CellDimensions, getCellDimensions, ImageProtocol, + isWindowsTerminalPreviewSixelSupported, renderImage, setCellDimensions, TERMINAL, @@ -16,6 +17,8 @@ type MutableTerminalInfo = { const terminal = TERMINAL as unknown as MutableTerminalInfo; const BASE64_DUMMY = "AA=="; const SQUARE_DIMENSIONS = { widthPx: 100, heightPx: 100 }; +const BASE64_ONE_PIXEL_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAABJRU5ErkJggg=="; function parseKittyParam(sequence: string, key: "c" | "r"): number | null { const match = sequence.match(new RegExp(`${key}=(\\d+)`)); @@ -79,6 +82,18 @@ describe("terminal image rendering", () => { expect(result?.sequence).toContain("height=auto"); }); + it("encodes SIXEL output when protocol is SIXEL", () => { + terminal.imageProtocol = ImageProtocol.Sixel; + const result = renderImage(BASE64_ONE_PIXEL_PNG, SQUARE_DIMENSIONS, { + maxWidthCells: 10, + maxHeightCells: 2, + }); + + expect(result).not.toBeNull(); + expect(result?.rows).toBe(2); + expect(result?.sequence.startsWith("\x1bP")).toBe(true); + }); + it("Image component forwards maxHeightCells to terminal rendering", () => { terminal.imageProtocol = ImageProtocol.Kitty; const image = new Image( @@ -97,3 +112,29 @@ describe("terminal image rendering", () => { expect(lines[1]).toContain("r=2"); }); }); + +describe("Windows Terminal Preview SIXEL detection", () => { + it("requires Windows platform, WT session, and known version 1.22+", () => { + expect( + isWindowsTerminalPreviewSixelSupported( + { WT_SESSION: "1", TERM_PROGRAM: "Windows_Terminal", TERM_PROGRAM_VERSION: "1.22.2362.0" }, + "win32", + ), + ).toBe(true); + expect( + isWindowsTerminalPreviewSixelSupported( + { WT_SESSION: "1", TERM_PROGRAM: "Windows_Terminal", TERM_PROGRAM_VERSION: "1.21.0.0" }, + "win32", + ), + ).toBe(false); + expect( + isWindowsTerminalPreviewSixelSupported({ WT_SESSION: "1", TERM_PROGRAM: "Windows_Terminal" }, "win32"), + ).toBe(false); + expect( + isWindowsTerminalPreviewSixelSupported( + { WT_SESSION: "1", TERM_PROGRAM: "Windows_Terminal", TERM_PROGRAM_VERSION: "1.22.2362.0" }, + "linux", + ), + ).toBe(false); + }); +}); diff --git a/packages/tui/test/sixel-probe.test.ts b/packages/tui/test/sixel-probe.test.ts new file mode 100644 index 000000000..8c532908a --- /dev/null +++ b/packages/tui/test/sixel-probe.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { ImageProtocol, setTerminalImageProtocol, TERMINAL, TUI } from "@oh-my-pi/pi-tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +type MutableTerminalInfo = { + imageProtocol: ImageProtocol | null; +}; + +const terminalInfo = TERMINAL as unknown as MutableTerminalInfo; +const originalProtocol = TERMINAL.imageProtocol; +const originalWtSession = Bun.env.WT_SESSION; +const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +function restoreIsTty( + stream: NodeJS.ReadStream | NodeJS.WriteStream, + descriptor: PropertyDescriptor | undefined, +): void { + if (descriptor) { + Object.defineProperty(stream, "isTTY", descriptor); + return; + } + delete (stream as unknown as { isTTY?: boolean }).isTTY; +} + +describe("TUI SIXEL capability probe", () => { + afterEach(() => { + setTerminalImageProtocol(originalProtocol); + terminalInfo.imageProtocol = originalProtocol; + if (originalWtSession === undefined) delete Bun.env.WT_SESSION; + else Bun.env.WT_SESSION = originalWtSession; + restoreIsTty(process.stdin, stdinIsTtyDescriptor); + restoreIsTty(process.stdout, stdoutIsTtyDescriptor); + }); + + it("enables SIXEL only after positive terminal capability response", () => { + if (process.platform !== "win32") return; + setTerminalImageProtocol(null); + terminalInfo.imageProtocol = null; + Bun.env.WT_SESSION = "test-wt-session"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.start(); + terminal.sendInput("\x1b[?1;2;4c"); + + expect(TERMINAL.imageProtocol).toBe(ImageProtocol.Sixel); + tui.stop(); + }); + + it("enables SIXEL when DA and graphics replies are coalesced in one chunk", () => { + if (process.platform !== "win32") return; + setTerminalImageProtocol(null); + terminalInfo.imageProtocol = null; + Bun.env.WT_SESSION = "test-wt-session"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.start(); + terminal.sendInput("\x1b[?1;2;4c\x1b[?2;1;0S"); + + expect(TERMINAL.imageProtocol).toBe(ImageProtocol.Sixel); + tui.stop(); + }); + + it("enables SIXEL when DA reply arrives split across chunks", () => { + if (process.platform !== "win32") return; + setTerminalImageProtocol(null); + terminalInfo.imageProtocol = null; + Bun.env.WT_SESSION = "test-wt-session"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.start(); + terminal.sendInput("\x1b[?1;2;"); + terminal.sendInput("4c"); + + expect(TERMINAL.imageProtocol).toBe(ImageProtocol.Sixel); + tui.stop(); + }); + + it("keeps SIXEL disabled when capability responses are negative", () => { + if (process.platform !== "win32") return; + setTerminalImageProtocol(null); + terminalInfo.imageProtocol = null; + Bun.env.WT_SESSION = "test-wt-session"; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + tui.start(); + terminal.sendInput("\x1b[?1;2c"); + terminal.sendInput("\x1b[?2;0;0S"); + + expect(TERMINAL.imageProtocol).toBeNull(); + tui.stop(); + }); +});