konoma 0.28.4

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), a git suite (jj/Jujutsu in preview), and an agent-watch mode that follows your AI's edits (macOS and Linux)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! konoma-native kitty graphics transmit + Unicode-placeholder rendering, with **zlib compression**.
//!
//! ratatui-image already transmits an image once and then draws it with the kitty protocol's
//! [Unicode placeholders], but it sends the pixels as **uncompressed RGBA**. For konoma's primary
//! content — screenshots, diagrams, UI mockups viewed next to an AI agent — that payload is several
//! MB and dominates the "3 seconds to show a full-screen image" latency (konoma's own CPU work is
//! ~37 ms; see `docs/PERF-IMAGE-TRANSFER-2026-07.md`). The kitty protocol supports `o=z`
//! (RFC 1950 zlib) compression of the transmitted data, which cuts that payload 2× (photos) to
//! ~50× (screenshots). ratatui-image exposes no hook to inject compression, so this module
//! re-implements just the kitty path with `o=z` added to the transmit.
//!
//! The Unicode-placeholder **rendering** (row diacritics, the id encoded in the cell foreground
//! color, the save/restore-cursor dance, per-cell skip) is a faithful port of ratatui-image
//! (`protocol/kitty.rs`, MIT) — that part is fiddly and battle-tested, so it is copied rather than
//! reinvented. The one behavioral change is the compressed transmit (`build_transmit`).
//!
//! [Unicode placeholders]: https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders

use std::fmt::Write as _;
use std::io::Write as _;
use std::sync::atomic::{AtomicU32, Ordering};

use base64::Engine as _;
use image::RgbaImage;
use ratatui::buffer::{Buffer, CellDiffOption};
use ratatui::layout::Rect;

/// Force each placeholder cell to be treated as one column wide when diffing (the image data lives
/// in one cell but visually spans the row). Mirrors ratatui-image's `UNIT_WIDTH`.
const UNIT_WIDTH: CellDiffOption =
    CellDiffOption::ForcedWidth(std::num::NonZeroU16::new(1).unwrap());

/// Monotonic image-id source. Each opened image gets a fresh id so a new transmit never collides
/// with a still-referenced older one (re-using an id is destructive: "when re-transmitting image
/// data for a specific id, the existing image and all its placements must be deleted").
///
/// **A fresh id per transmit means the terminal accumulates images, and konoma never deletes any.**
/// What kitty reclaims on its own is only the *placement* — the on-screen instance — once its
/// Unicode placeholders are gone; the *image data* behind it stays resident, counted against the
/// terminal's image-storage quota, until the terminal itself evicts something ("when the terminal
/// is running out of quota space for new images, existing images without placements will be
/// preferentially deleted"). Conflating the two is a trap: it reads as "nothing leaks", when in
/// fact every transmit permanently adds to the quota. That is fine for a *one-shot* transmit — the
/// full-screen path transmits once per opened image, and per zoom/pan step — but it is *not* a
/// licence to transmit on a timer.
///
/// So anything that re-transmits **the same picture** must allocate its id once and then reuse it,
/// which turns each later transmit into a replacement rather than an addition (the destructive
/// re-use quoted above is exactly the wanted behavior there). That is what the inline Markdown
/// image path does: `App::md_kitty_ids` pins one id per picture per protocol slot, so an animated
/// GIF cycling its frames forever — and a document opened, closed and opened again all day, which
/// re-transmits from scratch every time because the decode cache does not survive a file switch —
/// both occupy a constant amount of terminal storage. Before that, it went through
/// ratatui-image's `Picker::new_protocol`, which picks its id with `rand::random()` — a
/// fresh id **per frame**, measured at ~66 MB/min against Ghostty's 320 MB budget, which evicted a
/// long-lived still image out from under a placeholder konoma would never re-transmit (the still
/// simply vanished after ~5 minutes).
///
/// [Deleting images]: https://sw.kovidgoyal.net/kitty/graphics-protocol/#deleting-images
static NEXT_ID: AtomicU32 = AtomicU32::new(1);

/// Allocate a fresh kitty image id (never 0 — kitty treats 0 as "unspecified").
pub fn next_id() -> u32 {
    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
    if id == 0 {
        NEXT_ID.fetch_add(1, Ordering::Relaxed)
    } else {
        id
    }
}

/// zlib-compress `raw` at a middle level (fast enough on the worker thread, near-max ratio for the
/// flat regions typical of screenshots/diagrams). Returns the RFC 1950 zlib stream `o=z` expects.
fn zlib_compress(raw: &[u8]) -> Vec<u8> {
    let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::new(6));
    // Writing to a Vec never fails; keep the payload even if flush somehow errs (worst case the
    // terminal rejects it and the image simply does not show — never a crash).
    let _ = enc.write_all(raw);
    enc.finish().unwrap_or_default()
}

/// A prepared, compressed kitty image ready to transmit once and then place with Unicode
/// placeholders. Cheap to clone (holds the built escape strings).
#[derive(Clone)]
pub struct KittyImage {
    /// The full transmit escape (compressed pixels + virtual-placement header), sent on first render.
    transmit: String,
    /// Set once the transmit has been written into a frame; subsequent frames emit placeholders only.
    /// `Arc<AtomicBool>` (not `Rc<Cell>`) so a `KittyImage` built on the async worker thread is `Send`.
    transmitted: std::sync::Arc<std::sync::atomic::AtomicBool>,
    /// Display size in cells (the image was resized to exactly this many cells).
    cols: u16,
    rows: u16,
    /// The id encoded as a foreground-color SGR (`\x1b[38;2;r;g;b m`) — how a placeholder cell names
    /// its image — plus the extra high byte carried as a diacritic.
    id_color: String,
    id_extra: u16,
}

impl KittyImage {
    /// Build a compressed kitty image from an already-resized RGBA buffer (`cols`×`rows` cells).
    /// The pixel dimensions are taken from `rgba`; `cols`/`rows` are how many terminal cells it fills.
    pub fn build(rgba: &RgbaImage, cols: u16, rows: u16, id: u32, is_tmux: bool) -> Self {
        let transmit = build_transmit(rgba, id, is_tmux);
        let [id_extra, r, g, b] = id.to_be_bytes();
        Self {
            transmit,
            transmitted: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
            cols,
            rows,
            id_color: format!("\x1b[38;2;{r};{g};{b}m"),
            id_extra: u16::from(id_extra),
        }
    }

    /// Display size in cells this image was built (resized) for. The full-screen path compares it
    /// against the wanted target to know a terminal resize forces a rebuild; the inline path uses it
    /// to reproduce `ratatui_image::Image`'s "refuse to draw into an area smaller than the image".
    pub fn cell_size(&self) -> (u16, u16) {
        (self.cols, self.rows)
    }

    /// Bytes of the (compressed, base64'd) transmit escape this image carries. Test-only: lets a
    /// test state the compression win, and the constant per-frame residency, in real numbers.
    #[cfg(test)]
    pub fn transmit_len(&self) -> usize {
        self.transmit.len()
    }

    /// Emit the image into `buf` at `area`: the transmit sequence once (first frame), then the
    /// Unicode-placeholder rows every frame. Faithful port of ratatui-image's kitty `render`.
    pub fn render(&self, area: Rect, buf: &mut Buffer) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        let full_width = area.width.min(self.cols);
        let width_usize = usize::from(full_width);
        if width_usize == 0 {
            return;
        }

        // Transmit only on the first render of this image.
        let already = self
            .transmitted
            .swap(true, std::sync::atomic::Ordering::SeqCst);
        let mut seq: Option<&str> = if already {
            None
        } else {
            Some(self.transmit.as_str())
        };

        let row_diacritics: String =
            std::iter::repeat_n('\u{10EEEE}', width_usize.saturating_sub(1)).collect();

        // Restore the saved cursor (including its color), then walk to the row's end.
        let right = area.width - 1;
        let down = area.height - 1;
        let restore_cursor = format!("\x1b[u\x1b[{right}C\x1b[{down}B");

        // Clamp to the number of encodable placeholder rows (one diacritic per row).
        let height = area.height.min(self.rows).min(DIACRITICS.len() as u16);
        let mut symbol = String::new();
        for y in 0..height {
            symbol.clear();
            // Prepend the transmit exactly once, on the first row.
            if let Some(s) = seq.take() {
                symbol.push_str(s);
            }
            // Save cursor + fg color, then start the placeholder run: image row `y`, column 0,
            // and the id's high byte as a third diacritic.
            let _ = write!(
                symbol,
                "\x1b[s{}\u{10EEEE}{}{}{}",
                self.id_color,
                diacritic(y),
                diacritic(0),
                diacritic(self.id_extra),
            );
            symbol.push_str(&row_diacritics);
            // Mark the remaining cells of this row as skip so ratatui does not overwrite the image.
            for x in 1..full_width {
                if let Some(cell) = buf.cell_mut((area.left() + x, area.top() + y)) {
                    cell.set_diff_option(CellDiffOption::Skip);
                }
            }
            symbol.push_str(&restore_cursor);
            if let Some(cell) = buf.cell_mut((area.left(), area.top() + y)) {
                cell.set_symbol(&symbol).set_diff_option(UNIT_WIDTH);
            }
        }
    }
}

/// Crop `src` to `crop_rect` (source px), resize to the display cell area at native font
/// resolution, and build the compressed (`o=z`) `KittyImage`. Pure and `Send`-friendly, so it runs
/// either on the render thread (first build) or a worker (zoom/pan). None if the target is empty.
pub fn build_from_source(
    src: &image::DynamicImage,
    crop_rect: (u32, u32, u32, u32),
    cols: u16,
    rows: u16,
    font: ratatui_image::FontSize,
    is_tmux: bool,
) -> Option<KittyImage> {
    if cols == 0 || rows == 0 {
        return None;
    }
    // Native resolution: one image pixel per screen pixel = cols/rows cells × the font cell size.
    let px_w = u32::from(cols) * u32::from(font.width.max(1));
    let px_h = u32::from(rows) * u32::from(font.height.max(1));
    let (x0, y0, cw, ch) = crop_rect;
    let resized = src
        .crop_imm(x0, y0, cw, ch)
        .resize_exact(px_w, px_h, image::imageops::FilterType::Lanczos3)
        .to_rgba8();
    Some(KittyImage::build(&resized, cols, rows, next_id(), is_tmux))
}

/// Build the kitty transmit escape for `img`, **zlib-compressed** (`o=z`), with a virtual
/// placement (`U=1`) so it can be positioned with Unicode placeholders.
///
/// Structure per the kitty spec: the pixels are RGBA (`f=32`), compressed as one zlib stream, then
/// base64-encoded and split into ≤4096-char chunks. The first chunk carries the control keys; every
/// chunk sets `m=1` until the last (`m=0`). Wrapped in tmux passthrough when `is_tmux`.
///
/// This is the one place that diverges from ratatui-image: the payload is compressed and the header
/// gains `o=z`.
pub fn build_transmit(img: &RgbaImage, id: u32, is_tmux: bool) -> String {
    let (w, h) = (img.width(), img.height());
    let compressed = zlib_compress(img.as_raw());
    let b64 = base64::engine::general_purpose::STANDARD.encode(&compressed);

    let (start, escape, end) = tmux_wrap(is_tmux);

    const CHARS_PER_CHUNK: usize = 4096;
    let chunk_count = b64.len().div_ceil(CHARS_PER_CHUNK).max(1);
    let mut data = String::with_capacity(b64.len() + chunk_count * (16 + escape.len() * 2));

    let bytes = b64.as_bytes();
    for i in 0..chunk_count {
        let lo = i * CHARS_PER_CHUNK;
        let hi = (lo + CHARS_PER_CHUNK).min(b64.len());
        data.push_str(start);
        let _ = write!(data, "{escape}_Gq=2,");
        if i == 0 {
            // a=T transmit+display, U=1 virtual placement, f=32 RGBA, o=z zlib, t=d direct data.
            let _ = write!(data, "i={id},a=T,U=1,f=32,o=z,t=d,s={w},v={h},");
        }
        let more = u8::from(i + 1 < chunk_count);
        let _ = write!(data, "m={more};");
        // SAFETY: base64 output is ASCII, so any 4096-byte slice is a valid str.
        data.push_str(std::str::from_utf8(&bytes[lo..hi]).unwrap_or(""));
        let _ = write!(data, "{escape}\\");
        data.push_str(end);
    }
    data
}

/// tmux passthrough wrapping: `(start, escape, end)`. Outside tmux these are empty / bare ESC.
/// Mirrors ratatui-image's `Parser::tmux_start_escape_end`.
fn tmux_wrap(is_tmux: bool) -> (&'static str, &'static str, &'static str) {
    if is_tmux {
        ("\x1bPtmux;", "\x1b\x1b", "\x1b\\")
    } else {
        ("", "\x1b", "")
    }
}

/// The combining mark that encodes placeholder row/column index `i` (clamped to the first on
/// overflow — kitty only defines 297).
fn diacritic(i: u16) -> char {
    *DIACRITICS.get(usize::from(i)).unwrap_or(&DIACRITICS[0])
}

include!("kitty_diacritics.rs");

#[cfg(test)]
mod tests {
    use super::*;
    use image::{Rgba, RgbaImage};

    /// A small solid-color test image.
    fn solid(w: u32, h: u32, px: [u8; 4]) -> RgbaImage {
        RgbaImage::from_pixel(w, h, Rgba(px))
    }

    /// Pull the base64 payload out of a (non-tmux) transmit escape and concatenate all chunks.
    fn payload_b64(transmit: &str) -> String {
        // Each chunk is `\x1b_G...;<b64>\x1b\\`. Take the text between the last `;` of the header
        // and the terminating `\x1b\\` for every chunk.
        let mut out = String::new();
        for chunk in transmit.split("\x1b_G").skip(1) {
            let after_header = &chunk[chunk.find(';').expect("chunk has a header") + 1..];
            let body = &after_header[..after_header.find('\x1b').expect("chunk terminator")];
            out.push_str(body);
        }
        out
    }

    #[test]
    fn transmit_round_trips_through_zlib() {
        // Non-solid content so compression is a real (not degenerate) round-trip.
        let mut img = RgbaImage::new(20, 10);
        for (x, y, p) in img.enumerate_pixels_mut() {
            *p = Rgba([(x * 11) as u8, (y * 23) as u8, (x + y) as u8, 255]);
        }
        let t = build_transmit(&img, 7, false);

        // Header sanity: RGBA, zlib, virtual placement, correct dimensions.
        assert!(t.contains("i=7,a=T,U=1,f=32,o=z,t=d,s=20,v=10,"));

        // Decode base64 → zlib-inflate → must equal the original RGBA bytes exactly.
        let b64 = payload_b64(&t);
        let compressed = base64::engine::general_purpose::STANDARD
            .decode(b64.as_bytes())
            .expect("valid base64");
        let mut dec = flate2::read::ZlibDecoder::new(&compressed[..]);
        let mut raw = Vec::new();
        std::io::Read::read_to_end(&mut dec, &mut raw).expect("valid zlib");
        assert_eq!(raw, *img.as_raw(), "decompressed pixels match the source");
    }

    #[test]
    fn compression_shrinks_flat_images() {
        // A screenshot-like flat image should compress dramatically (the whole point).
        let img = solid(400, 300, [30, 40, 55, 255]);
        let raw_b64 = img.as_raw().len().div_ceil(3) * 4;
        let compressed_b64 = payload_b64(&build_transmit(&img, 1, false)).len();
        assert!(
            compressed_b64 * 10 < raw_b64,
            "flat image compresses >10x: {compressed_b64} vs {raw_b64}"
        );
    }

    #[test]
    fn tmux_wraps_each_chunk() {
        // Force multiple chunks with a larger image; every chunk must carry the tmux passthrough.
        let img = solid(200, 200, [1, 2, 3, 255]);
        let t = build_transmit(&img, 3, true);
        assert!(t.starts_with("\x1bPtmux;"), "tmux passthrough start");
        assert!(t.contains("\x1b\x1b_G"), "escapes are doubled inside tmux");
    }

    #[test]
    fn render_transmits_once_then_placeholders_only() {
        let img = solid(30, 20, [200, 100, 50, 255]);
        // 3 cols x 2 rows display.
        let ki = KittyImage::build(&img, 3, 2, 9, false);
        let area = Rect::new(0, 0, 3, 2);

        let mut buf = Buffer::empty(area);
        ki.render(area, &mut buf);
        let first: String = buf.content.iter().map(|c| c.symbol()).collect();
        assert!(first.contains("o=z"), "first render carries the transmit");
        assert!(first.contains('\u{10EEEE}'), "placeholders are drawn");

        // A second render (same image) must NOT re-transmit — that is the whole savings.
        let mut buf2 = Buffer::empty(area);
        ki.render(area, &mut buf2);
        let second: String = buf2.content.iter().map(|c| c.symbol()).collect();
        assert!(
            !second.contains("o=z"),
            "second render is placeholders only"
        );
        assert!(second.contains('\u{10EEEE}'), "placeholders still drawn");
    }

    #[test]
    fn diacritics_table_is_complete() {
        assert_eq!(DIACRITICS.len(), 297);
        assert_eq!(diacritic(0), DIACRITICS[0]);
        assert_eq!(diacritic(296), DIACRITICS[296]);
        assert_eq!(diacritic(999), DIACRITICS[0], "overflow clamps to first");
    }

    #[test]
    fn fresh_ids_are_nonzero_and_unique() {
        let a = next_id();
        let b = next_id();
        assert_ne!(a, 0);
        assert_ne!(a, b);
    }

    #[test]
    fn cell_size_reports_build_dimensions() {
        // A terminal resize keeps the crop but changes the fit area; prepare_image compares this
        // against the target to know it must rebuild (regression: image kept its pre-resize size).
        let img = solid(30, 20, [10, 20, 30, 255]);
        let ki = KittyImage::build(&img, 7, 4, 1, false);
        assert_eq!(ki.cell_size(), (7, 4));
    }

    #[test]
    fn render_clamps_to_area_smaller_than_image() {
        // Area smaller than the built cell size (terminal shrank before a rebuild): must not panic,
        // and must emit at most `area` rows of placeholders.
        let img = solid(40, 40, [5, 5, 5, 255]);
        let ki = KittyImage::build(&img, 10, 8, 2, false);
        let area = Rect::new(0, 0, 3, 2); // smaller than 10x8
        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 8));
        ki.render(area, &mut buf);
        // Rows outside the (small) area stay blank.
        let outside = buf.cell((0, 5)).map(|c| c.symbol().to_string());
        assert_eq!(
            outside.as_deref(),
            Some(" "),
            "no placeholder beyond the area"
        );
    }
}