aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-27 21:05:25 +0300
committerGitHub <noreply@github.com>2026-09-27 21:05:25 +0300
commit25aaa0b7e31b0b271ce72a69c9dd272d7ee83376 (patch)
tree9cb45a72cfaa973434532bdf4b872338d64da2f8
parent2552d101afe46b8622a7695f38f6cd1766dd1afc (diff)
fix: keep offscreen kitty images loaded instead of resending them on scroll (#4686)
-rw-r--r--crates/ghostty-vt/src/lib.rs79
-rw-r--r--src/kitty_graphics.rs16
-rw-r--r--src/kitty_graphics/surface.rs107
-rw-r--r--src/pane.rs4
-rw-r--r--src/pane/terminal.rs12
-rw-r--r--src/server/headless/tests/pane_graphics.rs217
-rw-r--r--src/terminal/runtime.rs4
7 files changed, 412 insertions, 27 deletions
diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs
index ef62ef1e..056ff142 100644
--- a/crates/ghostty-vt/src/lib.rs
+++ b/crates/ghostty-vt/src/lib.rs
@@ -12,7 +12,7 @@ pub mod bindings;
use std::cell::Cell;
use std::collections::hash_map::DefaultHasher;
-use std::collections::{HashMap, HashSet};
+use std::collections::HashMap;
use std::ffi::c_void;
use std::fmt;
use std::hash::{Hash, Hasher};
@@ -1824,6 +1824,37 @@ impl Terminal {
Ok(generation != 0 && self.kitty_empty_generation.get() != Some(generation))
}
+ /// Current fingerprint of each stored image, placed or not, when it is known
+ /// without hashing pixels. `None` means deleted or replaced since last seen.
+ pub fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Result<Vec<Option<u64>>, Error> {
+ let graphics = self.kitty_graphics()?;
+ if graphics.is_null() {
+ return Ok(vec![None; image_ids.len()]);
+ }
+ image_ids
+ .iter()
+ .map(|&image_id| {
+ let image = unsafe { ffi::ghostty_kitty_graphics_image(graphics, image_id) };
+ if image.is_null() {
+ return Ok(None);
+ }
+ if let Some(source) = native_source::image_source(image)? {
+ return Ok(Some(source.fingerprint()));
+ }
+ let generation = kitty_image_u64(
+ image,
+ ffi::GhosttyKittyGraphicsImageData_GHOSTTY_KITTY_IMAGE_DATA_GENERATION,
+ )?;
+ Ok(self.kitty_fingerprints.lock().ok().and_then(|cache| {
+ cache
+ .get(&image_id)
+ .filter(|entry| entry.generation == generation)
+ .map(|entry| entry.fingerprint)
+ }))
+ })
+ .collect()
+ }
+
pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> {
self.kitty_image_placements_with_data_filter(|_| true)
}
@@ -1872,13 +1903,13 @@ impl Terminal {
}
if !storage_has_placements {
self.kitty_empty_generation.set(Some(generation));
- self.prune_kitty_fingerprints(&[]);
+ self.prune_kitty_fingerprints(graphics);
return Ok(Vec::new());
}
placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?);
placements.sort_by_key(|placement| placement.z);
- self.prune_kitty_fingerprints(&placements);
+ self.prune_kitty_fingerprints(graphics);
Ok(placements)
}
@@ -1923,16 +1954,13 @@ impl Terminal {
fingerprint
}
- fn prune_kitty_fingerprints(&self, placements: &[KittyImagePlacement]) {
+ /// Keeps fingerprints for hidden images so scrolling them back into view
+ /// does not re-hash their pixels; generation checks catch replacements.
+ fn prune_kitty_fingerprints(&self, graphics: ffi::GhosttyKittyGraphics) {
if let Ok(mut cache) = self.kitty_fingerprints.lock() {
- if cache.is_empty() {
- return;
- }
- let live: HashSet<u32> = placements
- .iter()
- .map(|placement| placement.image_id)
- .collect();
- cache.retain(|image_id, _| live.contains(image_id));
+ cache.retain(|image_id, _| {
+ !unsafe { ffi::ghostty_kitty_graphics_image(graphics, *image_id) }.is_null()
+ });
}
}
@@ -3851,6 +3879,33 @@ mod tests {
}
#[test]
+ fn kitty_image_fingerprint_survives_hidden_placements_until_the_image_is_deleted() {
+ let mut terminal = Terminal::new(10, 5, 0).unwrap();
+ let cached =
+ |terminal: &Terminal| terminal.kitty_fingerprints.lock().unwrap().contains_key(&7);
+ terminal.write(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=10,r=5,q=2;/wAA/w==\x1b\\");
+ assert_eq!(
+ terminal
+ .kitty_image_placements_with_data_filter(|_| false)
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert!(cached(&terminal));
+
+ terminal.write(b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ assert!(terminal
+ .kitty_image_placements_with_data_filter(|_| false)
+ .unwrap()
+ .is_empty());
+ assert!(cached(&terminal), "a hidden image must not be re-hashed");
+
+ terminal.write(b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\");
+ let _ = terminal.kitty_image_placements_with_data_filter(|_| false);
+ assert!(!cached(&terminal));
+ }
+
+ #[test]
fn kitty_storage_generation_skips_only_proven_empty_storage() {
let mut terminal = Terminal::new(10, 5, 1_000_000).unwrap();
terminal.enable_kitty_graphics().unwrap();
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs
index e69b9df3..f26cd8d9 100644
--- a/src/kitty_graphics.rs
+++ b/src/kitty_graphics.rs
@@ -335,16 +335,20 @@ fn collect_visible_placements(
}
};
let mut requested_images = HashSet::new();
- for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| {
+ let pane_placements = runtime.kitty_image_placements_with_data_filter(|descriptor| {
if descriptor.source_file {
return false;
}
terminal_image_needs_data(info.id, descriptor, delivered_images, &mut requested_images)
- }) {
- let scrollback_offset = runtime
- .scroll_metrics()
- .map(|m| m.offset_from_bottom as u32)
- .unwrap_or(0);
+ });
+ if pane_placements.is_empty() {
+ continue;
+ }
+ let scrollback_offset = runtime
+ .scroll_metrics()
+ .map(|m| m.offset_from_bottom as u32)
+ .unwrap_or(0);
+ for placement in pane_placements {
placements.push(HostPlacement {
raw_data: None,
pane_id: info.id,
diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs
index ee6d6e97..09db2286 100644
--- a/src/kitty_graphics/surface.rs
+++ b/src/kitty_graphics/surface.rs
@@ -20,10 +20,17 @@ use crate::protocol::{
};
const MAX_SURFACE_GRAPHICS_PLACEMENTS: usize = 4_096;
+// Apps such as pi scroll images out of view and back. Keeping a bounded set
+// loaded on the host avoids resending their pixels on every pass. The limits
+// match pi's own off-screen cache in decoded bytes, which is what hosts store.
+const MAX_OFFSCREEN_IMAGES: usize = 16;
+const MAX_OFFSCREEN_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Clone, Debug, Default)]
pub(crate) struct DeliveryCache {
assets: HashSet<SurfaceGraphicsAssetKey>,
+ /// Delivered terminal images without a placement, most recently visible first.
+ offscreen: Vec<SurfaceGraphicsAssetKey>,
pending: bool,
}
@@ -33,6 +40,7 @@ pub(crate) type SourceFiles =
impl DeliveryCache {
pub(crate) fn forget_asset(&mut self, key: &SurfaceGraphicsAssetKey) {
self.assets.remove(key);
+ self.offscreen.retain(|offscreen| offscreen != key);
self.pending = true;
}
@@ -730,8 +738,15 @@ pub(crate) fn collect_scene(
.iter()
.map(|placement| placement.asset.clone())
.collect::<HashSet<_>>();
+ let offscreen = offscreen_assets(app, workspace_index, &public_panes, delivered, &desired);
let mut next = DeliveryCache {
- assets: delivered.assets.intersection(&desired).cloned().collect(),
+ assets: delivered
+ .assets
+ .intersection(&desired)
+ .chain(&offscreen)
+ .cloned()
+ .collect(),
+ offscreen: offscreen.clone(),
pending: false,
};
let mut assets = Vec::new();
@@ -769,19 +784,105 @@ pub(crate) fn collect_scene(
placement.x,
)
});
- let retained_assets = Vec::new();
sources.retain(|key, _| next.assets.contains(key));
(
SurfaceGraphicsScene {
assets,
placements,
- retained_assets,
+ retained_assets: offscreen,
},
next,
sources,
)
}
+/// Delivered pane images that left the view but still exist in their terminal,
+/// bounded and ordered from most to least recently visible.
+fn offscreen_assets(
+ app: &crate::app::App,
+ workspace_index: Option<usize>,
+ public_panes: &HashMap<String, PaneId>,
+ delivered: &DeliveryCache,
+ visible: &HashSet<SurfaceGraphicsAssetKey>,
+) -> Vec<SurfaceGraphicsAssetKey> {
+ let Some(workspace_index) = workspace_index else {
+ return Vec::new();
+ };
+ fn pane_image(key: &SurfaceGraphicsAssetKey) -> Option<(&str, u32)> {
+ match &key.source {
+ SurfaceGraphicsSource::Terminal {
+ target: SurfaceGraphicsTarget::Pane { pane_id },
+ image_id,
+ } => Some((pane_id.as_str(), *image_id)),
+ _ => None,
+ }
+ }
+ let mut newly_hidden = delivered
+ .assets
+ .iter()
+ .filter(|key| !visible.contains(*key) && !delivered.offscreen.contains(key))
+ .collect::<Vec<_>>();
+ if newly_hidden.is_empty() && delivered.offscreen.is_empty() {
+ return Vec::new();
+ }
+ newly_hidden.sort_by_key(|key| pane_image(key));
+ let visible_sources = visible
+ .iter()
+ .map(|key| &key.source)
+ .collect::<HashSet<_>>();
+ let mut candidates_by_pane = HashMap::<PaneId, Vec<&SurfaceGraphicsAssetKey>>::new();
+ let candidates = newly_hidden
+ .into_iter()
+ .chain(&delivered.offscreen)
+ .filter(|key| {
+ !visible_sources.contains(&key.source) && key.data_len <= MAX_OFFSCREEN_IMAGE_BYTES
+ })
+ .filter_map(|key| {
+ let (public_id, _) = pane_image(key)?;
+ let pane_id = *public_panes.get(public_id)?;
+ candidates_by_pane.entry(pane_id).or_default().push(key);
+ Some(key)
+ })
+ .collect::<Vec<_>>();
+
+ let mut live = HashSet::new();
+ for (pane_id, keys) in candidates_by_pane {
+ let Some(runtime) = app.state.runtime_for_pane_in_workspace(
+ &app.terminal_runtimes,
+ workspace_index,
+ pane_id,
+ ) else {
+ continue;
+ };
+ let image_ids = keys
+ .iter()
+ .filter_map(|key| pane_image(key).map(|(_, image_id)| image_id))
+ .collect::<Vec<_>>();
+ for (key, fingerprint) in keys
+ .into_iter()
+ .zip(runtime.kitty_image_fingerprints(&image_ids))
+ {
+ if fingerprint == Some(key.data_fingerprint) {
+ live.insert(key);
+ }
+ }
+ }
+
+ let mut bytes = 0u64;
+ let mut offscreen = Vec::new();
+ for key in candidates {
+ if offscreen.len() == MAX_OFFSCREEN_IMAGES {
+ break;
+ }
+ if !live.contains(key) || bytes.saturating_add(key.data_len) > MAX_OFFSCREEN_IMAGE_BYTES {
+ continue;
+ }
+ bytes += key.data_len;
+ offscreen.push(key.clone());
+ }
+ offscreen
+}
+
fn image_signature_from_asset(key: &SurfaceGraphicsAssetKey) -> ImageSignature {
ImageSignature {
image_width: key.image_width,
diff --git a/src/pane.rs b/src/pane.rs
index e5695675..8fd8e396 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -3496,6 +3496,10 @@ impl PaneRuntime {
.kitty_image_placements_with_data_filter(needs_data)
}
+ pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> {
+ self.terminal.kitty_image_fingerprints(image_ids)
+ }
+
pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol {
let fallback = crate::input::KeyboardProtocol::from_kitty_flags(
self.kitty_keyboard_flags.load(Ordering::Relaxed),
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 5eaddb21..8ac4bd1c 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -579,6 +579,10 @@ impl PaneTerminal {
.kitty_image_placements_with_data_filter(needs_data)
}
+ pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> {
+ self.ghostty.kitty_image_fingerprints(image_ids)
+ }
+
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
self.ghostty.apply_host_terminal_theme(theme);
}
@@ -2349,6 +2353,14 @@ impl GhosttyPaneTerminal {
.unwrap_or_default()
}
+ pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|core| core.terminal.kitty_image_fingerprints(image_ids).ok())
+ .unwrap_or_else(|| vec![None; image_ids.len()])
+ }
+
pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
let Ok(mut core) = self.core.lock() else {
return;
diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs
index 190eaa80..1255f4eb 100644
--- a/src/server/headless/tests/pane_graphics.rs
+++ b/src/server/headless/tests/pane_graphics.rs
@@ -97,6 +97,73 @@ async fn first_kitty_image_updates_retained_surface_without_full_redraw() {
}
#[tokio::test]
+async fn offscreen_images_keep_text_updates_on_the_retained_path() {
+ let (mut server, _control_rx, client_rx, pane_id) = retained_test_server_with_control(
+ b"\x1b_Ga=T,f=32,t=d,i=7,s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\",
+ );
+ let client = server.clients.get_mut(&1).unwrap();
+ client.mode = ClientConnectionMode::ClientShell;
+ client.render_state =
+ crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame);
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+ let sources = HashSet::from([pane_id]);
+ server.render_and_stream();
+ let _ = receive_render(&client_rx, Duration::from_millis(100));
+
+ write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ let ServerMessage::PaneSurface(hidden) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected hidden image scene");
+ };
+ assert!(hidden.graphics.placements.is_empty());
+ assert_eq!(hidden.graphics.retained_assets.len(), 1);
+
+ // Clients reject row patches while images are retained, so the text must
+ // arrive in a surface that keeps the hidden image without resending it.
+ write_shared_test_pane(&mut server, pane_id, b"\rtext while hidden");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ let ServerMessage::PaneSurface(text_update) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected a surface the client accepts while images are retained");
+ };
+ assert!(frame_text(&text_update.frame).contains("text while hidden"));
+ assert!(text_update.graphics.placements.is_empty());
+ assert!(text_update.graphics.assets.is_empty());
+ assert_eq!(
+ text_update.graphics.retained_assets,
+ hidden.graphics.retained_assets
+ );
+
+ write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ let ServerMessage::PaneSurface(shown) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected the hidden image to return");
+ };
+ assert_eq!(shown.graphics.placements.len(), 1);
+ assert!(shown.graphics.assets.is_empty());
+
+ write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ let _ = receive_render(&client_rx, Duration::from_millis(100));
+ write_shared_test_pane(&mut server, pane_id, b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\");
+ assert!(server.render_retained_pane_surface_and_stream(&sources));
+ let ServerMessage::PaneSurface(deleted) =
+ read_server_message(receive_render(&client_rx, Duration::from_millis(100)))
+ else {
+ panic!("expected the deleted image to be released");
+ };
+ assert!(deleted.graphics.retained_assets.is_empty());
+}
+
+#[tokio::test]
async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload() {
let (mut server, _control_rx, client_rx, pane_id) =
retained_test_server_with_control(b"\x1b[?1049h");
@@ -174,7 +241,7 @@ async fn retained_unicode_image_arrives_after_fragmented_upload_without_reupload
async fn render_scale_profile_retained_graphics() {
use ratatui::layout::Direction;
for retained in [false, true] {
- for with_image in [false, true] {
+ for image in ["none", "visible", "offscreen"] {
for count in [1, 15] {
let (mut server, _control_rx, client_rx, root) =
retained_test_server_with_control(b"populated terminal\r\n");
@@ -205,7 +272,7 @@ async fn render_scale_profile_retained_graphics() {
width_px: 10,
height_px: 20,
};
- if with_image {
+ if image != "none" {
write_shared_test_pane(
&mut server,
root,
@@ -214,6 +281,11 @@ async fn render_scale_profile_retained_graphics() {
}
server.render_and_stream();
let _ = receive_render(&client_rx, Duration::from_millis(100));
+ if image == "offscreen" {
+ write_shared_test_pane(&mut server, root, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ server.render_and_stream();
+ let _ = receive_render(&client_rx, Duration::from_millis(100));
+ }
let sources = pane_ids.iter().copied().collect();
let mut samples = Vec::new();
for sample in 0..110 {
@@ -242,10 +314,10 @@ async fn render_scale_profile_retained_graphics() {
}
samples.sort_unstable();
println!(
- "retained={retained} 80x24 panes={count} image={with_image} median_us={} p95_us={}",
- samples[50].as_micros(),
- samples[94].as_micros()
- );
+ "retained={retained} 80x24 panes={count} image={image} median_us={} p95_us={}",
+ samples[50].as_micros(),
+ samples[94].as_micros()
+ );
}
}
}
@@ -322,6 +394,139 @@ async fn client_shell_delivers_equal_pixels_for_distinct_terminal_image_ids() {
assert!(surface.graphics.assets.is_empty());
}
+#[tokio::test]
+async fn client_shell_keeps_offscreen_terminal_image_loaded_until_the_image_is_deleted() {
+ let (mut server, _control_rx, client_rx, pane) = retained_test_server_with_control(
+ b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;/wAA/w==\x1b\\\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\",
+ );
+ let client = server.clients.get_mut(&1).unwrap();
+ client.mode = ClientConnectionMode::ClientShell;
+ client.render_state =
+ crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame);
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+ let render = |server: &mut HeadlessServer| {
+ server.render_and_stream();
+ let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(surface) = message else {
+ panic!("expected client shell pane surface");
+ };
+ surface.graphics
+ };
+
+ let shown = render(&mut server);
+ assert_eq!(shown.placements.len(), 1);
+ assert_eq!(shown.assets.len(), 1);
+ let key = shown.assets[0].key.clone();
+
+ // Scrolling an image out of view removes its placement but keeps the image.
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ let hidden = render(&mut server);
+ assert!(hidden.placements.is_empty());
+ assert!(hidden.assets.is_empty());
+ assert_eq!(hidden.retained_assets, vec![key.clone()]);
+
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\");
+ let returned = render(&mut server);
+ assert_eq!(returned.placements.len(), 1);
+ assert_eq!(returned.placements[0].asset, key);
+ assert!(
+ returned.assets.is_empty(),
+ "an image the client still holds must not be sent again"
+ );
+
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ assert_eq!(render(&mut server).retained_assets, vec![key.clone()]);
+
+ // Replacing a hidden image with same-sized pixels releases the stale copy.
+ write_shared_test_pane(
+ &mut server,
+ pane,
+ b"\x1b_Ga=t,f=32,t=d,i=7,s=1,v=1,q=2;AAD//w==\x1b\\",
+ );
+ assert!(render(&mut server).retained_assets.is_empty());
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=7,c=1,r=1,q=2\x1b\\");
+ let replaced_hidden = render(&mut server);
+ assert_eq!(replaced_hidden.assets.len(), 1);
+ assert_eq!(replaced_hidden.assets[0].data, vec![0, 0, 255, 255]);
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ assert_eq!(render(&mut server).retained_assets.len(), 1);
+
+ // Deleting a hidden image releases it on the host.
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=I,i=7,q=2\x1b\\");
+ let deleted = render(&mut server);
+ assert!(deleted.placements.is_empty());
+ assert!(deleted.retained_assets.is_empty());
+
+ // Reusing the id for new pixels delivers them instead of the old image.
+ write_shared_test_pane(
+ &mut server,
+ pane,
+ b"\x1b_Ga=T,f=32,t=d,i=7,s=1,v=1,c=1,r=1,q=2;AP8A/w==\x1b\\",
+ );
+ let replaced = render(&mut server);
+ assert_eq!(replaced.assets.len(), 1);
+ assert_eq!(replaced.assets[0].data, vec![0, 255, 0, 255]);
+ assert_ne!(replaced.assets[0].key, key);
+}
+
+#[tokio::test]
+async fn client_shell_evicts_least_recently_visible_offscreen_terminal_images() {
+ let mut screen = Vec::new();
+ for image_id in 1..=17 {
+ screen.extend_from_slice(
+ format!("\x1b_Ga=T,f=32,t=d,i={image_id},s=1,v=1,c=1,r=1,q=2;/wAA/w==\x1b\\")
+ .as_bytes(),
+ );
+ }
+ let (mut server, _control_rx, client_rx, pane) = retained_test_server_with_control(&screen);
+ let client = server.clients.get_mut(&1).unwrap();
+ client.mode = ClientConnectionMode::ClientShell;
+ client.render_state =
+ crate::server::render_stream::ClientRenderState::new(RenderEncoding::SemanticFrame);
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+ let render = |server: &mut HeadlessServer| {
+ server.render_and_stream();
+ let message = read_server_message(receive_render(&client_rx, Duration::from_millis(100)));
+ let ServerMessage::PaneSurface(surface) = message else {
+ panic!("expected client shell pane surface");
+ };
+ surface.graphics
+ };
+ let retained_ids = |graphics: &crate::protocol::SurfaceGraphicsScene| {
+ graphics
+ .retained_assets
+ .iter()
+ .map(|key| match key.source {
+ crate::protocol::SurfaceGraphicsSource::Terminal { image_id, .. } => image_id,
+ _ => panic!("expected terminal image"),
+ })
+ .collect::<Vec<_>>()
+ };
+ assert_eq!(render(&mut server).assets.len(), 17);
+
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ let hidden = render(&mut server);
+ assert!(hidden.placements.is_empty());
+ assert_eq!(retained_ids(&hidden), (1..=16).collect::<Vec<_>>());
+
+ // Image 17 was evicted, so showing it sends its pixels again.
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=p,i=17,c=1,r=1,q=2\x1b\\");
+ assert_eq!(render(&mut server).assets.len(), 1);
+
+ // The most recently visible image is kept first; the oldest is evicted.
+ write_shared_test_pane(&mut server, pane, b"\x1b_Ga=d,d=a,q=2\x1b\\");
+ let rehidden = render(&mut server);
+ let mut expected = vec![17];
+ expected.extend(1..=15);
+ assert_eq!(retained_ids(&rehidden), expected);
+}
+
fn fill_render_lane(server: &HeadlessServer) {
let queued = HeadlessServer::frame_server_message(&ServerMessage::ReloadSoundConfig)
.expect("dummy frame");
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index 5f1c8a85..91713409 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -458,6 +458,10 @@ impl TerminalRuntime {
self.0.kitty_image_placements_with_data_filter(needs_data)
}
+ pub(crate) fn kitty_image_fingerprints(&self, image_ids: &[u32]) -> Vec<Option<u64>> {
+ self.0.kitty_image_fingerprints(image_ids)
+ }
+
pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol {
self.0.keyboard_protocol()
}