use base64::Engine as _;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Kitty,
Iterm2,
Unsupported,
}
pub fn detect_protocol() -> ImageProtocol {
static CACHED: OnceLock<ImageProtocol> = OnceLock::new();
*CACHED.get_or_init(detect_protocol_uncached)
}
fn detect_protocol_uncached() -> ImageProtocol {
if let Ok(prog) = std::env::var("TERM_PROGRAM") {
let lower = prog.to_ascii_lowercase();
if lower == "iterm.app" || lower == "iterm2" {
return ImageProtocol::Iterm2;
}
if lower == "wezterm" {
return ImageProtocol::Kitty;
}
}
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() {
return ImageProtocol::Kitty;
}
if let Ok(term) = std::env::var("TERM") {
let lower = term.to_ascii_lowercase();
if lower.contains("kitty") {
return ImageProtocol::Kitty;
}
if lower.contains("xterm-kitty") {
return ImageProtocol::Kitty;
}
}
if std::env::var("KITTY_WINDOW_ID").is_ok() {
return ImageProtocol::Kitty;
}
ImageProtocol::Unsupported
}
const KITTY_CHUNK_SIZE: usize = 4096;
pub fn encode_kitty(image_bytes: &[u8], cols: usize) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(image_bytes);
let mut out = String::with_capacity(b64.len() + 256);
let chunks: Vec<&str> = b64
.as_bytes()
.chunks(KITTY_CHUNK_SIZE)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
for (i, chunk) in chunks.iter().enumerate() {
let is_first = i == 0;
let is_last = i == chunks.len() - 1;
let more = u8::from(!is_last);
if is_first {
write_kitty_chunk(&mut out, &format!("a=T,f=100,c={cols},m={more}"), chunk);
} else {
write_kitty_chunk(&mut out, &format!("m={more}"), chunk);
}
}
out
}
fn write_kitty_chunk(out: &mut String, control: &str, payload: &str) {
out.push_str("\x1b_G");
out.push_str(control);
out.push(';');
out.push_str(payload);
out.push_str("\x1b\\");
}
pub fn encode_iterm2(image_bytes: &[u8], cols: usize) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(image_bytes);
let size = image_bytes.len();
format!("\x1b]1337;File=size={size};width={cols};inline=1:{b64}\x07")
}
pub fn placeholder(mime_type: &str, width: Option<u32>, height: Option<u32>) -> String {
match (width, height) {
(Some(w), Some(h)) => format!("[image: {mime_type}, {w}x{h}]"),
_ => format!("[image: {mime_type}]"),
}
}
pub fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1A\n") {
let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
return Some((w, h));
}
if data.len() >= 4 && data[0] == 0xFF && data[1] == 0xD8 {
return jpeg_dimensions(data);
}
if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
let w = u32::from(u16::from_le_bytes([data[6], data[7]]));
let h = u32::from(u16::from_le_bytes([data[8], data[9]]));
return Some((w, h));
}
None
}
fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
let mut i = 2;
while i < data.len() {
while i < data.len() && data[i] != 0xFF {
i += 1;
}
if i >= data.len() {
return None;
}
while i < data.len() && data[i] == 0xFF {
i += 1;
}
if i >= data.len() {
return None;
}
let marker = data[i];
i += 1;
if matches!(marker, 0x01 | 0xD0..=0xD7) {
continue;
}
if matches!(marker, 0xDA | 0xD9) {
return None;
}
if i + 1 >= data.len() {
return None;
}
let seg_len = usize::from(u16::from_be_bytes([data[i], data[i + 1]]));
if seg_len < 2 || i.saturating_add(seg_len) > data.len() {
return None;
}
if is_jpeg_sof_marker(marker) {
if seg_len < 7 {
return None;
}
let h = u32::from(u16::from_be_bytes([data[i + 3], data[i + 4]]));
let w = u32::from(u16::from_be_bytes([data[i + 5], data[i + 6]]));
return Some((w, h));
}
i += seg_len;
}
None
}
const fn is_jpeg_sof_marker(marker: u8) -> bool {
matches!(
marker,
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF
)
}
pub fn render_inline(image_b64: &str, mime_type: &str, max_cols: usize) -> String {
let _ = max_cols;
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(image_b64) else {
return placeholder(mime_type, None, None);
};
let dims = image_dimensions(&bytes);
placeholder(mime_type, dims.map(|(w, _)| w), dims.map(|(_, h)| h))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kitty_single_chunk_small_image() {
let data = b"hello";
let result = encode_kitty(data, 40);
assert!(result.starts_with("\x1b_G"), "Should start with APC");
assert!(result.contains("a=T"), "First chunk should have a=T");
assert!(result.contains("f=100"), "Should auto-detect format");
assert!(result.contains("c=40"), "Should set column constraint");
assert!(result.contains("m=0"), "Single chunk should have m=0");
assert!(result.ends_with("\x1b\\"), "Should end with ST");
}
#[test]
fn kitty_multi_chunk_large_payload() {
let data = vec![0u8; 4096];
let result = encode_kitty(&data, 80);
let chunk_count = result.matches("\x1b_G").count();
assert!(
chunk_count >= 2,
"Should have at least 2 chunks, got {chunk_count}"
);
assert!(result.contains("m=1"), "First chunk should signal more");
let last_chunk_start = result.rfind("\x1b_G").unwrap();
let last_chunk = &result[last_chunk_start..];
assert!(last_chunk.contains("m=0"), "Last chunk should signal done");
}
#[test]
fn iterm2_format() {
let data = b"test image";
let result = encode_iterm2(data, 60);
assert!(
result.starts_with("\x1b]1337;File="),
"Should start with OSC 1337"
);
assert!(result.contains("inline=1"), "Should be inline");
assert!(
result.contains(&format!("size={}", data.len())),
"Should include file size"
);
assert!(result.contains("width=60"), "Should include width");
assert!(result.ends_with('\x07'), "Should end with BEL");
}
#[test]
fn placeholder_with_dimensions() {
let result = placeholder("image/png", Some(800), Some(600));
assert_eq!(result, "[image: image/png, 800x600]");
}
#[test]
fn placeholder_without_dimensions() {
let result = placeholder("image/jpeg", None, None);
assert_eq!(result, "[image: image/jpeg]");
}
#[test]
fn png_dimensions() {
let mut data = vec![0u8; 32];
data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
data[8..12].copy_from_slice(&13u32.to_be_bytes());
data[12..16].copy_from_slice(b"IHDR");
data[16..20].copy_from_slice(&100u32.to_be_bytes());
data[20..24].copy_from_slice(&50u32.to_be_bytes());
let dims = image_dimensions(&data);
assert_eq!(dims, Some((100, 50)));
}
#[test]
fn gif_dimensions() {
let mut data = vec![0u8; 16];
data[..6].copy_from_slice(b"GIF89a");
data[6..8].copy_from_slice(&320u16.to_le_bytes());
data[8..10].copy_from_slice(&240u16.to_le_bytes());
let dims = image_dimensions(&data);
assert_eq!(dims, Some((320, 240)));
}
#[test]
fn jpeg_dimensions() {
let data = vec![
0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x32, 0x00, 0x64, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
let dims = image_dimensions(&data);
assert_eq!(dims, Some((100, 50)));
}
#[test]
fn jpeg_dimensions_with_fill_bytes_before_sof() {
let data = vec![
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x02, 0xFF, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x32, 0x00, 0x64, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
assert_eq!(image_dimensions(&data), Some((100, 50)));
}
#[test]
fn jpeg_dimensions_supports_extended_sof_markers() {
let data = vec![
0xFF, 0xD8, 0xFF, 0xC5, 0x00, 0x11, 0x08, 0x00, 0x2A, 0x00, 0x54, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, ];
assert_eq!(image_dimensions(&data), Some((84, 42)));
}
#[test]
fn unknown_format_returns_none() {
let data = b"definitely not an image";
assert_eq!(image_dimensions(data), None);
}
#[test]
fn render_inline_returns_placeholder_for_invalid_base64() {
let result = render_inline("%%%not-base64%%%", "image/png", 80);
assert_eq!(result, "[image: image/png]");
}
#[test]
fn render_inline_with_unknown_image_bytes_omits_dimensions() {
let b64 = base64::engine::general_purpose::STANDARD.encode(b"not-an-image");
let result = render_inline(&b64, "image/webp", 80);
assert_eq!(result, "[image: image/webp]");
}
#[test]
fn render_inline_unsupported_with_decodable_image() {
let result = placeholder("image/png", Some(640), Some(480));
assert!(result.contains("640x480"));
assert!(result.contains("image/png"));
}
#[test]
fn detect_protocol_is_deterministic() {
let p1 = detect_protocol();
let p2 = detect_protocol();
assert_eq!(p1, p2);
}
#[test]
fn image_dimensions_empty_data() {
assert_eq!(image_dimensions(&[]), None);
}
#[test]
fn image_dimensions_truncated_png_header() {
let data = b"\x89PNG\r\n\x1A\n\x00\x00";
assert_eq!(image_dimensions(data), None);
}
#[test]
fn image_dimensions_truncated_gif_header() {
let data = b"GIF89a\x01";
assert_eq!(image_dimensions(data), None);
}
#[test]
fn image_dimensions_jpeg_truncated_sof() {
let data = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x05];
assert_eq!(image_dimensions(&data), None);
}
#[test]
fn image_dimensions_jpeg_no_sof_marker() {
let data = vec![0xFF, 0xD8, 0x00, 0x00];
assert_eq!(image_dimensions(&data), None);
}
#[test]
fn image_dimensions_gif87a() {
let mut data = vec![0u8; 16];
data[..6].copy_from_slice(b"GIF87a");
data[6..8].copy_from_slice(&128u16.to_le_bytes());
data[8..10].copy_from_slice(&64u16.to_le_bytes());
assert_eq!(image_dimensions(&data), Some((128, 64)));
}
#[test]
fn placeholder_width_only() {
let result = placeholder("image/png", Some(100), None);
assert_eq!(result, "[image: image/png]");
}
#[test]
fn placeholder_height_only() {
let result = placeholder("image/png", None, Some(200));
assert_eq!(result, "[image: image/png]");
}
#[test]
fn kitty_empty_data_produces_empty_output() {
let result = encode_kitty(&[], 40);
assert!(result.is_empty());
}
#[test]
fn iterm2_empty_data() {
let result = encode_iterm2(&[], 40);
assert!(result.contains("size=0"));
assert!(result.contains("width=40"));
}
#[test]
fn render_inline_with_valid_png_includes_dimensions() {
let mut png_data = vec![0u8; 32];
png_data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
png_data[8..12].copy_from_slice(&13u32.to_be_bytes());
png_data[12..16].copy_from_slice(b"IHDR");
png_data[16..20].copy_from_slice(&200u32.to_be_bytes());
png_data[20..24].copy_from_slice(&150u32.to_be_bytes());
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
let result = render_inline(&b64, "image/png", 80);
assert_eq!(result, "[image: image/png, 200x150]");
}
#[test]
fn image_protocol_equality() {
assert_eq!(ImageProtocol::Kitty, ImageProtocol::Kitty);
assert_ne!(ImageProtocol::Kitty, ImageProtocol::Iterm2);
assert_ne!(ImageProtocol::Iterm2, ImageProtocol::Unsupported);
}
mod proptest_terminal_images {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn kitty_bookends(data in proptest::collection::vec(any::<u8>(), 1..512), cols in 1..200usize) {
let result = encode_kitty(&data, cols);
assert!(result.starts_with("\x1b_G"), "must start with APC");
assert!(result.ends_with("\x1b\\"), "must end with ST");
}
#[test]
fn kitty_chunk_count_lower_bound(data in proptest::collection::vec(any::<u8>(), 1..8192)) {
let result = encode_kitty(&data, 80);
let b64_len = (data.len() * 4).div_ceil(3); let expected_chunks = b64_len.div_ceil(4096);
let actual_chunks = result.matches("\x1b_G").count();
assert!(actual_chunks >= expected_chunks.min(1));
}
#[test]
fn kitty_first_chunk_has_action(data in proptest::collection::vec(any::<u8>(), 1..100)) {
let result = encode_kitty(&data, 40);
let first_st = result.find("\x1b\\").unwrap();
let first_chunk = &result[..first_st];
assert!(first_chunk.contains("a=T"));
assert!(first_chunk.contains("f=100"));
}
#[test]
fn iterm2_format_invariants(data in proptest::collection::vec(any::<u8>(), 0..512), cols in 1..200usize) {
let result = encode_iterm2(&data, cols);
assert!(result.starts_with("\x1b]1337;File="));
assert!(result.contains(&format!("size={}", data.len())));
assert!(result.contains(&format!("width={cols}")));
assert!(result.contains("inline=1"));
assert!(result.ends_with('\x07'));
}
#[test]
fn placeholder_both_dims(w in 1..10000u32, h in 1..10000u32, mime in "[a-z]+/[a-z]+") {
let result = placeholder(&mime, Some(w), Some(h));
assert!(result.contains(&format!("{w}x{h}")));
assert!(result.contains(&mime));
}
#[test]
fn placeholder_missing_dim(w in 1..10000u32, h in 1..10000u32) {
let dim_pattern = format!("{w}x{h}");
let result_no_h = placeholder("image/png", Some(w), None);
assert!(!result_no_h.contains(&dim_pattern));
assert_eq!(result_no_h, "[image: image/png]");
let result_no_w = placeholder("image/png", None, Some(h));
assert!(!result_no_w.contains(&dim_pattern));
assert_eq!(result_no_w, "[image: image/png]");
let result_none = placeholder("image/png", None, None);
assert_eq!(result_none, "[image: image/png]");
}
#[test]
fn png_dimensions_roundtrip(w in 1..10000u32, h in 1..10000u32) {
let mut data = vec![0u8; 32];
data[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
data[8..12].copy_from_slice(&13u32.to_be_bytes());
data[12..16].copy_from_slice(b"IHDR");
data[16..20].copy_from_slice(&w.to_be_bytes());
data[20..24].copy_from_slice(&h.to_be_bytes());
assert_eq!(image_dimensions(&data), Some((w, h)));
}
#[test]
fn gif_dimensions_roundtrip(w in 1..65535u16, h in 1..65535u16) {
let mut data = vec![0u8; 16];
data[..6].copy_from_slice(b"GIF89a");
data[6..8].copy_from_slice(&w.to_le_bytes());
data[8..10].copy_from_slice(&h.to_le_bytes());
assert_eq!(image_dimensions(&data), Some((u32::from(w), u32::from(h))));
}
#[test]
fn unknown_format_none(data in proptest::collection::vec(any::<u8>(), 0..64)) {
if data.len() >= 8 && data.starts_with(b"\x89PNG\r\n\x1A\n") {
return Ok(());
}
if data.len() >= 4 && data.first() == Some(&0xFF) && data.get(1) == Some(&0xD8) {
return Ok(());
}
if data.len() >= 10 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
return Ok(());
}
assert_eq!(image_dimensions(&data), None);
}
#[test]
fn render_inline_never_panics(b64 in "\\PC{0,100}", mime in "[a-z]+/[a-z]+") {
let _ = render_inline(&b64, &mime, 80);
}
#[test]
fn render_inline_png_has_dims(w in 1..5000u32, h in 1..5000u32) {
let mut png = vec![0u8; 32];
png[..8].copy_from_slice(b"\x89PNG\r\n\x1A\n");
png[8..12].copy_from_slice(&13u32.to_be_bytes());
png[12..16].copy_from_slice(b"IHDR");
png[16..20].copy_from_slice(&w.to_be_bytes());
png[20..24].copy_from_slice(&h.to_be_bytes());
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
let result = render_inline(&b64, "image/png", 80);
assert!(result.contains(&format!("{w}x{h}")));
}
#[test]
fn render_inline_preserves_mime_label(
data in proptest::collection::vec(any::<u8>(), 0..512),
mime in "[a-z]{1,10}/[a-z0-9.+-]{1,20}"
) {
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
let result = render_inline(&b64, &mime, 80);
assert!(result.contains(&mime));
}
#[test]
fn sof_marker_classification(marker in 0u8..=255u8) {
let expected = matches!(
marker,
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF
);
assert_eq!(is_jpeg_sof_marker(marker), expected);
}
}
}
}