fix(kitty-graphics): zlib-compress images forwarded to the host terminal (#5608)

* fix(kitty-graphics): zlib-compress images forwarded to the host terminal

`emit_kitty_transmit` sent every image to the host as uncompressed RGBA
base64 (`f=32,t=d`), about 20x the size of a PNG the application sent,
and re-sent it on every tab switch, pane split and resize step. Compress
the cached host payload with zlib and tag the transmit with `o=z`, which
the kitty protocol defines as part of its base feature set.

Measured with a headless client at 429x77 (8x17 px cells): a 121 KB PNG
went from 2.4 MB to 163 KB on the wire, a 943 KB PNG from 19.9 MB to
1.9 MB, with no measurable change in server CPU.

Fixes #5596

* fix: conditional zlib compression depending on outer terminal support

* rustfmt

* add pr

---------

Co-authored-by: Aram Drevekenin <[email protected]>
This commit is contained in:
Tyler Keller
2026-09-25 10:33:28 +02:00
committed by GitHub
co-authored by Aram Drevekenin
parent 3e470a43dc
commit 2fce032cce
22 changed files with 554 additions and 42 deletions
+1
View File
@@ -24,4 +24,5 @@ vte = { workspace = true }
[dev-dependencies]
base64 = { version = "0.22", default-features = false, features = ["std"] }
flate2 = { version = "1.1.9", default-features = false, features = ["rust_backend"] }
tempfile = { workspace = true }
@@ -28,6 +28,7 @@ struct ReceivedBytesWithChangeSignal {
pub enum HostTerminal {
Basic,
Kitty,
KittyZlib,
Manual,
}
@@ -35,6 +36,8 @@ const PRIMARY_DA_QUERY: &[u8] = b"\x1b[c";
const PRIMARY_DA_REPLY: &[u8] = b"\x1b[?62;22c";
const KITTY_GRAPHICS_PROBE_PREFIX: &[u8] = b"\x1b_Ga=q,i=31,";
const KITTY_GRAPHICS_PROBE_REPLY: &[u8] = b"\x1b_Gi=31;OK\x1b\\";
const KITTY_ZLIB_PROBE_PREFIX: &[u8] = b"\x1b_Ga=q,i=32,";
const KITTY_ZLIB_PROBE_REPLY: &[u8] = b"\x1b_Gi=32;OK\x1b\\";
const CELL_SIZE_QUERY: &[u8] = b"\x1b[16t";
const CELL_SIZE_REPLY: &[u8] = b"\x1b[6;21;8t";
@@ -53,6 +56,12 @@ impl HostResponder {
(KITTY_GRAPHICS_PROBE_PREFIX, KITTY_GRAPHICS_PROBE_REPLY),
(CELL_SIZE_QUERY, CELL_SIZE_REPLY),
],
HostTerminal::KittyZlib => &[
(PRIMARY_DA_QUERY, PRIMARY_DA_REPLY),
(KITTY_GRAPHICS_PROBE_PREFIX, KITTY_GRAPHICS_PROBE_REPLY),
(KITTY_ZLIB_PROBE_PREFIX, KITTY_ZLIB_PROBE_REPLY),
(CELL_SIZE_QUERY, CELL_SIZE_REPLY),
],
HostTerminal::Manual => &[],
}
}
@@ -65,6 +65,12 @@ fn start_zellij() -> TestSession {
.start()
}
fn start_zellij_with_a_zlib_capable_host() -> TestSession {
TestRunner::new(TERMINAL_SIZE)
.with_host_terminal(HostTerminal::KittyZlib)
.start()
}
fn setup_kitty_host(zellij: &TestSession, terminal: &FakePtyHandle) {
terminal.disable_echo();
zellij.send_stdin(b"Z");
@@ -107,6 +113,41 @@ fn pane_kitty_image_reaches_client_with_transmit_and_placement() {
zellij.quit();
}
#[test]
fn kitty_image_is_zlib_compressed_when_the_host_confirms_zlib_support() {
use base64::Engine as _;
use std::io::Read;
let mut zellij = start_zellij_with_a_zlib_capable_host();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
setup_kitty_host(&zellij, &terminal);
terminal.output(RGB_2X2_A_T);
let compressed_header: &[u8] = b"\x1b_Ga=t,q=2,f=32,o=z,t=d,i=2000000000,s=2,v=2,m=0;";
let bytes = zellij.wait_until_raw_output(
"compressed kitty transmit and placement reach the client",
|bytes| {
contains_bytes(bytes, compressed_header)
&& contains_bytes(bytes, PLACEMENT)
&& !contains_bytes(bytes, TRANSMIT_HEADER)
},
);
let payload_start = find_bytes(&bytes, compressed_header).unwrap() + compressed_header.len();
let payload_len = find_bytes(&bytes[payload_start..], b"\x1b\\").unwrap();
let compressed = base64::engine::general_purpose::STANDARD
.decode(&bytes[payload_start..payload_start + payload_len])
.unwrap();
let mut inflated = Vec::new();
flate2::read::ZlibDecoder::new(&compressed[..])
.read_to_end(&mut inflated)
.unwrap();
assert_eq!(inflated, vec![0xffu8; 2 * 2 * 4]);
zellij.quit();
}
#[test]
fn kitty_image_at_bottom_row_scrolls_and_reaches_client() {
let mut zellij = start_zellij();