Lines
100 %
Functions
57.89 %
Branches
//! Inline kitty graphics support for the `nms` REPL / one-shot
//! evaluator.
//!
//! When the user evaluates a form whose value is a `Value::Bytes`
//! that sniffs as an image AND stdout is connected to a kitty-graphics
//! capable terminal, the value-printer emits a kitty APC sequence
//! containing the image. The image renders inline at the cursor;
//! subsequent text lines below sit beneath it.
//! Capability detection mirrors `tui::chart::supports_kitty` so an
//! `nms` invocation and a TUI session inside the same terminal agree
//! on what to emit. The `--no-graphics` CLI flag short-circuits the
//! detection for CI captures and for users whose terminal the
//! heuristics misidentify.
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
/// Image format the kitty graphics protocol can transmit directly.
/// `f=100` corresponds to PNG; `f=24`/`f=32` would be raw RGB/RGBA.
/// We never auto-convert SVG → PNG inline (that lives in the
/// `plotting` crate's `:format png` keyword path); SVG bytes fall
/// through to the textual `#u8(...)` printer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFormat {
Png,
Jpeg,
}
impl ImageFormat {
fn kitty_format_code(self) -> &'static str {
match self {
// Kitty's f=100 covers both PNG and JPEG decode; the
// protocol picks the right decoder via libpng/libjpeg.
ImageFormat::Png | ImageFormat::Jpeg => "100",
/// Detect the image format by magic bytes. Returns `None` for any
/// payload that isn't a known inline-renderable raster.
#[must_use]
pub fn sniff(bytes: &[u8]) -> Option<ImageFormat> {
if bytes.len() >= 8 && bytes[0..8] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] {
return Some(ImageFormat::Png);
if bytes.len() >= 3 && bytes[0..3] == [0xFF, 0xD8, 0xFF] {
return Some(ImageFormat::Jpeg);
None
/// Mirror of `tui::chart::supports_kitty` for the standalone `nms`
/// process. Returns true when the surrounding terminal advertises
/// kitty-graphics support via env vars set by the terminal emulator.
pub fn supports_kitty<F>(env_lookup: F) -> bool
where
F: Fn(&str) -> Option<String>,
{
if env_lookup("KITTY_WINDOW_ID").is_some() {
return true;
let term = env_lookup("TERM").unwrap_or_default();
if term.contains("kitty") {
let term_program = env_lookup("TERM_PROGRAM").unwrap_or_default();
matches!(term_program.as_str(), "WezTerm" | "ghostty")
/// Wrap `bytes` of a known image format into a single kitty graphics
/// APC sequence. The sequence is appended to `out`, which keeps
/// callers in control of stdout buffering — `nms` prints it through
/// the normal value-formatter, and tests can capture the bytes for
/// comparison.
///
/// For payloads above 4096 bytes the protocol recommends chunking with
/// the `m=1`/`m=0` continuation markers. We chunk at 4096 base64
/// characters (≈ 3072 raw bytes) so even multi-MB charts transmit
/// without exceeding terminal buffer limits.
pub fn encode_kitty(format: ImageFormat, bytes: &[u8], out: &mut String) {
let encoded = STANDARD.encode(bytes);
const CHUNK: usize = 4096;
let f = format.kitty_format_code();
if encoded.len() <= CHUNK {
out.push_str("\x1b_Ga=T,f=");
out.push_str(f);
out.push(';');
out.push_str(&encoded);
out.push_str("\x1b\\");
return;
let mut i = 0;
let mut first = true;
while i < encoded.len() {
let end = (i + CHUNK).min(encoded.len());
let last = end == encoded.len();
out.push_str("\x1b_G");
if first {
out.push_str("a=T,f=");
out.push(',');
first = false;
out.push_str(if last { "m=0;" } else { "m=1;" });
out.push_str(&encoded[i..end]);
i = end;
/// Convenience entry that returns the kitty APC payload as a String
/// when the bytes are a known image and the terminal supports kitty
/// graphics, otherwise `None`. The caller falls back to the textual
/// `#u8(...)` printer when this returns `None`.
pub fn try_render_inline<F>(bytes: &[u8], env_lookup: F) -> Option<String>
let format = sniff(bytes)?;
if !supports_kitty(env_lookup) {
return None;
let mut out = String::with_capacity(bytes.len() * 4 / 3 + 32);
encode_kitty(format, bytes, &mut out);
Some(out)
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
move |k: &str| map.get(k).cloned()
#[test]
fn sniff_recognises_png_magic() {
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
assert_eq!(sniff(&png), Some(ImageFormat::Png));
fn sniff_recognises_jpeg_magic() {
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0];
assert_eq!(sniff(&jpeg), Some(ImageFormat::Jpeg));
fn sniff_returns_none_for_random_bytes() {
assert_eq!(sniff(&[1, 2, 3, 4]), None);
assert_eq!(sniff(b"<svg ..."), None);
assert_eq!(sniff(&[]), None);
fn supports_kitty_window_id() {
let env = env_from(&[("KITTY_WINDOW_ID", "1")]);
assert!(supports_kitty(env));
fn supports_kitty_via_term() {
let env = env_from(&[("TERM", "xterm-kitty")]);
fn supports_kitty_via_term_program_wezterm() {
let env = env_from(&[("TERM_PROGRAM", "WezTerm")]);
fn supports_kitty_via_term_program_ghostty() {
let env = env_from(&[("TERM_PROGRAM", "ghostty")]);
fn supports_kitty_plain_xterm_is_false() {
let env = env_from(&[("TERM", "xterm-256color")]);
assert!(!supports_kitty(env));
fn supports_kitty_empty_env_is_false() {
let env = env_from(&[]);
fn encode_kitty_short_payload_uses_single_chunk() {
let mut out = String::new();
encode_kitty(ImageFormat::Png, &[0x89, 0x50, 0x4E, 0x47], &mut out);
// Single-chunk sequence: ESC _ G a=T,f=100;<b64>ESC\
assert!(out.starts_with("\x1b_Ga=T,f=100;"));
assert!(out.ends_with("\x1b\\"));
// Exactly one APC start sequence.
assert_eq!(out.matches("\x1b_G").count(), 1);
fn encode_kitty_long_payload_chunks() {
let bytes = vec![0xABu8; 10_000];
encode_kitty(ImageFormat::Png, &bytes, &mut out);
// Expect multiple APC chunks; the first carries the format
// header, the last has m=0 (terminator).
let starts = out.matches("\x1b_G").count();
assert!(starts > 1, "got {starts} chunks");
assert!(
out.ends_with(
"m=0;"
.chars()
.chain("".chars())
.collect::<String>()
.as_str()
) || out.contains("m=0;")
);
fn try_render_inline_returns_none_for_non_image() {
assert!(try_render_inline(b"not an image", env).is_none());
fn try_render_inline_returns_none_when_terminal_lacks_kitty() {
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert!(try_render_inline(&png, env).is_none());
fn try_render_inline_returns_some_when_png_and_kitty() {
let rendered = try_render_inline(&png, env).expect("should encode");
assert!(rendered.contains("\x1b_G"));