Skip to main content

oxicode/tui_vt/
image_preview.rs

1//! Inline image preview support for kitty / iTerm2 graphics protocols.
2//!
3//! `detect_image_support` looks at environment variables to pick a protocol.
4//! `kitty_transmit_png` / `kitty_place` / `iterm_inline_png` / `text_fallback`
5//! are pure escape-sequence builders — they return `String`s the caller can
6//! emit through whatever terminal backend it owns. This module deliberately
7//! does NOT touch I/O; rendering and stdout writes are the caller's job, so
8//! the encoders stay unit-testable.
9//!
10//! Live viewport: transmit once (dedup by content-hash id) + place; rows
11//! committed to scrollback use `text_fallback` only — image pixels are not
12//! expected to survive terminal history (omp lesson).
13//!
14//! Reference: <https://sw.kovidgoyal.net/kitty/graphics-protocol/>
15//! and <https://iterm2.com/documentation-images.html>.
16
17use base64::{Engine, engine::general_purpose};
18
19/// Which inline-image protocol the host terminal supports.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ImageSupport {
22    /// Kitty graphics protocol (`xterm-kitty` or `KITTY_WINDOW_ID`).
23    Kitty,
24    /// iTerm2 OSC 1337 (`TERM_PROGRAM=iTerm.app`).
25    Iterm2,
26    /// No inline-image support — caller must use `text_fallback`.
27    None,
28}
29
30/// Default maximum number of concurrently-transmitted images the terminal is
31/// expected to keep alive. When a new transmission would push us over the
32/// limit, the budget evicts the oldest id and returns its delete command so
33/// the caller can emit it before the new transmit.
34pub const IMAGE_BUDGET_LIMIT: usize = 8;
35
36/// Decide which protocol the host terminal supports, from the live env.
37///
38/// `OXICODE_FORCE_IMAGE_TERM` (`kitty` | `iterm2` | `none`, aliases accepted)
39/// overrides auto-detection — used by tests and misdetected terminals.
40pub fn detect_image_support() -> ImageSupport {
41    let force = std::env::var("OXICODE_FORCE_IMAGE_TERM").ok();
42    let kitty_window = std::env::var("KITTY_WINDOW_ID").ok();
43    let term = std::env::var("TERM").unwrap_or_default();
44    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
45    detect_image_support_from(
46        force.as_deref(),
47        kitty_window.as_deref(),
48        &term,
49        &term_program,
50    )
51}
52
53/// Pure decision core — env values passed in so tests can build a matrix.
54///
55/// Priority: force override > `KITTY_WINDOW_ID` set > `TERM=xterm-kitty` >
56/// `TERM_PROGRAM=iTerm.app` > `None`.
57pub fn detect_image_support_from(
58    force: Option<&str>,
59    kitty_window: Option<&str>,
60    term: &str,
61    term_program: &str,
62) -> ImageSupport {
63    if let Some(v) = force {
64        match v.trim().to_ascii_lowercase().as_str() {
65            "kitty" | "xterm-kitty" => return ImageSupport::Kitty,
66            "iterm" | "iterm2" | "iterm.app" => return ImageSupport::Iterm2,
67            "none" | "off" | "disable" | "disabled" => return ImageSupport::None,
68            _ => {}
69        }
70    }
71    if kitty_window.is_some_and(|s| !s.is_empty()) {
72        return ImageSupport::Kitty;
73    }
74    if term.eq_ignore_ascii_case("xterm-kitty") {
75        return ImageSupport::Kitty;
76    }
77    if term_program.eq_ignore_ascii_case("iTerm.app") {
78        return ImageSupport::Iterm2;
79    }
80    ImageSupport::None
81}
82
83/// Build a kitty transmit escape sequence (APC `G`) — transmit only, no
84/// display (`a=t`).
85///
86/// Control data on the first chunk: `a=t,f=100,t=d,q=2,i=<id>`
87/// - `f=100` selects PNG (dimensions come from the PNG itself);
88/// - `t=d` is DIRECT transmission — the payload is inline base64, not a
89///   file path;
90/// - `q=2` suppresses ALL terminal responses (OK and failure) so no
91///   unsolicited replies land on the TUI's input stream;
92/// - `i=<id>` pins a stable id for later `a=p` placement.
93///
94/// The payload is base64 of the PNG bytes with `\` → `\\` and `,` → `\c`
95/// escaped, then chunked per the spec: each APC carries at most 4096
96/// payload bytes, every chunk except the last is a multiple of 4 base64
97/// characters, non-final chunks are tagged `m=1`, the final chunk `m=0`,
98/// and only the first chunk carries the control keys.
99pub fn kitty_transmit_png(id: u32, png: &[u8]) -> String {
100    let b64 = escape_kitty_payload(&general_purpose::STANDARD.encode(png));
101    /// Spec maximum chunk size; a multiple of 4 so chunk boundaries stay
102    /// on base64 quanta.
103    const CHUNK: usize = 4096;
104    let mut seq = String::new();
105    let mut start = 0;
106    while start < b64.len() {
107        let mut end = (start + CHUNK).min(b64.len());
108        if end < b64.len() {
109            end -= (end - start) % 4;
110        }
111        let first = start == 0;
112        let last = end == b64.len();
113        seq.push_str("\x1b_G");
114        if first {
115            seq.push_str("a=t,f=100,t=d,q=2,i=");
116            seq.push_str(&id.to_string());
117        }
118        if !first || !last {
119            if first {
120                seq.push(','); // separator after the control keys
121            }
122            seq.push_str(if last { "m=0" } else { "m=1" });
123        }
124        seq.push(';');
125        seq.push_str(&b64[start..end]);
126        seq.push_str("\x1b\\");
127        start = end;
128    }
129    seq
130}
131
132/// Build a kitty placement command for a previously-transmitted image id.
133///
134/// Format: `ESC _G a=p,i=<id>,r=<rows>,C=1 ESC \`
135///
136/// `a=p` displays the already-transmitted image at the CURRENT cursor
137/// position — the emit step parks the cursor on the tool box's top row
138/// with CUP first. Only `r` (rows) is given so the width follows the
139/// image's aspect ratio (a `c` of 1 would squeeze it to one column).
140/// `C=1` stops the terminal from moving the cursor after the placement
141/// (the caller restores it with DECRC anyway, but per the spec the
142/// default cursor move can otherwise land outside the scroll area).
143pub fn kitty_place(id: u32, rows: u16) -> String {
144    format!("\x1b_Ga=p,i={id},r={rows},C=1\x1b\\")
145}
146
147/// Build a kitty delete command for budget demotion — `d=I` (capital)
148/// deletes the image's placements AND frees its stored data, provided
149/// nothing else (e.g. scrollback) still references it.
150pub fn kitty_delete(id: u32) -> String {
151    format!("\x1b_Ga=d,d=I,i={id}\x1b\\")
152}
153
154/// Build an iTerm2 inline-image escape sequence (OSC 1337).
155///
156/// Format: `ESC ]1337;File=inline=1;preserveAspectRatio=1;base64=<b64> BEL`
157///
158/// iTerm2 sizes the image by its own cell metrics; the width/height
159/// arguments are optional and omitted here so the terminal picks defaults.
160pub fn iterm_inline_png(png: &[u8]) -> String {
161    let b64 = general_purpose::STANDARD.encode(png);
162    format!("\x1b]1337;File=inline=1;preserveAspectRatio=1;base64={b64}\x07")
163}
164
165/// Plain-text fallback used when no graphics protocol is supported or the
166/// `inline_images` kill-switch is off. `path` identifies the image.
167pub fn text_fallback(path: &str) -> String {
168    format!("[image: {path}]")
169}
170
171/// Escape a base64 string per the kitty graphics protocol: `\` → `\\`
172/// first, then `,` → `\c`. Processing char-by-char makes the order safe.
173fn escape_kitty_payload(b64: &str) -> String {
174    let mut out = String::with_capacity(b64.len());
175    for ch in b64.chars() {
176        match ch {
177            '\\' => out.push_str("\\\\"),
178            ',' => out.push_str("\\c"),
179            other => out.push(other),
180        }
181    }
182    out
183}
184
185/// Tracks transmitted image ids so the renderer stays under the kitty
186/// working-set limit ([`IMAGE_BUDGET_LIMIT`]), emitting delete escapes for
187/// evicted ids. Ids should be content hashes — re-rendering the same image
188/// refreshes its position instead of re-transmitting.
189#[derive(Debug, Default)]
190pub struct ImageBudget {
191    /// Ids in insertion order: `ids[0]` is the oldest, last is newest.
192    ids: Vec<u32>,
193}
194
195impl ImageBudget {
196    /// Construct an empty budget.
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// Decide what to emit for a new transmission of `id`. Returns:
202    /// - `Some(delete_cmd)` when the budget was full and the oldest id had
203    ///   to be evicted — the caller emits this BEFORE the new transmit;
204    /// - `None` when no delete is required (budget had room, or the id was
205    ///   already tracked — its position is refreshed, no re-transmit).
206    pub fn record(&mut self, id: u32) -> Option<String> {
207        // Dedup: a known id just refreshes its recency.
208        if let Some(pos) = self.ids.iter().position(|x| *x == id) {
209            self.ids.remove(pos);
210            self.ids.push(id);
211            return None;
212        }
213        let mut to_evict = None;
214        if self.ids.len() >= IMAGE_BUDGET_LIMIT {
215            let oldest = self.ids.remove(0);
216            to_evict = Some(kitty_delete(oldest));
217        }
218        self.ids.push(id);
219        to_evict
220    }
221
222    /// How many ids are currently tracked.
223    pub fn len(&self) -> usize {
224        self.ids.len()
225    }
226
227    /// True when no ids have been recorded yet.
228    pub fn is_empty(&self) -> bool {
229        self.ids.is_empty()
230    }
231
232    /// True when `id` is still live in the budget (already transmitted,
233    /// not evicted). Used by the emit step to skip re-transmitting a
234    /// known image.
235    pub fn contains(&self, id: u32) -> bool {
236        self.ids.contains(&id)
237    }
238}
239
240/// An image waiting for its first live placement, captured when a
241/// generate_image tool result lands in the transcript.
242pub struct PendingImage {
243    /// Content-hash id (see [`content_hash_id`]).
244    pub id: u32,
245    /// Decoded PNG bytes.
246    pub png: std::sync::Arc<Vec<u8>>,
247    /// Marker embedded in the fallback row (`generate_image:<id>`). The
248    /// render pass resolves it to a transcript row index — the row only
249    /// exists after the append command flows through the harness channel,
250    /// so the index cannot be known at enqueue time.
251    pub label: String,
252}
253
254/// Screen position of a pending image's tool box, recorded by the live
255/// render pass and consumed by the post-draw emit step.
256pub struct ImageAnchor {
257    pub id: u32,
258    /// Column of the box (0-based screen cell).
259    pub x: u16,
260    /// Row of the box top (0-based screen cell).
261    pub y: u16,
262    /// Visual height of the box in cell rows — the placement height.
263    pub rows: u16,
264    /// Transcript row carrying the fallback text — the liveness check
265    /// (`>= committed_entries`) runs against this at emit time.
266    pub transcript_index: usize,
267}
268
269/// Render-state owner for inline image previews: protocol detection, the
270/// settings kill-switch, the transmit budget, the pending queue, and the
271/// per-frame anchors shared between the render pass (recorder) and the
272/// post-draw emit step (consumer).
273pub struct ImagePreviews {
274    support: ImageSupport,
275    enabled: bool,
276    budget: ImageBudget,
277    pending: Vec<PendingImage>,
278    anchors: std::sync::Arc<parking_lot::Mutex<Vec<ImageAnchor>>>,
279}
280
281impl ImagePreviews {
282    pub fn new(support: ImageSupport) -> Self {
283        Self {
284            support,
285            enabled: true,
286            budget: ImageBudget::new(),
287            pending: Vec::new(),
288            anchors: std::sync::Arc::new(parking_lot::Mutex::new(Vec::new())),
289        }
290    }
291
292    /// Settings kill-switch (`inline_images`, default ON).
293    pub fn set_enabled(&mut self, enabled: bool) {
294        self.enabled = enabled;
295    }
296
297    /// Queue a decoded image for its first live placement. Called by the
298    /// generate_image result hook with the label embedded in the
299    /// fallback row. The queue is capped: rows that never render live
300    /// (immediately committed) would otherwise pile up forever.
301    pub fn enqueue(&mut self, id: u32, png: std::sync::Arc<Vec<u8>>, label: String) {
302        const MAX_PENDING: usize = 32;
303        if self.pending.len() >= MAX_PENDING {
304            self.pending.remove(0);
305        }
306        self.pending.push(PendingImage { id, png, label });
307    }
308
309    /// Pending queue (inspected by tests and the render pre-pass).
310    pub fn pending(&self) -> &[PendingImage] {
311        &self.pending
312    }
313
314    /// Record where a pending image's tool box was painted this frame.
315    /// Called from the live render pass; consumed by [`Self::emit_live`]
316    /// right after the frame flushes.
317    pub fn record_anchor(&self, id: u32, x: u16, y: u16, rows: u16, transcript_index: usize) {
318        self.anchors.lock().push(ImageAnchor {
319            id,
320            x,
321            y,
322            rows,
323            transcript_index,
324        });
325    }
326
327    /// Pending queue length.
328    pub fn pending_len(&self) -> usize {
329        self.pending.len()
330    }
331
332    /// Build the image escape stream for anchors recorded during this
333    /// frame's LIVE render and return it — the caller writes the string
334    /// through the terminal backend. Rows already committed to
335    /// scrollback never emit — the
336    /// transcript's fallback text is all history keeps (omp lesson:
337    /// image pixels must not be expected to survive history).
338    ///
339    /// Every write is wrapped in DECSC/DECRC (save/restore cursor) and
340    /// CUP to the anchor so the sequences land on the tool box without
341    /// disturbing the frame's cursor state.
342    pub fn emit_live(&mut self, committed_entries: usize) -> String {
343        let anchors = std::mem::take(&mut *self.anchors.lock());
344        if self.pending.is_empty() && anchors.is_empty() {
345            return String::new();
346        }
347        if !self.enabled || self.support == ImageSupport::None {
348            // Nothing will ever be emitted for these — drop the queue so
349            // it cannot grow unbounded across a long session.
350            self.pending.clear();
351            return String::new();
352        }
353        let mut seq = String::new();
354        for anchor in anchors {
355            if anchor.transcript_index < committed_entries {
356                // The row was committed between render and emit — its
357                // fallback text is already in scrollback and the image
358                // can never be placed. Drop the pending.
359                if let Some(pos) = self.pending.iter().position(|p| p.id == anchor.id) {
360                    self.pending.remove(pos);
361                }
362                continue;
363            }
364            let Some(pos) = self.pending.iter().position(|p| p.id == anchor.id) else {
365                continue;
366            };
367            let pending = self.pending.remove(pos);
368            seq.push_str("\x1b7");
369            seq.push_str(&format!(
370                "\x1b[{};{}H",
371                anchor.y.saturating_add(1),
372                anchor.x.saturating_add(1)
373            ));
374            match self.support {
375                ImageSupport::Kitty => {
376                    // Transmit once per unique id; re-arrival after an
377                    // eviction re-transmits (the budget dropped the data).
378                    let known = self.budget.contains(pending.id);
379                    if let Some(delete) = self.budget.record(pending.id) {
380                        seq.push_str(&delete);
381                    }
382                    if !known {
383                        seq.push_str(&kitty_transmit_png(pending.id, &pending.png));
384                    }
385                    seq.push_str(&kitty_place(pending.id, anchor.rows));
386                }
387                ImageSupport::Iterm2 => {
388                    // OSC 1337 uploads and displays in one sequence —
389                    // there is no transmit/place split to dedup.
390                    seq.push_str(&iterm_inline_png(&pending.png));
391                }
392                ImageSupport::None => unreachable!("gated above"),
393            }
394            seq.push_str("\x1b8");
395        }
396        seq
397    }
398}
399
400impl Default for ImagePreviews {
401    /// Live-env detection + kill-switch default ON.
402    fn default() -> Self {
403        Self::new(detect_image_support())
404    }
405}
406
407/// Stable content-hash id for dedup: first 32 bits of SHA-256.
408pub fn content_hash_id(png: &[u8]) -> u32 {
409    use sha2::{Digest, Sha256};
410    let digest = Sha256::digest(png);
411    u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
412}
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use base64::{Engine, engine::general_purpose};
417
418    /// Env-var decision matrix — pins every detection branch.
419    #[test]
420    fn detect_kitty_from_env_matrix() {
421        // Force override wins.
422        assert_eq!(
423            detect_image_support_from(Some("kitty"), None, "xterm", "iTerm.app"),
424            ImageSupport::Kitty,
425        );
426        assert_eq!(
427            detect_image_support_from(Some("iterm"), None, "xterm-kitty", "iTerm.app"),
428            ImageSupport::Iterm2,
429        );
430        assert_eq!(
431            detect_image_support_from(Some("none"), Some("42"), "xterm-kitty", "iTerm.app"),
432            ImageSupport::None,
433        );
434        // KITTY_WINDOW_ID alone → Kitty, even when TERM is generic.
435        assert_eq!(
436            detect_image_support_from(None, Some("12345"), "xterm-256color", ""),
437            ImageSupport::Kitty,
438        );
439        // TERM=xterm-kitty → Kitty.
440        assert_eq!(
441            detect_image_support_from(None, None, "xterm-kitty", ""),
442            ImageSupport::Kitty,
443        );
444        // TERM_PROGRAM=iTerm.app → Iterm2.
445        assert_eq!(
446            detect_image_support_from(None, None, "xterm-256color", "iTerm.app"),
447            ImageSupport::Iterm2,
448        );
449        // Anything else → None.
450        assert_eq!(
451            detect_image_support_from(None, None, "xterm-256color", "Apple_Terminal"),
452            ImageSupport::None,
453        );
454        // Force override accepts aliases (case-insensitive).
455        assert_eq!(
456            detect_image_support_from(Some("ITERM2"), None, "xterm", ""),
457            ImageSupport::Iterm2,
458        );
459        assert_eq!(
460            detect_image_support_from(Some("disabled"), None, "xterm-kitty", ""),
461            ImageSupport::None,
462        );
463    }
464
465    /// Kitty payload must escape `,` (→ `\c`) and `\` (→ `\\`) in the base64
466    /// stream, and the escaped payload must round-trip back to the PNG bytes.
467    /// Transmission is DIRECT (`t=d` — the payload is inline base64, not a
468    /// file path) and fully quiet (`q=2` — no OK/failure replies on stdin).
469    #[test]
470    fn kitty_transmit_contains_escaped_base64() {
471        let png: &[u8] = &[0xff, 0x00, 0xff, 0x3b, 0xc3, 0x47];
472        let s = kitty_transmit_png(7, png);
473        assert!(s.starts_with("\x1b_G"), "must start with APC introducer");
474        assert!(s.ends_with("\x1b\\"), "must end with ST terminator");
475        assert!(s.contains("f=100"), "format=png");
476        assert!(s.contains("t=d"), "direct inline transmission");
477        assert!(s.contains("q=2"), "quiet: no terminal responses");
478        assert!(s.contains("i=7"), "id carried");
479        // No raw commas survive inside the payload portion.
480        let payload = s
481            .trim_start_matches("\x1b_G")
482            .trim_end_matches("\x1b\\")
483            .split_once(';')
484            .map(|(_, p)| p)
485            .expect("kv block ends with ';'");
486        assert!(!payload.contains(','), "all commas must be escaped as \\c");
487        // Escaping must round-trip: unescape → base64 → original bytes.
488        let unescaped = unescape_kitty_payload(payload);
489        let decoded = general_purpose::STANDARD.decode(&unescaped).unwrap();
490        assert_eq!(decoded, png);
491    }
492
493    /// Payloads whose base64 exceeds the 4096-byte APC chunk limit must be
494    /// transmitted as m=1/m=0 chunks: the first chunk carries the control
495    /// keys, subsequent chunks carry only `m` (plus `q`), and every chunk
496    /// except the last is a multiple of 4 base64 characters.
497    #[test]
498    fn kitty_transmit_chunks_large_payloads() {
499        // 4096 base64 chars encode 3072 bytes; use 10000 bytes → ~13336
500        // base64 chars → 4 chunks (4096 + 4096 + 4096 + ~1048).
501        let png: Vec<u8> = (0..10_000u32).map(|i| (i % 251) as u8).collect();
502        let s = kitty_transmit_png(11, &png);
503        let apcs: Vec<&str> = s.split("\x1b_G").skip(1).collect();
504        assert_eq!(apcs.len(), 4, "4 chunks for ~13.3k base64 chars");
505        // First chunk: full control data + m=1.
506        assert!(apcs[0].starts_with("a=t,f=100,t=d,q=2,i=11,m=1;"));
507        // Middle chunks: only m=1 before the payload.
508        for mid in &apcs[1..3] {
509            assert!(
510                mid.starts_with("m=1;"),
511                "middle chunks carry only m: {mid:?}"
512            );
513        }
514        // Last chunk: m=0.
515        assert!(apcs[3].starts_with("m=0;"), "final chunk marks m=0");
516        // Chunk sizes (payload between ';' and the ST): all but the last
517        // are multiples of 4 base64 chars, none exceeds 4096.
518        let payloads: Vec<&str> = apcs
519            .iter()
520            .map(|c| c.split(';').nth(1).unwrap_or("").trim_end_matches("\x1b\\"))
521            .collect();
522        for (i, pl) in payloads.iter().enumerate() {
523            assert!(pl.len() <= 4096, "chunk {i} within the 4096 limit");
524            if i < payloads.len() - 1 {
525                assert!(pl.len() % 4 == 0, "chunk {i} multiple of 4");
526            }
527        }
528        // Reassembling the payloads round-trips the PNG.
529        let joined: String = payloads.concat();
530        let unescaped = unescape_kitty_payload(&joined);
531        let decoded = general_purpose::STANDARD.decode(&unescaped).unwrap();
532        assert_eq!(decoded, png);
533    }
534
535    /// iTerm2 OSC 1337 wrapper layout and base64 round-trip.
536    #[test]
537    fn iterm_osc1337_wraps_base64() {
538        let png: &[u8] = &[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
539        let s = iterm_inline_png(png);
540        assert!(s.starts_with("\x1b]1337;File=inline=1"));
541        assert!(s.contains("preserveAspectRatio=1"));
542        assert!(s.contains("base64="));
543        assert!(s.ends_with('\x07'), "iTerm2 uses BEL as terminator");
544        let b64 = s
545            .split("base64=")
546            .nth(1)
547            .and_then(|t| t.strip_suffix('\x07'))
548            .expect("base64= present");
549        assert_eq!(general_purpose::STANDARD.decode(b64).unwrap(), png);
550    }
551
552    /// Plain-text fallback shape.
553    #[test]
554    fn fallback_format() {
555        assert_eq!(text_fallback("/tmp/a.png"), "[image: /tmp/a.png]");
556        assert_eq!(text_fallback(""), "[image: ]");
557    }
558
559    /// Budget keeps at most `IMAGE_BUDGET_LIMIT` live ids, evicting the
560    /// oldest with a delete command when full, and refreshes position on
561    /// re-record.
562    #[test]
563    fn image_budget_evicts_oldest() {
564        let mut b = ImageBudget::new();
565        for i in 0..IMAGE_BUDGET_LIMIT as u32 {
566            assert!(b.record(i).is_none(), "no eviction for slot {i}");
567        }
568        assert_eq!(b.len(), IMAGE_BUDGET_LIMIT);
569        // The 9th id forces eviction of the oldest (0).
570        let evicted = b.record(99).expect("must emit delete for evicted id");
571        assert!(
572            evicted.starts_with("\x1b_Ga=d,d=I"),
573            "demotion uses d=I so the terminal frees the image data"
574        );
575        assert!(evicted.contains("i=0"), "delete targets the evicted id");
576        assert_eq!(b.len(), IMAGE_BUDGET_LIMIT, "budget stays capped");
577        // Recording an existing id does NOT trigger eviction and refreshes
578        // its position so it is no longer the oldest.
579        assert!(b.record(99).is_none());
580        let evicted = b.record(100).expect("eviction continues");
581        assert!(evicted.contains("i=1"), "the new oldest is id=1, not 99");
582    }
583
584    /// Live emit (kitty): save-cursor + CUP at the anchor + budget-checked
585    /// transmit-once + placement, then restore-cursor. The placed image
586    /// leaves the pending queue.
587    #[test]
588    fn emit_live_kitty_writes_transmit_and_place_at_anchor() {
589        let mut p = ImagePreviews::new(ImageSupport::Kitty);
590        let png = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
591        let id = content_hash_id(&png);
592        p.enqueue(
593            id,
594            std::sync::Arc::new(png),
595            format!("generate_image:{id:08x}"),
596        );
597        p.record_anchor(id, 2, 10, 6, 3);
598        let s = p.emit_live(0);
599        assert!(s.contains("\x1b7"), "save cursor first");
600        assert!(s.contains("\x1b[11;3H"), "CUP to anchor (1-based)");
601        assert!(s.contains("\x1b_Ga=t,f=100"), "transmit present");
602        assert!(s.contains(&format!("i={id}")), "stable content-hash id");
603        assert!(s.contains("a=p"), "placement present");
604        assert!(s.contains("r=6"), "placement sized to the box rows");
605        assert!(s.contains("C=1"), "placement must not move the cursor");
606        assert!(s.contains("\x1b8"), "restore cursor last");
607        assert_eq!(p.pending_len(), 0, "placed image leaves the pending queue");
608    }
609
610    /// Live emit gates: rows already committed to scrollback are dropped
611    /// without writing; the kill-switch and `ImageSupport::None` suppress
612    /// every write (the transcript's fallback text is all the user gets).
613    #[test]
614    fn emit_live_skips_committed_rows_and_disabled() {
615        let png = vec![1u8, 2, 3, 4];
616        let id = content_hash_id(&png);
617
618        // Committed row → dropped, nothing written.
619        let mut p = ImagePreviews::new(ImageSupport::Kitty);
620        p.enqueue(id, std::sync::Arc::new(png.clone()), String::new());
621        p.record_anchor(id, 0, 0, 5, 3);
622        assert!(p.emit_live(4).is_empty(), "committed rows never transmit");
623        assert_eq!(p.pending_len(), 0, "committed pending dropped");
624
625        // Kill-switch off → nothing written.
626        let mut p = ImagePreviews::new(ImageSupport::Kitty);
627        p.set_enabled(false);
628        p.enqueue(id, std::sync::Arc::new(png.clone()), String::new());
629        p.record_anchor(id, 0, 0, 5, 0);
630        assert!(
631            p.emit_live(0).is_empty(),
632            "kill-switch suppresses all writes"
633        );
634
635        // No protocol support → nothing written.
636        let mut p = ImagePreviews::new(ImageSupport::None);
637        p.enqueue(id, std::sync::Arc::new(png), String::new());
638        p.record_anchor(id, 0, 0, 5, 0);
639        assert!(
640            p.emit_live(0).is_empty(),
641            "unsupported terminals get text only"
642        );
643    }
644
645    /// Live emit (iTerm2): the OSC 1337 sequence both uploads and displays
646    /// at the anchored cursor — no separate transmit/place split.
647    #[test]
648    fn emit_live_iterm_writes_osc1337_at_anchor() {
649        let mut p = ImagePreviews::new(ImageSupport::Iterm2);
650        let png = vec![0x89, 0x50, 0x4e, 0x47];
651        let id = content_hash_id(&png);
652        p.enqueue(id, std::sync::Arc::new(png), String::new());
653        p.record_anchor(id, 0, 4, 5, 0);
654        let s = p.emit_live(0);
655        assert!(s.contains("\x1b[5;1H"), "cursor parked on the anchor row");
656        assert!(s.contains("\x1b]1337;File=inline=1"), "inline upload");
657    }
658
659    /// Content-hash ids are stable for identical bytes and distinct for
660    /// different bytes — the dedup key for transmit-once.
661    #[test]
662    fn content_hash_id_stable_and_distinct() {
663        assert_eq!(
664            content_hash_id(b"hello world"),
665            content_hash_id(b"hello world")
666        );
667        assert_ne!(
668            content_hash_id(b"hello world"),
669            content_hash_id(b"hello worlD")
670        );
671    }
672
673    /// Inverse of `escape_kitty_payload`, used to prove the escaping
674    /// round-trips (kept in tests — production never needs to unescape).
675    fn unescape_kitty_payload(s: &str) -> String {
676        let mut out = String::with_capacity(s.len());
677        let mut chars = s.chars().peekable();
678        while let Some(c) = chars.next() {
679            if c == '\\' {
680                match chars.next() {
681                    Some('\\') => out.push('\\'),
682                    Some('c') => out.push(','),
683                    Some(other) => {
684                        out.push('\\');
685                        out.push(other);
686                    }
687                    None => out.push('\\'),
688                }
689            } else {
690                out.push(c);
691            }
692        }
693        out
694    }
695}