aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 14:14:40 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 14:15:26 +0300
commit4600309051e5d6bea1f64e72cec585b102117ce5 (patch)
tree8ca78803bbef8453bdd2d9a0de312201cd0ee60d /src
parentfb9aff54087aa8a606e9260a689986d2bd76f5fa (diff)
feat: support kitty graphics in attached clients
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs10
-rw-r--r--src/app/state.rs2
-rw-r--r--src/client/blit.rs20
-rw-r--r--src/client/mod.rs146
-rw-r--r--src/config/model.rs18
-rw-r--r--src/ghostty/mod.rs97
-rw-r--r--src/kitty_graphics.rs500
-rw-r--r--src/main.rs5
-rw-r--r--src/pane.rs21
-rw-r--r--src/pane/terminal.rs46
-rw-r--r--src/server/client_transport.rs32
-rw-r--r--src/server/headless.rs147
-rw-r--r--src/server/protocol.rs61
-rw-r--r--src/server/render_stream.rs49
-rw-r--r--src/ui.rs1
15 files changed, 975 insertions, 180 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 0719dc0c..84b8e72f 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -194,6 +194,7 @@ impl App {
event_hub: crate::api::EventHub,
) -> Self {
let (prefix_code, prefix_mods) = config.prefix_key();
+ crate::kitty_graphics::set_enabled(config.advanced.kitty_graphics);
let (event_tx, event_rx) = mpsc::channel::<AppEvent>(64);
let render_notify = Arc::new(Notify::new());
let render_dirty = Arc::new(AtomicBool::new(false));
@@ -365,6 +366,7 @@ impl App {
agent_panel_scope,
confirm_close: config.ui.confirm_close,
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
+ kitty_graphics_enabled: config.advanced.kitty_graphics,
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
accent: crate::config::parse_color(&config.ui.accent),
sound: config.ui.sound.clone(),
@@ -506,7 +508,11 @@ impl App {
crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size);
crate::ui::render(&self.state, frame);
})?;
- crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?;
+ if self.state.kitty_graphics_enabled {
+ crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?;
+ } else {
+ crate::kitty_graphics::clear_all_host_graphics()?;
+ }
self.last_render_at = Some(now);
needs_render = false;
continue;
@@ -689,6 +695,8 @@ impl App {
}
if !invalid_section("advanced") {
+ self.state.kitty_graphics_enabled = config.advanced.kitty_graphics;
+ crate::kitty_graphics::set_enabled(config.advanced.kitty_graphics);
self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
}
diff --git a/src/app/state.rs b/src/app/state.rs
index b350235a..c79fbc24 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -711,6 +711,7 @@ pub struct AppState {
pub agent_panel_scope: AgentPanelScope,
pub confirm_close: bool,
pub show_agent_labels_on_pane_borders: bool,
+ pub kitty_graphics_enabled: bool,
pub pane_scrollback_limit_bytes: usize,
#[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
pub accent: Color,
@@ -878,6 +879,7 @@ impl AppState {
agent_panel_scope: AgentPanelScope::AllWorkspaces,
confirm_close: true,
show_agent_labels_on_pane_borders: false,
+ kitty_graphics_enabled: false,
pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES,
accent: Color::Cyan,
sound: SoundConfig {
diff --git a/src/client/blit.rs b/src/client/blit.rs
index a206f97e..4a5c90c3 100644
--- a/src/client/blit.rs
+++ b/src/client/blit.rs
@@ -597,6 +597,7 @@ mod tests {
height,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
}
}
@@ -781,6 +782,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -809,6 +811,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let hidden = FrameData {
cells: vec![make_cell("B", 0, 0, 0); 9],
@@ -820,6 +823,7 @@ mod tests {
visible: false,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut last_visible_cursor = None;
let mut output = Vec::new();
@@ -974,6 +978,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -998,6 +1003,7 @@ mod tests {
visible: false,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -1018,6 +1024,7 @@ mod tests {
height: 1,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -1043,6 +1050,7 @@ mod tests {
visible: false,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -1063,6 +1071,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -1090,6 +1099,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut curr = prev.clone();
curr.cells[0] = make_cell("B", 0, 0, 0);
@@ -1127,6 +1137,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let hidden = FrameData {
cells: vec![make_cell("B", 0, 0, 0); 9],
@@ -1134,6 +1145,7 @@ mod tests {
height: 3,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut last_visible_cursor = None;
let mut output = Vec::new();
@@ -1165,6 +1177,7 @@ mod tests {
height: 2,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut last_visible_cursor = None;
let mut output = Vec::new();
@@ -1190,6 +1203,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let curr = FrameData {
cells: vec![make_cell("B", 0, 0, 0)],
@@ -1197,6 +1211,7 @@ mod tests {
height: 1,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -1220,6 +1235,7 @@ mod tests {
height: 1,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -1243,6 +1259,7 @@ mod tests {
height: 1,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let curr = FrameData {
cells: vec![
@@ -1254,6 +1271,7 @@ mod tests {
height: 1,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
@@ -1279,6 +1297,7 @@ mod tests {
height: 1,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let curr = FrameData {
cells: vec![
@@ -1290,6 +1309,7 @@ mod tests {
height: 1,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let mut output = Vec::new();
diff --git a/src/client/mod.rs b/src/client/mod.rs
index e378427f..a29a40f6 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -35,6 +35,9 @@ use crate::server::protocol::{
PROTOCOL_VERSION,
};
+const DELETE_ALL_KITTY_GRAPHICS: &[u8] = b"\x1b_Ga=d,d=A,q=2;\x1b\\";
+static RECEIVED_KITTY_GRAPHICS: AtomicBool = AtomicBool::new(false);
+
// ---------------------------------------------------------------------------
// Client state
// ---------------------------------------------------------------------------
@@ -172,6 +175,10 @@ fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<()
}
fn restore_terminal_state(in_tmux: bool) {
+ if RECEIVED_KITTY_GRAPHICS.swap(false, Ordering::AcqRel) {
+ let _ = io::stdout().write_all(DELETE_ALL_KITTY_GRAPHICS);
+ }
+
// Reset modifyOtherKeys if we enabled it.
if in_tmux {
let _ = io::stdout().write_all(b"\x1b[>4;0m");
@@ -214,6 +221,8 @@ fn do_handshake(
stream: &mut UnixStream,
cols: u16,
rows: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
requested_encoding: RenderEncoding,
) -> Result<RenderEncoding, ClientError> {
stream
@@ -225,6 +234,8 @@ fn do_handshake(
version: PROTOCOL_VERSION,
cols,
rows,
+ cell_width_px,
+ cell_height_px,
requested_encoding,
};
protocol::write_message(stream, &hello)
@@ -266,7 +277,7 @@ enum ClientLoopEvent {
/// Raw input bytes from stdin.
StdinInput(Vec<u8>),
/// Terminal resize detected.
- Resize(u16, u16),
+ Resize(u16, u16, u32, u32),
/// Server message received.
ServerMessage(ServerMessage),
/// Server reader thread exited (connection lost).
@@ -300,13 +311,20 @@ pub fn run_client() -> io::Result<()> {
}
};
- // Get the terminal size before handshake (before raw mode).
- let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
+ // Get the terminal geometry before handshake (before raw mode).
+ let (cols, rows, cell_width_px, cell_height_px) = current_terminal_geometry();
let requested_encoding = requested_render_encoding();
// Perform handshake while the stream is still in blocking mode.
- let negotiated_encoding = match do_handshake(&mut stream, cols, rows, requested_encoding) {
+ let negotiated_encoding = match do_handshake(
+ &mut stream,
+ cols,
+ rows,
+ cell_width_px,
+ cell_height_px,
+ requested_encoding,
+ ) {
Ok(encoding) => encoding,
Err(err) => {
eprintln!("herdr: {err}");
@@ -452,11 +470,13 @@ async fn run_client_loop(
return Err(ClientError::ConnectionLost(e));
}
}
- ClientLoopEvent::Resize(new_cols, new_rows) => {
+ ClientLoopEvent::Resize(new_cols, new_rows, cell_width_px, cell_height_px) => {
state.reported_size = (new_cols, new_rows);
let msg = ClientMessage::Resize {
cols: new_cols,
rows: new_rows,
+ cell_width_px,
+ cell_height_px,
};
if let Err(e) = write_to_server(&mut write_stream, &msg) {
return Err(ClientError::ConnectionLost(e));
@@ -466,15 +486,28 @@ async fn run_client_loop(
ServerMessage::Frame(frame_data) => {
let encoded = state.blit_encoder.encode(&frame_data, false);
let mut stdout = io::stdout();
- let _ = stdout.write_all(&encoded.bytes);
+ let _ = write_encoded_frame_with_graphics(
+ &mut stdout,
+ &encoded.bytes,
+ &frame_data.graphics,
+ );
let _ = stdout.flush();
state.blit_encoder.commit(frame_data, encoded);
}
ServerMessage::Terminal(frame) => {
+ if contains_kitty_graphics_bytes(&frame.bytes) {
+ RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release);
+ }
let mut stdout = io::stdout();
let _ = stdout.write_all(&frame.bytes);
let _ = stdout.flush();
}
+ ServerMessage::Graphics { bytes } => {
+ RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release);
+ let mut stdout = io::stdout();
+ let _ = stdout.write_all(&bytes);
+ let _ = stdout.flush();
+ }
ServerMessage::ServerShutdown { reason } => {
return Err(ClientError::ServerShutdown { reason });
}
@@ -661,9 +694,69 @@ fn forward_clipboard(data: &str) {
}
// ---------------------------------------------------------------------------
+// Frame output
+// ---------------------------------------------------------------------------
+
+const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l";
+
+fn write_encoded_frame_with_graphics(
+ mut writer: impl io::Write,
+ encoded: &[u8],
+ graphics: &[u8],
+) -> io::Result<()> {
+ if graphics.is_empty() {
+ return writer.write_all(encoded);
+ }
+
+ RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release);
+
+ if let Some(sync_end) = rfind_subslice(encoded, SYNC_OUTPUT_END) {
+ writer.write_all(&encoded[..sync_end])?;
+ writer.write_all(graphics)?;
+ writer.write_all(&encoded[sync_end..])?;
+ return Ok(());
+ }
+
+ writer.write_all(b"\x1b[?2026h")?;
+ writer.write_all(encoded)?;
+ writer.write_all(graphics)?;
+ writer.write_all(SYNC_OUTPUT_END)
+}
+
+fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool {
+ bytes.windows(3).any(|window| window == b"\x1b_G")
+}
+
+fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
+ if needle.is_empty() || needle.len() > haystack.len() {
+ return None;
+ }
+
+ haystack
+ .windows(needle.len())
+ .rposition(|window| window == needle)
+}
+
+// ---------------------------------------------------------------------------
// Resize polling
// ---------------------------------------------------------------------------
+fn current_terminal_geometry() -> (u16, u16, u32, u32) {
+ let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
+ let Ok(size) = crossterm::terminal::window_size() else {
+ return (cols, rows, 8, 16);
+ };
+ if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 {
+ return (cols, rows, 8, 16);
+ }
+ (
+ cols,
+ rows,
+ (size.width as u32 / size.columns as u32).max(1),
+ (size.height as u32 / size.rows as u32).max(1),
+ )
+}
+
/// Polls the terminal size and sends resize events when it changes.
fn resize_poll_loop(
resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
@@ -671,14 +764,22 @@ fn resize_poll_loop(
initial_rows: u16,
should_quit: &Arc<AtomicBool>,
) {
- let mut last_size = (initial_cols, initial_rows);
+ let (_, _, initial_cell_width, initial_cell_height) = current_terminal_geometry();
+ let mut last_size = (
+ initial_cols,
+ initial_rows,
+ initial_cell_width,
+ initial_cell_height,
+ );
while !should_quit.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(100));
- let new_size = crossterm::terminal::size().unwrap_or(last_size);
+ let new_size = current_terminal_geometry();
if new_size != last_size {
last_size = new_size;
if resize_tx
- .blocking_send(ClientLoopEvent::Resize(new_size.0, new_size.1))
+ .blocking_send(ClientLoopEvent::Resize(
+ new_size.0, new_size.1, new_size.2, new_size.3,
+ ))
.is_err()
{
break; // Main loop gone.
@@ -714,6 +815,33 @@ mod tests {
use super::*;
#[test]
+ fn graphics_bytes_are_inserted_before_blit_sync_end() {
+ let mut output = Vec::new();
+ write_encoded_frame_with_graphics(
+ &mut output,
+ b"\x1b[?2026htext\x1b[?2026lcursor",
+ b"graphics",
+ )
+ .unwrap();
+
+ assert_eq!(output, b"\x1b[?2026htextgraphics\x1b[?2026lcursor");
+ }
+
+ #[test]
+ fn graphics_bytes_fallback_wraps_unsynchronized_blit() {
+ let mut output = Vec::new();
+ write_encoded_frame_with_graphics(&mut output, b"text", b"graphics").unwrap();
+
+ assert_eq!(output, b"\x1b[?2026htextgraphics\x1b[?2026l");
+ }
+
+ #[test]
+ fn terminal_frame_kitty_detection_matches_apc_prefix() {
+ assert!(contains_kitty_graphics_bytes(b"text\x1b_Ga=p;\x1b\\"));
+ assert!(!contains_kitty_graphics_bytes(b"text\x1b[?2026h"));
+ }
+
+ #[test]
fn write_host_terminal_theme_query_emits_osc_queries() {
let mut output = Vec::new();
write_host_terminal_theme_query(&mut output).unwrap();
diff --git a/src/config/model.rs b/src/config/model.rs
index 8d2b356f..ffc117ed 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -149,6 +149,8 @@ pub struct UiConfig {
pub struct AdvancedConfig {
/// Allow launching herdr inside an existing herdr pane. Default: false.
pub allow_nested: bool,
+ /// Experimental local Kitty graphics rendering for attached clients. Default: false.
+ pub kitty_graphics: bool,
/// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000.
#[serde(alias = "scrollback_lines")]
pub scrollback_limit_bytes: usize,
@@ -237,6 +239,7 @@ impl Default for AdvancedConfig {
fn default() -> Self {
Self {
allow_nested: false,
+ kitty_graphics: false,
scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES,
}
}
@@ -332,14 +335,29 @@ delivery = "terminal"
}
#[test]
+ fn kitty_graphics_default_off_and_parse() {
+ let config = Config::default();
+ assert!(!config.advanced.kitty_graphics);
+
+ let toml = r#"
+[advanced]
+kitty_graphics = true
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert!(config.advanced.kitty_graphics);
+ }
+
+ #[test]
fn advanced_config_parses() {
let toml = r#"
[advanced]
allow_nested = true
+kitty_graphics = true
scrollback_limit_bytes = 12345
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.advanced.allow_nested);
+ assert!(config.advanced.kitty_graphics);
assert_eq!(config.advanced.scrollback_limit_bytes, 12345);
}
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index d3a67540..1652621a 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -10,8 +10,10 @@
)]
pub mod bindings;
+use std::collections::hash_map::DefaultHasher;
use std::ffi::c_void;
use std::fmt;
+use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
use std::mem;
use std::os::raw::c_char;
@@ -149,7 +151,7 @@ const APC_MAX_BYTES_KITTY: usize = 16 * 1024 * 1024;
static INSTALL_PNG_DECODER: Once = Once::new();
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
pub enum KittyImageFormat {
Rgb,
Rgba,
@@ -166,11 +168,24 @@ pub struct KittyImagePlacement {
pub image_width: u32,
pub image_height: u32,
pub format: KittyImageFormat,
+ pub data_len: usize,
+ pub data_fingerprint: u64,
pub data: Vec<u8>,
pub render: KittyPlacementRenderInfo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct KittyImageDescriptor {
+ pub image_id: u32,
+ pub placement_id: u32,
+ pub image_width: u32,
+ pub image_height: u32,
+ pub format: KittyImageFormat,
+ pub data_len: usize,
+ pub data_fingerprint: u64,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KittyPlacementRenderInfo {
pub pixel_width: u32,
pub pixel_height: u32,
@@ -837,6 +852,16 @@ impl Terminal {
}
pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> {
+ self.kitty_image_placements_with_data_filter(|_| true)
+ }
+
+ pub fn kitty_image_placements_with_data_filter<F>(
+ &self,
+ mut needs_data: F,
+ ) -> Result<Vec<KittyImagePlacement>, Error>
+ where
+ F: FnMut(KittyImageDescriptor) -> bool,
+ {
let mut graphics: ffi::GhosttyKittyGraphics = ptr::null_mut();
unsafe {
ffi::ghostty_terminal_get(
@@ -865,7 +890,9 @@ impl Terminal {
let mut placements = Vec::new();
while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } {
- if let Some(placement) = self.kitty_image_placement(graphics, iterator)? {
+ if let Some(placement) =
+ self.kitty_image_placement(graphics, iterator, &mut needs_data)?
+ {
placements.push(placement);
}
}
@@ -873,11 +900,15 @@ impl Terminal {
Ok(placements)
}
- fn kitty_image_placement(
+ fn kitty_image_placement<F>(
&self,
graphics: ffi::GhosttyKittyGraphics,
iterator: ffi::GhosttyKittyGraphicsPlacementIterator,
- ) -> Result<Option<KittyImagePlacement>, Error> {
+ needs_data: &mut F,
+ ) -> Result<Option<KittyImagePlacement>, Error>
+ where
+ F: FnMut(KittyImageDescriptor) -> bool,
+ {
let image_id = kitty_placement_u32(
iterator,
ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID,
@@ -917,11 +948,27 @@ impl Terminal {
if compression != ffi::GhosttyKittyImageCompression_GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE {
return Ok(None);
}
- let data = kitty_image_data(image)?;
let placement_id = kitty_placement_u32(
iterator,
ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID,
)?;
+ let (data_ptr, data_len) = kitty_image_data_ptr_len(image)?;
+ let data_fingerprint =
+ kitty_image_fingerprint(data_ptr, data_len, image_width, image_height, format);
+ let descriptor = KittyImageDescriptor {
+ image_id,
+ placement_id,
+ image_width,
+ image_height,
+ format,
+ data_len,
+ data_fingerprint,
+ };
+ let data = if needs_data(descriptor) {
+ kitty_image_data_from_ptr(data_ptr, data_len)
+ } else {
+ Vec::new()
+ };
let x_offset = kitty_placement_u32(
iterator,
ffi::GhosttyKittyGraphicsPlacementData_GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET,
@@ -944,6 +991,8 @@ impl Terminal {
image_width,
image_height,
format,
+ data_len,
+ data_fingerprint,
data,
render: KittyPlacementRenderInfo {
pixel_width: raw_info.pixel_width,
@@ -1093,7 +1142,9 @@ fn kitty_image_compression(
Ok(out)
}
-fn kitty_image_data(image: ffi::GhosttyKittyGraphicsImage) -> Result<Vec<u8>, Error> {
+fn kitty_image_data_ptr_len(
+ image: ffi::GhosttyKittyGraphicsImage,
+) -> Result<(*const u8, usize), Error> {
let mut ptr_out: *const u8 = ptr::null();
let mut len = 0usize;
unsafe {
@@ -1110,10 +1161,40 @@ fn kitty_image_data(image: ffi::GhosttyKittyGraphicsImage) -> Result<Vec<u8>, Er
)
.into_result()?;
}
+ Ok((ptr_out, len))
+}
+
+fn kitty_image_data_from_ptr(ptr_out: *const u8, len: usize) -> Vec<u8> {
+ if ptr_out.is_null() || len == 0 {
+ return Vec::new();
+ }
+ unsafe { slice::from_raw_parts(ptr_out, len) }.to_vec()
+}
+
+fn kitty_image_fingerprint(
+ ptr_out: *const u8,
+ len: usize,
+ image_width: u32,
+ image_height: u32,
+ format: KittyImageFormat,
+) -> u64 {
+ let mut hasher = DefaultHasher::new();
+ len.hash(&mut hasher);
+ image_width.hash(&mut hasher);
+ image_height.hash(&mut hasher);
+ format.hash(&mut hasher);
if ptr_out.is_null() || len == 0 {
- return Ok(Vec::new());
+ return hasher.finish();
+ }
+
+ let data = unsafe { slice::from_raw_parts(ptr_out, len) };
+ let prefix_len = data.len().min(256);
+ data[..prefix_len].hash(&mut hasher);
+ if data.len() > prefix_len {
+ let suffix_len = data.len().min(256);
+ data[data.len() - suffix_len..].hash(&mut hasher);
}
- Ok(unsafe { slice::from_raw_parts(ptr_out, len) }.to_vec())
+ hasher.finish()
}
fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result<Option<String>, Error> {
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs
index 3dc96e67..43cbacbd 100644
--- a/src/kitty_graphics.rs
+++ b/src/kitty_graphics.rs
@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Write as FmtWrite;
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use base64::Engine;
@@ -10,11 +11,12 @@ use ratatui::layout::Rect;
use crate::app::state::AppState;
use crate::app::Mode;
-use crate::ghostty::{KittyImageFormat, KittyImagePlacement};
+use crate::ghostty::{KittyImageDescriptor, KittyImageFormat, KittyImagePlacement};
use crate::layout::PaneId;
const KITTY_CHUNK_BYTES: usize = 3072;
const HOST_IMAGE_ID_BASE: u32 = 10_000;
+const STALE_PLACEMENT_GRACE_FRAMES: u8 = 1;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct HostCellSize {
@@ -60,6 +62,12 @@ impl HostCellSize {
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct HostViewKey {
+ workspace_index: usize,
+ tab_index: usize,
+}
+
#[derive(Debug)]
struct HostPlacement {
pane_id: PaneId,
@@ -75,7 +83,13 @@ struct ImageSignature {
image_height: u32,
format_code: u32,
data_len: usize,
- data_hash: u64,
+ data_fingerprint: u64,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct HostPlacementState {
+ signature: PlacementSignature,
+ missed_frames: u8,
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
@@ -106,10 +120,49 @@ struct ClippedPlacement {
y_offset: u32,
}
-static HOST_IMAGES: OnceLock<Mutex<HashMap<u32, ImageSignature>>> = OnceLock::new();
-static HOST_PLACEMENTS: OnceLock<Mutex<HashMap<(u32, u32), PlacementSignature>>> = OnceLock::new();
+#[derive(Debug, Default, Clone)]
+pub(crate) struct HostGraphicsCache {
+ images: HashMap<u32, ImageSignature>,
+ placements: HashMap<(u32, u32), HostPlacementState>,
+ view: Option<HostViewKey>,
+}
+
+static KITTY_GRAPHICS_ENABLED: AtomicBool = AtomicBool::new(false);
+static LOCAL_HOST_GRAPHICS: OnceLock<Mutex<HostGraphicsCache>> = OnceLock::new();
+
+pub(crate) fn set_enabled(enabled: bool) {
+ KITTY_GRAPHICS_ENABLED.store(enabled, Ordering::Release);
+}
+
+pub(crate) fn is_enabled() -> bool {
+ KITTY_GRAPHICS_ENABLED.load(Ordering::Acquire)
+}
pub(crate) fn paint_local_pane_graphics(app: &AppState, cell_size: HostCellSize) -> io::Result<()> {
+ let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default()));
+ let mut bytes = Vec::new();
+ if let Ok(mut cache) = cache.lock() {
+ bytes = encode_local_pane_graphics(app, cell_size, &mut cache);
+ }
+ if bytes.is_empty() {
+ return Ok(());
+ }
+
+ let mut framed = Vec::with_capacity(bytes.len() + 8);
+ framed.extend_from_slice(b"\x1b7");
+ framed.extend_from_slice(&bytes);
+ framed.extend_from_slice(b"\x1b8");
+
+ let mut stdout = io::stdout().lock();
+ stdout.write_all(&framed)?;
+ stdout.flush()
+}
+
+pub(crate) fn encode_local_pane_graphics(
+ app: &AppState,
+ cell_size: HostCellSize,
+ cache: &mut HostGraphicsCache,
+) -> Vec<u8> {
let mode_ok = app.mode == Mode::Terminal;
let cell_ok = cell_size.is_known();
tracing::debug!(
@@ -130,139 +183,188 @@ pub(crate) fn paint_local_pane_graphics(app: &AppState, cell_size: HostCellSize)
},
"paint_local_pane_graphics early return"
);
- return clear_all_host_images();
+ return cache.clear_bytes();
}
- let placements = collect_visible_placements(app, cell_size);
+ let view_key = active_view_key(app);
+ let uploaded_images = cache.images.clone();
+ let placements = collect_visible_placements(app, cell_size, &uploaded_images);
tracing::debug!(
placements_collected = placements.len(),
"collect_visible_placements result"
);
+
let mut bytes = Vec::new();
- let mut current_placements = HashSet::new();
+ let view_changed = cache.update_view(view_key);
+ encode_graphics_update(
+ &mut bytes,
+ &placements,
+ view_changed,
+ &mut cache.images,
+ &mut cache.placements,
+ );
+ tracing::debug!(
+ placements = placements.len(),
+ bytes = bytes.len(),
+ cell_width_px = cell_size.width_px,
+ cell_height_px = cell_size.height_px,
+ "painting kitty graphics placements"
+ );
+ bytes
+}
- let image_state = HOST_IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
- let placement_state = HOST_PLACEMENTS.get_or_init(|| Mutex::new(HashMap::new()));
- if let (Ok(mut host_images), Ok(mut host_placements)) =
- (image_state.lock(), placement_state.lock())
- {
- for placement in &placements {
- let clipped = clipped_placement(placement);
- tracing::debug!(
- pane_id = ?placement.pane_id,
- has_clipped = clipped.is_some(),
- grid_cols = placement.placement.render.grid_cols,
- grid_rows = placement.placement.render.grid_rows,
- viewport_col = placement.placement.render.viewport_col,
- viewport_row = placement.placement.render.viewport_row,
- area_w = placement.area.width,
- area_h = placement.area.height,
- "clipped_placement result"
- );
- let Some((clipped, format_code)) = clipped else {
- continue;
- };
- let host_id = host_image_id(placement.pane_id, &placement.placement);
- let host_placement_id = host_placement_id(placement.pane_id, &placement.placement);
- let image_signature = image_signature(placement, format_code);
- let placement_signature = placement_signature(clipped);
- let placement_key = (host_id, host_placement_id);
- current_placements.insert(placement_key);
-
- match host_images.get(&host_id).copied() {
- Some(existing) if existing == image_signature => {}
- Some(_) => {
- encode_delete_image(&mut bytes, host_id);
- host_placements.retain(|(image_id, _), _| *image_id != host_id);
- encode_upload_image(&mut bytes, placement, format_code, host_id);
- host_images.insert(host_id, image_signature);
- }
- None => {
- encode_upload_image(&mut bytes, placement, format_code, host_id);
- host_images.insert(host_id, image_signature);
- }
+fn encode_graphics_update(
+ bytes: &mut Vec<u8>,
+ placements: &[HostPlacement],
+ view_changed: bool,
+ host_images: &mut HashMap<u32, ImageSignature>,
+ host_placements: &mut HashMap<(u32, u32), HostPlacementState>,
+) {
+ let mut current_placements = HashSet::new();
+ for placement in placements {
+ let clipped = clipped_placement(placement);
+ tracing::debug!(
+ pane_id = ?placement.pane_id,
+ has_clipped = clipped.is_some(),
+ grid_cols = placement.placement.render.grid_cols,
+ grid_rows = placement.placement.render.grid_rows,
+ viewport_col = placement.placement.render.viewport_col,
+ viewport_row = placement.placement.render.viewport_row,
+ area_w = placement.area.width,
+ area_h = placement.area.height,
+ "clipped_placement result"
+ );
+ let Some((clipped, format_code)) = clipped else {
+ continue;
+ };
+ let host_id = host_image_id(placement.pane_id, &placement.placement);
+ let host_placement_id = host_placement_id(placement.pane_id, &placement.placement);
+ let image_signature = image_signature(placement, format_code);
+ let placement_signature = placement_signature(clipped);
+ let placement_key = (host_id, host_placement_id);
+ current_placements.insert(placement_key);
+
+ match host_images.get(&host_id).copied() {
+ Some(existing) if existing == image_signature => {}
+ Some(_) => {
+ encode_delete_image(bytes, host_id);
+ host_placements.retain(|(image_id, placement_id), _| {
+ if *image_id == host_id {
+ current_placements.remove(&(*image_id, *placement_id));
+ false
+ } else {
+ true
+ }
+ });
+ encode_upload_image(bytes, placement, format_code, host_id);
+ host_images.insert(host_id, image_signature);
}
+ None => {
+ encode_upload_image(bytes, placement, format_code, host_id);
+ host_images.insert(host_id, image_signature);
+ }
+ }
- match host_placements.get_mut(&placement_key) {
- Some(existing) if *existing == placement_signature => {}
- Some(existing) => {
- encode_display_placement(
- &mut bytes,
- clipped,
- host_id,
- host_placement_id,
- placement.placement.z,
- );
- *existing = placement_signature;
- }
- None => {
- encode_display_placement(
- &mut bytes,
- clipped,
- host_id,
- host_placement_id,
- placement.placement.z,
- );
- host_placements.insert(placement_key, placement_signature);
- }
+ match host_placements.get_mut(&placement_key) {
+ Some(existing) if existing.signature == placement_signature => {
+ existing.missed_frames = 0;
+ }
+ Some(existing) => {
+ encode_display_placement(
+ bytes,
+ clipped,
+ host_id,
+ host_placement_id,
+ placement.placement.z,
+ );
+ existing.signature = placement_signature;
+ existing.missed_frames = 0;
+ }
+ None => {
+ encode_display_placement(
+ bytes,
+ clipped,
+ host_id,
+ host_placement_id,
+ placement.placement.z,
+ );
+ host_placements.insert(
+ placement_key,
+ HostPlacementState {
+ signature: placement_signature,
+ missed_frames: 0,
+ },
+ );
}
}
+ }
- let stale_placements: Vec<(u32, u32)> = host_placements
- .keys()
- .copied()
- .filter(|key| !current_placements.contains(key))
- .collect();
- for (host_id, host_placement_id) in stale_placements {
- encode_delete_placement(&mut bytes, host_id, host_placement_id);
- host_placements.remove(&(host_id, host_placement_id));
+ let mut stale_placements = Vec::new();
+ for (key, state) in &mut *host_placements {
+ if current_placements.contains(key) {
+ continue;
+ }
+ if !view_changed && state.missed_frames < STALE_PLACEMENT_GRACE_FRAMES {
+ state.missed_frames += 1;
+ continue;
}
+ stale_placements.push(*key);
}
+ for (host_id, host_placement_id) in stale_placements {
+ encode_delete_placement(bytes, host_id, host_placement_id);
+ host_placements.remove(&(host_id, host_placement_id));
+ }
+}
+pub(crate) fn clear_all_host_graphics() -> io::Result<()> {
+ let cache = LOCAL_HOST_GRAPHICS.get_or_init(|| Mutex::new(HostGraphicsCache::default()));
+ let mut bytes = Vec::new();
+ if let Ok(mut cache) = cache.lock() {
+ bytes = cache.clear_bytes();
+ }
if bytes.is_empty() {
return Ok(());
}
-
- tracing::debug!(
- placements = placements.len(),
- bytes = bytes.len(),
- cell_width_px = cell_size.width_px,
- cell_height_px = cell_size.height_px,
- "painting kitty graphics placements"
- );
-
- let mut framed = Vec::with_capacity(bytes.len() + 8);
- framed.extend_from_slice(b"\x1b7");
- framed.extend_from_slice(&bytes);
- framed.extend_from_slice(b"\x1b8");
-
let mut stdout = io::stdout().lock();
- stdout.write_all(&framed)?;
+ stdout.write_all(&bytes)?;
stdout.flush()
}
-fn clear_all_host_images() -> io::Result<()> {
- let image_state = HOST_IMAGES.get_or_init(|| Mutex::new(HashMap::new()));
- let placement_state = HOST_PLACEMENTS.get_or_init(|| Mutex::new(HashMap::new()));
- let mut bytes = Vec::new();
- if let (Ok(mut host_images), Ok(mut host_placements)) =
- (image_state.lock(), placement_state.lock())
- {
- for id in host_images.keys().copied().collect::<Vec<_>>() {
+impl HostGraphicsCache {
+ pub(crate) fn clear_bytes(&mut self) -> Vec<u8> {
+ let mut bytes = Vec::new();
+ for id in self.images.keys().copied().collect::<Vec<_>>() {
encode_delete_image(&mut bytes, id);
}
- host_images.clear();
- host_placements.clear();
+ self.images.clear();
+ self.placements.clear();
+ self.view = None;
+ bytes
}
- if bytes.is_empty() {
- return Ok(());
+
+ fn update_view(&mut self, view_key: Option<HostViewKey>) -> bool {
+ if self.view == view_key {
+ return false;
+ }
+ self.view = view_key;
+ true
}
- let mut stdout = io::stdout().lock();
- stdout.write_all(&bytes)?;
- stdout.flush()
}
-fn collect_visible_placements(app: &AppState, cell_size: HostCellSize) -> Vec<HostPlacement> {
+fn active_view_key(app: &AppState) -> Option<HostViewKey> {
+ let ws_idx = app.active?;
+ let ws = app.workspaces.get(ws_idx)?;
+ Some(HostViewKey {
+ workspace_index: ws_idx,
+ tab_index: ws.active_tab_index(),
+ })
+}
+
+fn collect_visible_placements(
+ app: &AppState,
+ cell_size: HostCellSize,
+ uploaded_images: &HashMap<u32, ImageSignature>,
+) -> Vec<HostPlacement> {
let ws_idx = match app.active {
Some(idx) => idx,
None => {
@@ -297,7 +399,12 @@ fn collect_visible_placements(app: &AppState, cell_size: HostCellSize) -> Vec<Ho
continue;
}
};
- for placement in runtime.kitty_image_placements() {
+ for placement in runtime.kitty_image_placements_with_data_filter(|descriptor| {
+ let host_id = host_image_id_raw(info.id, descriptor.image_id);
+ let format_code = kitty_format_code(descriptor.format);
+ uploaded_images.get(&host_id).copied()
+ != Some(image_signature_from_descriptor(descriptor, format_code))
+ }) {
let scrollback_offset = runtime
.scroll_metrics()
.map(|m| m.offset_from_bottom as u32)
@@ -319,9 +426,13 @@ fn collect_visible_placements(app: &AppState, cell_size: HostCellSize) -> Vec<Ho
}
fn host_image_id(pane_id: PaneId, placement: &KittyImagePlacement) -> u32 {
+ host_image_id_raw(pane_id, placement.image_id)
+}
+
+fn host_image_id_raw(pane_id: PaneId, image_id: u32) -> u32 {
let mut hasher = DefaultHasher::new();
pane_id.raw().hash(&mut hasher);
- placement.image_id.hash(&mut hasher);
+ image_id.hash(&mut hasher);
HOST_IMAGE_ID_BASE + ((hasher.finish() as u32) % 900_000)
}
@@ -544,14 +655,25 @@ fn scale_pixels(value: u32, source: u32, dest: u32) -> u32 {
}
fn image_signature(placement: &HostPlacement, format_code: u32) -> ImageSignature {
- let mut hasher = DefaultHasher::new();
- placement.placement.data.hash(&mut hasher);
ImageSignature {
image_width: placement.placement.image_width,
image_height: placement.placement.image_height,
format_code,
- data_len: placement.placement.data.len(),
- data_hash: hasher.finish(),
+ data_len: placement.placement.data_len,
+ data_fingerprint: placement.placement.data_fingerprint,
+ }
+}
+
+fn image_signature_from_descriptor(
+ descriptor: KittyImageDescriptor,
+ format_code: u32,
+) -> ImageSignature {
+ ImageSignature {
+ image_width: descriptor.image_width,
+ image_height: descriptor.image_height,
+ format_code,
+ data_len: descriptor.data_len,
+ data_fingerprint: descriptor.data_fingerprint,
}
}
@@ -593,3 +715,153 @@ fn encode_kitty_data(out: &mut Vec<u8>, control: &str, data: &[u8]) {
let _ = write!(out, "\x1b_Gm={more};{encoded}\x1b\\");
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ghostty::KittyPlacementRenderInfo;
+
+ fn test_placement(viewport_col: i32, viewport_row: i32) -> HostPlacement {
+ HostPlacement {
+ pane_id: PaneId::from_raw(1),
+ area: Rect::new(0, 0, 20, 10),
+ cell_size: HostCellSize {
+ width_px: 10,
+ height_px: 10,
+ },
+ scrollback_offset: 0,
+ placement: KittyImagePlacement {
+ image_id: 7,
+ placement_id: 3,
+ z: 0,
+ x_offset: 0,
+ y_offset: 0,
+ image_width: 30,
+ image_height: 30,
+ format: KittyImageFormat::Rgba,
+ data_len: 30 * 30 * 4,
+ data_fingerprint: 42,
+ data: vec![255; 30 * 30 * 4],
+ render: KittyPlacementRenderInfo {
+ pixel_width: 0,
+ pixel_height: 0,
+ grid_cols: 3,
+ grid_rows: 3,
+ viewport_col,
+ viewport_row,
+ source_x: 0,
+ source_y: 0,
+ source_width: 0,
+ source_height: 0,
+ },
+ },
+ }
+ }
+
+ #[test]
+ fn clipped_placement_handles_positive_viewport_without_wrapping() {
+ let placement = test_placement(2, 2);
+ let (clipped, _) = clipped_placement(&placement).expect("visible placement");
+
+ assert_eq!(clipped.x, 2);
+ assert_eq!(clipped.y, 2);
+ assert_eq!(clipped.cols, 3);
+ assert_eq!(clipped.rows, 3);
+ assert_eq!(clipped.source_x, 0);
+ assert_eq!(clipped.source_y, 0);
+ }
+
+ #[test]
+ fn clipped_placement_crops_negative_viewport_offsets() {
+ let placement = test_placement(-1, -1);
+ let (clipped, _) = clipped_placement(&placement).expect("partially visible placement");
+
+ assert_eq!(clipped.x, 0);
+ assert_eq!(clipped.y, 0);
+ assert_eq!(clipped.cols, 2);
+ assert_eq!(clipped.rows, 2);
+ assert_eq!(clipped.source_x, 10);
+ assert_eq!(clipped.source_y, 10);
+ }
+
+ #[test]
+ fn graphics_update_uploads_once_then_repositions_only() {
+ let mut images = HashMap::new();
+ let mut placements = HashMap::new();
+ let mut bytes = Vec::new();
+ let placement = test_placement(0, 0);
+
+ encode_graphics_update(
+ &mut bytes,
+ &[placement],
+ false,
+ &mut images,
+ &mut placements,
+ );
+ let first = String::from_utf8_lossy(&bytes);
+ assert!(first.contains("a=t"));
+ assert!(first.contains("a=p"));
+
+ bytes.clear();
+ let same = test_placement(0, 0);
+ encode_graphics_update(&mut bytes, &[same], false, &mut images, &mut placements);
+ assert!(bytes.is_empty());
+
+ let moved = test_placement(0, 1);
+ encode_graphics_update(&mut bytes, &[moved], false, &mut images, &mut placements);
+ let moved_bytes = String::from_utf8_lossy(&bytes);
+ assert!(!moved_bytes.contains("a=t"));
+ assert!(moved_bytes.contains("a=p"));
+ }
+
+ #[test]
+ fn stale_placement_grace_deletes_placement_not_image() {
+ let mut images = HashMap::new();
+ let mut placements = HashMap::new();
+ let mut bytes = Vec::new();
+ let placement = test_placement(0, 0);
+
+ encode_graphics_update(
+ &mut bytes,
+ &[placement],
+ false,
+ &mut images,
+ &mut placements,
+ );
+ assert_eq!(placements.len(), 1);
+
+ bytes.clear();
+ encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements);
+ assert!(bytes.is_empty());
+ assert_eq!(placements.len(), 1);
+
+ encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements);
+ let delete = String::from_utf8_lossy(&bytes);
+ assert!(delete.contains("a=d,d=i"));
+ assert!(!delete.contains("d=I"));
+ assert!(placements.is_empty());
+ assert_eq!(images.len(), 1);
+ }
+
+ #[test]
+ fn view_change_deletes_stale_placement_immediately() {
+ let mut images = HashMap::new();
+ let mut placements = HashMap::new();
+ let mut bytes = Vec::new();
+ let placement = test_placement(0, 0);
+
+ encode_graphics_update(
+ &mut bytes,
+ &[placement],
+ false,
+ &mut images,
+ &mut placements,
+ );
+ bytes.clear();
+ encode_graphics_update(&mut bytes, &[], true, &mut images, &mut placements);
+
+ let delete = String::from_utf8_lossy(&bytes);
+ assert!(delete.contains("a=d,d=i"));
+ assert!(placements.is_empty());
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 18e6138a..d2dfedd9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -156,6 +156,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
[advanced]
# Allow launching herdr from inside a herdr-managed pane.
# allow_nested = false
+# Experimental local Kitty graphics rendering for attached clients.
+# Requires a Kitty graphics-compatible outer terminal. Detach/headless replay is not supported yet.
+# kitty_graphics = false
# Maximum scrollback buffer size in bytes retained per pane terminal.
# Matches Ghostty's default scrollback-limit behavior.
# scrollback_limit_bytes = 10000000
@@ -424,6 +427,7 @@ fn main() -> io::Result<()> {
if panic_in_tmux {
let _ = std::io::Write::write_all(&mut io::stdout(), b"\x1b[>4;0m");
}
+ let _ = crate::kitty_graphics::clear_all_host_graphics();
let _ = execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
@@ -486,6 +490,7 @@ fn main() -> io::Result<()> {
std::io::stdout().flush()?;
}
+ crate::kitty_graphics::clear_all_host_graphics()?;
execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
diff --git a/src/pane.rs b/src/pane.rs
index e9a35357..8a89fa54 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -437,6 +437,16 @@ impl PaneRuntime {
if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
}
+ if let Some(delay) = result.render_delay {
+ let render_notify = render_notify.clone();
+ let render_dirty = render_dirty.clone();
+ rt.spawn(async move {
+ tokio::time::sleep(delay).await;
+ if !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
+ }
+ });
+ }
for content in result.clipboard_writes {
if let Err(err) =
rt.block_on(events.send(AppEvent::ClipboardWrite { content }))
@@ -817,8 +827,15 @@ impl PaneRuntime {
self.terminal.visible_hyperlinks(area)
}
- pub fn kitty_image_placements(&self) -> Vec<crate::ghostty::KittyImagePlacement> {
- self.terminal.kitty_image_placements()
+ pub fn kitty_image_placements_with_data_filter<F>(
+ &self,
+ needs_data: F,
+ ) -> Vec<crate::ghostty::KittyImagePlacement>
+ where
+ F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool,
+ {
+ self.terminal
+ .kitty_image_placements_with_data_filter(needs_data)
}
pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol {
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index f45d9df7..33cd8ed5 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -1,11 +1,12 @@
use std::borrow::Cow;
use std::sync::Mutex;
+use std::time::Duration;
use bytes::Bytes;
use ratatui::style::{Color, Modifier, Style};
use ratatui::{layout::Rect, Frame};
use tokio::sync::mpsc;
-use tracing::{debug, error, info, warn};
+use tracing::{debug, error, warn};
use unicode_width::UnicodeWidthStr;
use crate::layout::PaneId;
@@ -24,6 +25,7 @@ use super::{
};
const DEFAULT_DETECTION_ROWS: usize = 24;
+const KITTY_GRAPHICS_REDRAW_SETTLE: Duration = Duration::from_millis(20);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollMetrics {
@@ -59,6 +61,7 @@ impl InputState {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ProcessBytesResult {
pub request_render: bool,
+ pub render_delay: Option<Duration>,
pub clipboard_writes: Vec<Vec<u8>>,
}
@@ -170,8 +173,15 @@ impl PaneTerminal {
self.ghostty.visible_hyperlinks(area)
}
- pub fn kitty_image_placements(&self) -> Vec<crate::ghostty::KittyImagePlacement> {
- self.ghostty.kitty_image_placements()
+ pub fn kitty_image_placements_with_data_filter<F>(
+ &self,
+ needs_data: F,
+ ) -> Vec<crate::ghostty::KittyImagePlacement>
+ where
+ F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool,
+ {
+ self.ghostty
+ .kitty_image_placements_with_data_filter(needs_data)
}
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
@@ -315,6 +325,7 @@ impl GhosttyPaneTerminal {
error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader");
return ProcessBytesResult {
request_render: false,
+ render_delay: None,
clipboard_writes: Vec::new(),
};
};
@@ -358,9 +369,14 @@ impl GhosttyPaneTerminal {
}
core.terminal.write(filtered_bytes.as_ref());
- if contains_kitty_graphics_sequence(filtered_bytes.as_ref()) {
- match core.terminal.kitty_image_placements() {
- Ok(placements) => info!(
+ let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled()
+ && contains_kitty_graphics_sequence(filtered_bytes.as_ref());
+ if has_kitty_graphics_sequence {
+ match core
+ .terminal
+ .kitty_image_placements_with_data_filter(|_| false)
+ {
+ Ok(placements) => debug!(
pane = pane_id.raw(),
placements = placements.len(),
"processed kitty graphics sequence"
@@ -381,7 +397,9 @@ impl GhosttyPaneTerminal {
.unwrap_or(false);
let _ = response_writer;
ProcessBytesResult {
- request_render: !synchronized_output,
+ request_render: !synchronized_output && !has_kitty_graphics_sequence,
+ render_delay: (!synchronized_output && has_kitty_graphics_sequence)
+ .then_some(KITTY_GRAPHICS_REDRAW_SETTLE),
clipboard_writes,
}
}
@@ -642,11 +660,21 @@ impl GhosttyPaneTerminal {
.unwrap_or_default()
}
- pub fn kitty_image_placements(&self) -> Vec<crate::ghostty::KittyImagePlacement> {
+ pub fn kitty_image_placements_with_data_filter<F>(
+ &self,
+ needs_data: F,
+ ) -> Vec<crate::ghostty::KittyImagePlacement>
+ where
+ F: FnMut(crate::ghostty::KittyImageDescriptor) -> bool,
+ {
self.core
.lock()
.ok()
- .and_then(|core| core.terminal.kitty_image_placements().ok())
+ .and_then(|core| {
+ core.terminal
+ .kitty_image_placements_with_data_filter(needs_data)
+ .ok()
+ })
.unwrap_or_default()
}
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index c4c1acc0..a74bf7f6 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -51,6 +51,8 @@ pub(crate) enum ServerEvent {
client_id: u64,
cols: u16,
rows: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
render_encoding: RenderEncoding,
writer: ClientWriter,
},
@@ -61,6 +63,8 @@ pub(crate) enum ServerEvent {
client_id: u64,
cols: u16,
rows: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
},
/// A client detached gracefully.
ClientDetach { client_id: u64 },
@@ -113,11 +117,13 @@ pub(crate) fn handle_client_handshake(
}
};
- let (client_cols, client_rows, render_encoding) = match hello {
+ let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding) = match hello {
ClientMessage::Hello {
version,
cols,
rows,
+ cell_width_px,
+ cell_height_px,
requested_encoding,
} => {
// Version check.
@@ -137,7 +143,13 @@ pub(crate) fn handle_client_handshake(
// Clamp size.
let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
- (clamped_cols, clamped_rows, requested_encoding)
+ (
+ clamped_cols,
+ clamped_rows,
+ cell_width_px.max(1),
+ cell_height_px.max(1),
+ requested_encoding,
+ )
}
_ => {
// First message must be Hello.
@@ -176,6 +188,8 @@ pub(crate) fn handle_client_handshake(
client_id,
cols: client_cols,
rows: client_rows,
+ cell_width_px,
+ cell_height_px,
render_encoding,
writer,
});
@@ -324,12 +338,19 @@ fn client_read_loop(
ServerEvent::ClientInput { client_id, data }
}
}
- ClientMessage::Resize { cols, rows } => {
+ ClientMessage::Resize {
+ cols,
+ rows,
+ cell_width_px,
+ cell_height_px,
+ } => {
let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
ServerEvent::ClientResize {
client_id,
cols: clamped_cols,
rows: clamped_rows,
+ cell_width_px: cell_width_px.max(1),
+ cell_height_px: cell_height_px.max(1),
}
}
ClientMessage::Detach => ServerEvent::ClientDetach { client_id },
@@ -399,6 +420,8 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 100,
rows: 30,
+ cell_width_px: 8,
+ cell_height_px: 16,
requested_encoding: RenderEncoding::TerminalAnsi,
},
)
@@ -427,11 +450,14 @@ mod tests {
client_id,
cols,
rows,
+ cell_width_px,
+ cell_height_px,
render_encoding,
writer,
} => {
assert_eq!(client_id, 42);
assert_eq!((cols, rows), (100, 30));
+ assert_eq!((cell_width_px, cell_height_px), (8, 16));
assert_eq!(render_encoding, RenderEncoding::TerminalAnsi);
drop(writer);
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index d9227404..3e978d5a 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -191,6 +191,8 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
struct ClientConnection {
/// The client's terminal size (after clamping).
terminal_size: (u16, u16),
+ /// Pixel size of one client terminal cell.
+ cell_size: crate::kitty_graphics::HostCellSize,
/// Last known host terminal default colors for this client.
host_terminal_theme: crate::terminal_theme::TerminalTheme,
/// Last reported focus state for this client's outer terminal.
@@ -199,6 +201,8 @@ struct ClientConnection {
last_activity: u64,
/// Render baseline for the negotiated client encoding.
render_state: ClientRenderState,
+ /// Client-local host Kitty graphics cache.
+ graphics_cache: crate::kitty_graphics::HostGraphicsCache,
/// Whether a render was skipped because the render channel was full.
render_pending: bool,
/// Channels for sending framed ServerMessage data to the client writer thread.
@@ -208,6 +212,7 @@ struct ClientConnection {
impl ClientConnection {
fn new(
terminal_size: (u16, u16),
+ cell_size: crate::kitty_graphics::HostCellSize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
outer_terminal_focus: Option<bool>,
last_activity: u64,
@@ -216,10 +221,12 @@ impl ClientConnection {
) -> Self {
Self {
terminal_size,
+ cell_size,
host_terminal_theme,
outer_terminal_focus,
last_activity,
render_state: ClientRenderState::new(render_encoding),
+ graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(),
render_pending: false,
writer,
}
@@ -490,9 +497,15 @@ impl HeadlessServer {
if self.foreground_client_id.is_none() {
return;
}
+ let Some(client_id) = self.foreground_client_id else {
+ return;
+ };
+ let Some(client) = self.clients.get(&client_id) else {
+ return;
+ };
let (cols, rows) = self.effective_size;
let area = Rect::new(0, 0, cols, rows);
- crate::ui::compute_view(&mut self.app.state, area);
+ crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size);
// Shared runtime size changes affect pane wrapping and foreground-driven
// rendering semantics. Force one fresh frame to every remaining client
@@ -564,6 +577,7 @@ impl HeadlessServer {
fn remove_client(&mut self, client_id: u64) -> bool {
let was_foreground = self.foreground_client_id == Some(client_id);
+ self.send_client_graphics_cleanup(client_id);
self.clients.remove(&client_id);
if was_foreground {
self.promote_latest_remaining_client()
@@ -572,6 +586,33 @@ impl HeadlessServer {
}
}
+ fn send_client_graphics_cleanup(&mut self, client_id: u64) {
+ let (writer, bytes) = match self.clients.get_mut(&client_id) {
+ Some(client) => {
+ let bytes = client.graphics_cache.clear_bytes();
+ (client.writer.as_ref().cloned(), bytes)
+ }
+ None => return,
+ };
+ if bytes.is_empty() {
+ return;
+ }
+ let Some(writer) = writer else {
+ return;
+ };
+ let Ok(serialized) = Self::frame_server_message(&ServerMessage::Graphics { bytes }) else {
+ return;
+ };
+ let _ = writer.control.send(serialized);
+ }
+
+ fn send_all_clients_graphics_cleanup(&mut self) {
+ let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
+ for client_id in client_ids {
+ self.send_client_graphics_cleanup(client_id);
+ }
+ }
+
fn update_client_host_theme_from_events(
&mut self,
client_id: u64,
@@ -1047,15 +1088,29 @@ impl HeadlessServer {
client_id,
cols,
rows,
+ cell_width_px,
+ cell_height_px,
writer,
render_encoding,
} => {
- info!(client_id, cols, rows, ?render_encoding, "client connected");
+ info!(
+ client_id,
+ cols,
+ rows,
+ cell_width_px,
+ cell_height_px,
+ ?render_encoding,
+ "client connected"
+ );
let last_activity = self.allocate_activity_stamp();
self.clients.insert(
client_id,
ClientConnection::new(
(cols, rows),
+ crate::kitty_graphics::HostCellSize {
+ width_px: cell_width_px,
+ height_px: cell_height_px,
+ },
crate::terminal_theme::TerminalTheme::default(),
None,
last_activity,
@@ -1097,6 +1152,10 @@ impl HeadlessServer {
self.app.state.detach_requested = false;
info!(client_id, "client detach requested via keybind");
+ // Clear client-local host graphics before sending ServerShutdown
+ // so the outer terminal does not retain stale images.
+ self.send_client_graphics_cleanup(client_id);
+
// Send a ServerShutdown with "detached" reason to this client
// so it exits cleanly (not with a connection-lost error).
// The client will close its connection after receiving this,
@@ -1130,10 +1189,19 @@ impl HeadlessServer {
client_id,
cols,
rows,
+ cell_width_px,
+ cell_height_px,
} => {
- info!(client_id, cols, rows, "client resize");
+ info!(
+ client_id,
+ cols, rows, cell_width_px, cell_height_px, "client resize"
+ );
if let Some(client) = self.clients.get_mut(&client_id) {
client.terminal_size = (cols, rows);
+ client.cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: cell_width_px,
+ height_px: cell_height_px,
+ };
}
self.promote_client_to_foreground(client_id);
self.resize_shared_runtime_to_effective_size();
@@ -1355,20 +1423,21 @@ impl HeadlessServer {
/// frames to all connected clients.
fn render_and_stream(&mut self) {
let foreground_client_id = self.foreground_client_id;
- let mut render_targets: Vec<(u64, (u16, u16), bool)> = self
- .clients
- .iter()
- .filter(|(_, client)| client.writer.is_some())
- .map(|(&client_id, client)| {
- (
- client_id,
- client.terminal_size,
- foreground_client_id == Some(client_id),
- )
- })
- .collect();
+ let mut render_targets: Vec<(u64, (u16, u16), crate::kitty_graphics::HostCellSize, bool)> =
+ self.clients
+ .iter()
+ .filter(|(_, client)| client.writer.is_some())
+ .map(|(&client_id, client)| {
+ (
+ client_id,
+ client.terminal_size,
+ client.cell_size,
+ foreground_client_id == Some(client_id),
+ )
+ })
+ .collect();
- render_targets.sort_by_key(|(client_id, _, is_foreground)| (*is_foreground, *client_id));
+ render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id));
if render_targets.is_empty() {
let (cols, rows) = self.effective_size;
@@ -1387,20 +1456,32 @@ impl HeadlessServer {
}
let mut broken_clients: Vec<u64> = Vec::new();
- for (client_id, (cols, rows), is_foreground) in render_targets {
+ for (client_id, (cols, rows), cell_size, is_foreground) in render_targets {
let area = Rect::new(0, 0, cols, rows);
- let (buffer, cursor) = crate::server::render_stream::render_virtual(
+ let (buffer, cursor) = crate::server::render_stream::render_virtual_with_cell_size(
&mut self.app.state,
area,
is_foreground,
+ cell_size,
);
let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state);
- let frame =
+ let mut frame =
FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks);
let Some(client) = self.clients.get_mut(&client_id) else {
continue;
};
+ let mut next_graphics_cache = client.graphics_cache.clone();
+ if self.app.state.kitty_graphics_enabled {
+ frame.graphics = crate::kitty_graphics::encode_local_pane_graphics(
+ &self.app.state,
+ cell_size,
+ &mut next_graphics_cache,
+ );
+ } else {
+ frame.graphics = next_graphics_cache.clear_bytes();
+ }
+
let Some(writer) = client.writer.as_ref().cloned() else {
continue;
};
@@ -1417,7 +1498,6 @@ impl HeadlessServer {
client_id,
claimed, max, "skipping oversized frame for client"
);
- client.render_state.note_oversized_frame(frame);
continue;
}
Err(err) => {
@@ -1430,6 +1510,7 @@ impl HeadlessServer {
match writer.render.try_send(serialized) {
Ok(()) => {
client.render_pending = false;
+ client.graphics_cache = next_graphics_cache;
client.render_state.commit_sent_frame(frame, prepared);
}
Err(std::sync::mpsc::TrySendError::Full(_)) => {
@@ -1534,7 +1615,8 @@ impl HeadlessServer {
info!("server shutdown initiated");
self.shutting_down = true;
- // Send ServerShutdown to all connected clients.
+ // Clear client-local host graphics, then send ServerShutdown to all connected clients.
+ self.send_all_clients_graphics_cleanup();
let shutdown_msg = ServerMessage::ServerShutdown {
reason: Some("server is shutting down".to_owned()),
};
@@ -1557,6 +1639,7 @@ impl HeadlessServer {
// Send ServerShutdown to all remaining clients.
if !self.clients.is_empty() {
+ self.send_all_clients_graphics_cleanup();
let shutdown_msg = ServerMessage::ServerShutdown {
reason: Some("server is shutting down".to_owned()),
};
@@ -2026,6 +2109,7 @@ mod tests {
1,
ClientConnection::new(
(160, 45),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0xaa,
@@ -2048,6 +2132,7 @@ mod tests {
2,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme {
foreground: Some(crate::terminal_theme::RgbColor {
r: 0x10,
@@ -2100,6 +2185,7 @@ mod tests {
1,
ClientConnection::new(
(120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2111,6 +2197,7 @@ mod tests {
2,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
Some(true),
2,
@@ -2140,6 +2227,7 @@ mod tests {
1,
ClientConnection::new(
(120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2151,6 +2239,7 @@ mod tests {
2,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
Some(true),
2,
@@ -2180,6 +2269,7 @@ mod tests {
1,
ClientConnection::new(
(120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
Some(true),
1,
@@ -2215,6 +2305,7 @@ mod tests {
1,
ClientConnection::new(
(120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2226,6 +2317,7 @@ mod tests {
2,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
2,
@@ -2270,6 +2362,7 @@ mod tests {
1,
ClientConnection::new(
(120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2303,6 +2396,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2348,6 +2442,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2406,6 +2501,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2449,6 +2545,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2499,6 +2596,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2530,6 +2628,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2562,6 +2661,7 @@ mod tests {
1,
ClientConnection::new(
(120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2573,6 +2673,7 @@ mod tests {
2,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
2,
@@ -2614,6 +2715,7 @@ mod tests {
1,
ClientConnection::new(
(120, 40),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2625,6 +2727,7 @@ mod tests {
2,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
2,
@@ -2668,6 +2771,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
@@ -2719,6 +2823,7 @@ mod tests {
1,
ClientConnection::new(
(80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 5eda0231..743817ac 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -13,11 +13,12 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 4;
+pub const PROTOCOL_VERSION: u32 = 5;
-/// Maximum allowed frame payload size (2 MB). Frames larger than this are
-/// rejected to prevent denial-of-service via oversized length prefixes.
-pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024;
+/// Maximum allowed frame payload size (32 MB). Frames larger than this are
+/// rejected to prevent denial-of-service via oversized length prefixes while
+/// leaving room for first-frame Kitty graphics image uploads.
+pub const MAX_FRAME_SIZE: usize = 32 * 1024 * 1024;
/// Length of the u32 little-endian length prefix in bytes.
const LENGTH_PREFIX_BYTES: usize = 4;
@@ -46,6 +47,10 @@ pub enum ClientMessage {
cols: u16,
/// Terminal height in rows.
rows: u16,
+ /// Width of a terminal cell in physical pixels.
+ cell_width_px: u32,
+ /// Height of a terminal cell in physical pixels.
+ cell_height_px: u32,
/// Render encoding requested by the client.
requested_encoding: RenderEncoding,
},
@@ -62,6 +67,10 @@ pub enum ClientMessage {
cols: u16,
/// New terminal height in rows.
rows: u16,
+ /// Width of a terminal cell in physical pixels.
+ cell_width_px: u32,
+ /// Height of a terminal cell in physical pixels.
+ cell_height_px: u32,
},
/// Graceful disconnect request.
@@ -114,6 +123,8 @@ pub struct FrameData {
pub cursor: Option<CursorState>,
/// OSC 8 hyperlink URIs referenced by cells.
pub hyperlinks: Vec<String>,
+ /// Kitty graphics protocol bytes to apply after the text frame.
+ pub graphics: Vec<u8>,
}
impl FrameData {
@@ -178,6 +189,7 @@ impl FrameData {
height,
cursor,
hyperlinks: hyperlink_uris,
+ graphics: Vec::new(),
}
}
@@ -255,6 +267,12 @@ pub enum ServerMessage {
/// Terminal bytes to write directly for a terminal-ANSI client.
Terminal(TerminalFrame),
+ /// Client-local Kitty graphics bytes to write directly to the host terminal.
+ Graphics {
+ /// Raw Kitty graphics protocol bytes.
+ bytes: Vec<u8>,
+ },
+
/// Server is shutting down. Clients should exit gracefully.
ServerShutdown {
/// Optional reason for the shutdown.
@@ -546,6 +564,8 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
@@ -584,7 +604,12 @@ mod tests {
#[test]
fn client_resize_roundtrip() {
- let msg = ClientMessage::Resize { cols: 80, rows: 24 };
+ let msg = ClientMessage::Resize {
+ cols: 80,
+ rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ };
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
@@ -690,6 +715,7 @@ mod tests {
visible: true,
}),
hyperlinks: vec!["https://example.com".to_owned()],
+ graphics: Vec::new(),
};
let msg = ServerMessage::Frame(frame.clone());
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
@@ -740,6 +766,17 @@ mod tests {
}
#[test]
+ fn server_graphics_roundtrip() {
+ let msg = ServerMessage::Graphics {
+ bytes: b"\x1b_Ga=d,d=A,q=2;\x1b\\".to_vec(),
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
fn server_terminal_frame_roundtrip() {
let msg = ServerMessage::Terminal(TerminalFrame {
seq: 7,
@@ -771,6 +808,8 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
};
let mut buf = Vec::new();
@@ -811,6 +850,7 @@ mod tests {
visible: true,
}),
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
let msg = ServerMessage::Frame(frame);
@@ -839,6 +879,8 @@ mod tests {
version: PROTOCOL_VERSION,
cols: (80 + (i % 40) as u16),
rows: (24 + (i % 20) as u16),
+ cell_width_px: 8,
+ cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
},
1 => ClientMessage::Input {
@@ -847,6 +889,8 @@ mod tests {
2 => ClientMessage::Resize {
cols: (100 + (i % 30) as u16),
rows: (30 + (i % 10) as u16),
+ cell_width_px: 8,
+ cell_height_px: 16,
},
3 => ClientMessage::Detach,
_ => unreachable!(),
@@ -1142,6 +1186,7 @@ mod tests {
height: 2,
cursor: None,
hyperlinks: Vec::new(),
+ graphics: Vec::new(),
};
assert!(frame.to_ratatui_buffer().is_none());
}
@@ -1263,6 +1308,8 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
+ cell_width_px: 8,
+ cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
};
let mut buf = Vec::new();
@@ -1294,6 +1341,8 @@ mod tests {
version: PROTOCOL_VERSION,
cols: 200,
rows: 60,
+ cell_width_px: 8,
+ cell_height_px: 16,
requested_encoding: RenderEncoding::SemanticFrame,
},
ClientMessage::Input {
@@ -1302,6 +1351,8 @@ mod tests {
ClientMessage::Resize {
cols: 100,
rows: 30,
+ cell_width_px: 8,
+ cell_height_px: 16,
},
ClientMessage::Detach,
];
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index 45542a7a..ca0ecddd 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -57,7 +57,8 @@ impl ClientRenderState {
if blit_encoder.is_current(frame) {
return None;
}
- let encoded = blit_encoder.encode(frame, false);
+ let mut encoded = blit_encoder.encode(frame, false);
+ insert_graphics_before_sync_end(&mut encoded.bytes, &frame.graphics);
Some(PreparedRender {
message: ServerMessage::Terminal(TerminalFrame {
seq: *seq + 1,
@@ -72,12 +73,6 @@ impl ClientRenderState {
}
}
- pub(crate) fn note_oversized_frame(&mut self, frame: FrameData) {
- if let Self::Semantic { last_frame } = self {
- *last_frame = Some(frame);
- }
- }
-
pub(crate) fn commit_sent_frame(&mut self, frame: FrameData, prepared: PreparedRender) {
match (self, prepared.encoded) {
(Self::Semantic { last_frame }, None) => *last_frame = Some(frame),
@@ -98,6 +93,30 @@ impl ClientRenderState {
}
}
+const SYNC_OUTPUT_END: &[u8] = b"\x1b[?2026l";
+
+fn insert_graphics_before_sync_end(encoded: &mut Vec<u8>, graphics: &[u8]) {
+ if graphics.is_empty() {
+ return;
+ }
+
+ if let Some(sync_end) = rfind_subslice(encoded, SYNC_OUTPUT_END) {
+ encoded.splice(sync_end..sync_end, graphics.iter().copied());
+ } else {
+ encoded.extend_from_slice(graphics);
+ }
+}
+
+fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
+ if needle.is_empty() || needle.len() > haystack.len() {
+ return None;
+ }
+
+ haystack
+ .windows(needle.len())
+ .rposition(|window| window == needle)
+}
+
/// A prepared client render message plus any baseline state needed after send.
pub(crate) struct PreparedRender {
message: ServerMessage,
@@ -202,8 +221,22 @@ pub(crate) fn render_virtual(
area: Rect,
resize_panes: bool,
) -> (ratatui::buffer::Buffer, Option<CursorState>) {
+ render_virtual_with_cell_size(
+ app_state,
+ area,
+ resize_panes,
+ crate::kitty_graphics::HostCellSize::default(),
+ )
+}
+
+pub(crate) fn render_virtual_with_cell_size(
+ app_state: &mut AppState,
+ area: Rect,
+ resize_panes: bool,
+ cell_size: crate::kitty_graphics::HostCellSize,
+) -> (ratatui::buffer::Buffer, Option<CursorState>) {
if resize_panes {
- crate::ui::compute_view(app_state, area);
+ crate::ui::compute_view_with_cell_size(app_state, area, cell_size);
} else {
crate::ui::compute_view_without_resizing_panes(app_state, area);
}
diff --git a/src/ui.rs b/src/ui.rs
index 2e1acc56..a27388db 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -85,6 +85,7 @@ pub(super) fn spinner_frame(tick: u32) -> &'static str {
/// Compute view geometry and reconcile pane sizes.
/// Called before render to separate mutation from drawing.
+#[cfg_attr(not(test), allow(dead_code))]
pub fn compute_view(app: &mut AppState, area: Rect) {
compute_view_internal(
app,