use base64::{Engine, engine::general_purpose};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageSupport {
Kitty,
Iterm2,
None,
}
pub const IMAGE_BUDGET_LIMIT: usize = 8;
pub fn detect_image_support() -> ImageSupport {
let force = std::env::var("OXICODE_FORCE_IMAGE_TERM").ok();
let kitty_window = std::env::var("KITTY_WINDOW_ID").ok();
let term = std::env::var("TERM").unwrap_or_default();
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
detect_image_support_from(
force.as_deref(),
kitty_window.as_deref(),
&term,
&term_program,
)
}
pub fn detect_image_support_from(
force: Option<&str>,
kitty_window: Option<&str>,
term: &str,
term_program: &str,
) -> ImageSupport {
if let Some(v) = force {
match v.trim().to_ascii_lowercase().as_str() {
"kitty" | "xterm-kitty" => return ImageSupport::Kitty,
"iterm" | "iterm2" | "iterm.app" => return ImageSupport::Iterm2,
"none" | "off" | "disable" | "disabled" => return ImageSupport::None,
_ => {}
}
}
if kitty_window.is_some_and(|s| !s.is_empty()) {
return ImageSupport::Kitty;
}
if term.eq_ignore_ascii_case("xterm-kitty") {
return ImageSupport::Kitty;
}
if term_program.eq_ignore_ascii_case("iTerm.app") {
return ImageSupport::Iterm2;
}
ImageSupport::None
}
pub fn kitty_transmit_png(id: u32, png: &[u8]) -> String {
let b64 = escape_kitty_payload(&general_purpose::STANDARD.encode(png));
const CHUNK: usize = 4096;
let mut seq = String::new();
let mut start = 0;
while start < b64.len() {
let mut end = (start + CHUNK).min(b64.len());
if end < b64.len() {
end -= (end - start) % 4;
}
let first = start == 0;
let last = end == b64.len();
seq.push_str("\x1b_G");
if first {
seq.push_str("a=t,f=100,t=d,q=2,i=");
seq.push_str(&id.to_string());
}
if !first || !last {
if first {
seq.push(','); }
seq.push_str(if last { "m=0" } else { "m=1" });
}
seq.push(';');
seq.push_str(&b64[start..end]);
seq.push_str("\x1b\\");
start = end;
}
seq
}
pub fn kitty_place(id: u32, rows: u16) -> String {
format!("\x1b_Ga=p,i={id},r={rows},C=1\x1b\\")
}
pub fn kitty_delete(id: u32) -> String {
format!("\x1b_Ga=d,d=I,i={id}\x1b\\")
}
pub fn iterm_inline_png(png: &[u8]) -> String {
let b64 = general_purpose::STANDARD.encode(png);
format!("\x1b]1337;File=inline=1;preserveAspectRatio=1;base64={b64}\x07")
}
pub fn text_fallback(path: &str) -> String {
format!("[image: {path}]")
}
fn escape_kitty_payload(b64: &str) -> String {
let mut out = String::with_capacity(b64.len());
for ch in b64.chars() {
match ch {
'\\' => out.push_str("\\\\"),
',' => out.push_str("\\c"),
other => out.push(other),
}
}
out
}
#[derive(Debug, Default)]
pub struct ImageBudget {
ids: Vec<u32>,
}
impl ImageBudget {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, id: u32) -> Option<String> {
if let Some(pos) = self.ids.iter().position(|x| *x == id) {
self.ids.remove(pos);
self.ids.push(id);
return None;
}
let mut to_evict = None;
if self.ids.len() >= IMAGE_BUDGET_LIMIT {
let oldest = self.ids.remove(0);
to_evict = Some(kitty_delete(oldest));
}
self.ids.push(id);
to_evict
}
pub fn len(&self) -> usize {
self.ids.len()
}
pub fn is_empty(&self) -> bool {
self.ids.is_empty()
}
pub fn contains(&self, id: u32) -> bool {
self.ids.contains(&id)
}
}
pub struct PendingImage {
pub id: u32,
pub png: std::sync::Arc<Vec<u8>>,
pub label: String,
}
pub struct ImageAnchor {
pub id: u32,
pub x: u16,
pub y: u16,
pub rows: u16,
pub transcript_index: usize,
}
pub struct ImagePreviews {
support: ImageSupport,
enabled: bool,
budget: ImageBudget,
pending: Vec<PendingImage>,
anchors: std::sync::Arc<parking_lot::Mutex<Vec<ImageAnchor>>>,
}
impl ImagePreviews {
pub fn new(support: ImageSupport) -> Self {
Self {
support,
enabled: true,
budget: ImageBudget::new(),
pending: Vec::new(),
anchors: std::sync::Arc::new(parking_lot::Mutex::new(Vec::new())),
}
}
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
pub fn enqueue(&mut self, id: u32, png: std::sync::Arc<Vec<u8>>, label: String) {
const MAX_PENDING: usize = 32;
if self.pending.len() >= MAX_PENDING {
self.pending.remove(0);
}
self.pending.push(PendingImage { id, png, label });
}
pub fn pending(&self) -> &[PendingImage] {
&self.pending
}
pub fn record_anchor(&self, id: u32, x: u16, y: u16, rows: u16, transcript_index: usize) {
self.anchors.lock().push(ImageAnchor {
id,
x,
y,
rows,
transcript_index,
});
}
pub fn pending_len(&self) -> usize {
self.pending.len()
}
pub fn emit_live(&mut self, committed_entries: usize) -> String {
let anchors = std::mem::take(&mut *self.anchors.lock());
if self.pending.is_empty() && anchors.is_empty() {
return String::new();
}
if !self.enabled || self.support == ImageSupport::None {
self.pending.clear();
return String::new();
}
let mut seq = String::new();
for anchor in anchors {
if anchor.transcript_index < committed_entries {
if let Some(pos) = self.pending.iter().position(|p| p.id == anchor.id) {
self.pending.remove(pos);
}
continue;
}
let Some(pos) = self.pending.iter().position(|p| p.id == anchor.id) else {
continue;
};
let pending = self.pending.remove(pos);
seq.push_str("\x1b7");
seq.push_str(&format!(
"\x1b[{};{}H",
anchor.y.saturating_add(1),
anchor.x.saturating_add(1)
));
match self.support {
ImageSupport::Kitty => {
let known = self.budget.contains(pending.id);
if let Some(delete) = self.budget.record(pending.id) {
seq.push_str(&delete);
}
if !known {
seq.push_str(&kitty_transmit_png(pending.id, &pending.png));
}
seq.push_str(&kitty_place(pending.id, anchor.rows));
}
ImageSupport::Iterm2 => {
seq.push_str(&iterm_inline_png(&pending.png));
}
ImageSupport::None => unreachable!("gated above"),
}
seq.push_str("\x1b8");
}
seq
}
}
impl Default for ImagePreviews {
fn default() -> Self {
Self::new(detect_image_support())
}
}
pub fn content_hash_id(png: &[u8]) -> u32 {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(png);
u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine, engine::general_purpose};
#[test]
fn detect_kitty_from_env_matrix() {
assert_eq!(
detect_image_support_from(Some("kitty"), None, "xterm", "iTerm.app"),
ImageSupport::Kitty,
);
assert_eq!(
detect_image_support_from(Some("iterm"), None, "xterm-kitty", "iTerm.app"),
ImageSupport::Iterm2,
);
assert_eq!(
detect_image_support_from(Some("none"), Some("42"), "xterm-kitty", "iTerm.app"),
ImageSupport::None,
);
assert_eq!(
detect_image_support_from(None, Some("12345"), "xterm-256color", ""),
ImageSupport::Kitty,
);
assert_eq!(
detect_image_support_from(None, None, "xterm-kitty", ""),
ImageSupport::Kitty,
);
assert_eq!(
detect_image_support_from(None, None, "xterm-256color", "iTerm.app"),
ImageSupport::Iterm2,
);
assert_eq!(
detect_image_support_from(None, None, "xterm-256color", "Apple_Terminal"),
ImageSupport::None,
);
assert_eq!(
detect_image_support_from(Some("ITERM2"), None, "xterm", ""),
ImageSupport::Iterm2,
);
assert_eq!(
detect_image_support_from(Some("disabled"), None, "xterm-kitty", ""),
ImageSupport::None,
);
}
#[test]
fn kitty_transmit_contains_escaped_base64() {
let png: &[u8] = &[0xff, 0x00, 0xff, 0x3b, 0xc3, 0x47];
let s = kitty_transmit_png(7, png);
assert!(s.starts_with("\x1b_G"), "must start with APC introducer");
assert!(s.ends_with("\x1b\\"), "must end with ST terminator");
assert!(s.contains("f=100"), "format=png");
assert!(s.contains("t=d"), "direct inline transmission");
assert!(s.contains("q=2"), "quiet: no terminal responses");
assert!(s.contains("i=7"), "id carried");
let payload = s
.trim_start_matches("\x1b_G")
.trim_end_matches("\x1b\\")
.split_once(';')
.map(|(_, p)| p)
.expect("kv block ends with ';'");
assert!(!payload.contains(','), "all commas must be escaped as \\c");
let unescaped = unescape_kitty_payload(payload);
let decoded = general_purpose::STANDARD.decode(&unescaped).unwrap();
assert_eq!(decoded, png);
}
#[test]
fn kitty_transmit_chunks_large_payloads() {
let png: Vec<u8> = (0..10_000u32).map(|i| (i % 251) as u8).collect();
let s = kitty_transmit_png(11, &png);
let apcs: Vec<&str> = s.split("\x1b_G").skip(1).collect();
assert_eq!(apcs.len(), 4, "4 chunks for ~13.3k base64 chars");
assert!(apcs[0].starts_with("a=t,f=100,t=d,q=2,i=11,m=1;"));
for mid in &apcs[1..3] {
assert!(
mid.starts_with("m=1;"),
"middle chunks carry only m: {mid:?}"
);
}
assert!(apcs[3].starts_with("m=0;"), "final chunk marks m=0");
let payloads: Vec<&str> = apcs
.iter()
.map(|c| c.split(';').nth(1).unwrap_or("").trim_end_matches("\x1b\\"))
.collect();
for (i, pl) in payloads.iter().enumerate() {
assert!(pl.len() <= 4096, "chunk {i} within the 4096 limit");
if i < payloads.len() - 1 {
assert!(pl.len() % 4 == 0, "chunk {i} multiple of 4");
}
}
let joined: String = payloads.concat();
let unescaped = unescape_kitty_payload(&joined);
let decoded = general_purpose::STANDARD.decode(&unescaped).unwrap();
assert_eq!(decoded, png);
}
#[test]
fn iterm_osc1337_wraps_base64() {
let png: &[u8] = &[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
let s = iterm_inline_png(png);
assert!(s.starts_with("\x1b]1337;File=inline=1"));
assert!(s.contains("preserveAspectRatio=1"));
assert!(s.contains("base64="));
assert!(s.ends_with('\x07'), "iTerm2 uses BEL as terminator");
let b64 = s
.split("base64=")
.nth(1)
.and_then(|t| t.strip_suffix('\x07'))
.expect("base64= present");
assert_eq!(general_purpose::STANDARD.decode(b64).unwrap(), png);
}
#[test]
fn fallback_format() {
assert_eq!(text_fallback("/tmp/a.png"), "[image: /tmp/a.png]");
assert_eq!(text_fallback(""), "[image: ]");
}
#[test]
fn image_budget_evicts_oldest() {
let mut b = ImageBudget::new();
for i in 0..IMAGE_BUDGET_LIMIT as u32 {
assert!(b.record(i).is_none(), "no eviction for slot {i}");
}
assert_eq!(b.len(), IMAGE_BUDGET_LIMIT);
let evicted = b.record(99).expect("must emit delete for evicted id");
assert!(
evicted.starts_with("\x1b_Ga=d,d=I"),
"demotion uses d=I so the terminal frees the image data"
);
assert!(evicted.contains("i=0"), "delete targets the evicted id");
assert_eq!(b.len(), IMAGE_BUDGET_LIMIT, "budget stays capped");
assert!(b.record(99).is_none());
let evicted = b.record(100).expect("eviction continues");
assert!(evicted.contains("i=1"), "the new oldest is id=1, not 99");
}
#[test]
fn emit_live_kitty_writes_transmit_and_place_at_anchor() {
let mut p = ImagePreviews::new(ImageSupport::Kitty);
let png = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
let id = content_hash_id(&png);
p.enqueue(
id,
std::sync::Arc::new(png),
format!("generate_image:{id:08x}"),
);
p.record_anchor(id, 2, 10, 6, 3);
let s = p.emit_live(0);
assert!(s.contains("\x1b7"), "save cursor first");
assert!(s.contains("\x1b[11;3H"), "CUP to anchor (1-based)");
assert!(s.contains("\x1b_Ga=t,f=100"), "transmit present");
assert!(s.contains(&format!("i={id}")), "stable content-hash id");
assert!(s.contains("a=p"), "placement present");
assert!(s.contains("r=6"), "placement sized to the box rows");
assert!(s.contains("C=1"), "placement must not move the cursor");
assert!(s.contains("\x1b8"), "restore cursor last");
assert_eq!(p.pending_len(), 0, "placed image leaves the pending queue");
}
#[test]
fn emit_live_skips_committed_rows_and_disabled() {
let png = vec![1u8, 2, 3, 4];
let id = content_hash_id(&png);
let mut p = ImagePreviews::new(ImageSupport::Kitty);
p.enqueue(id, std::sync::Arc::new(png.clone()), String::new());
p.record_anchor(id, 0, 0, 5, 3);
assert!(p.emit_live(4).is_empty(), "committed rows never transmit");
assert_eq!(p.pending_len(), 0, "committed pending dropped");
let mut p = ImagePreviews::new(ImageSupport::Kitty);
p.set_enabled(false);
p.enqueue(id, std::sync::Arc::new(png.clone()), String::new());
p.record_anchor(id, 0, 0, 5, 0);
assert!(
p.emit_live(0).is_empty(),
"kill-switch suppresses all writes"
);
let mut p = ImagePreviews::new(ImageSupport::None);
p.enqueue(id, std::sync::Arc::new(png), String::new());
p.record_anchor(id, 0, 0, 5, 0);
assert!(
p.emit_live(0).is_empty(),
"unsupported terminals get text only"
);
}
#[test]
fn emit_live_iterm_writes_osc1337_at_anchor() {
let mut p = ImagePreviews::new(ImageSupport::Iterm2);
let png = vec![0x89, 0x50, 0x4e, 0x47];
let id = content_hash_id(&png);
p.enqueue(id, std::sync::Arc::new(png), String::new());
p.record_anchor(id, 0, 4, 5, 0);
let s = p.emit_live(0);
assert!(s.contains("\x1b[5;1H"), "cursor parked on the anchor row");
assert!(s.contains("\x1b]1337;File=inline=1"), "inline upload");
}
#[test]
fn content_hash_id_stable_and_distinct() {
assert_eq!(
content_hash_id(b"hello world"),
content_hash_id(b"hello world")
);
assert_ne!(
content_hash_id(b"hello world"),
content_hash_id(b"hello worlD")
);
}
fn unescape_kitty_payload(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('c') => out.push(','),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
}