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}