aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 18:53:37 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-13 18:53:53 +0300
commitc542fc871743f96f4e665e80f9274a262421d847 (patch)
treec3e131449c2fb1d2efc1eb823cfd0123a6e32cd1
parentb02a9a81d2316822822c81cbb806012ee278fb68 (diff)
fix: gate kitty graphics behind config
-rw-r--r--src/app/mod.rs17
-rw-r--r--src/client/mod.rs145
-rw-r--r--src/ghostty/mod.rs7
-rw-r--r--src/kitty_graphics.rs94
-rw-r--r--src/main.rs8
-rw-r--r--src/pane.rs8
-rw-r--r--src/server/client_transport.rs8
-rw-r--r--src/server/headless.rs80
-rw-r--r--src/server/protocol.rs15
9 files changed, 282 insertions, 100 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 84b8e72f..8ef9d8f6 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -501,17 +501,20 @@ impl App {
if needs_render && self.can_render_now(now) {
self.render_dirty.swap(false, Ordering::AcqRel);
let _sync_output = SyncOutputGuard::begin()?;
+ let kitty_graphics_enabled = self.state.kitty_graphics_enabled;
let mut cell_size = crate::kitty_graphics::HostCellSize::default();
terminal.draw(|frame| {
let area = frame.area();
- cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area);
- crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size);
+ if kitty_graphics_enabled {
+ cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area);
+ crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size);
+ } else {
+ crate::ui::compute_view(&mut self.state, area);
+ }
crate::ui::render(&self.state, frame);
})?;
- if self.state.kitty_graphics_enabled {
+ if 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;
@@ -695,8 +698,12 @@ impl App {
}
if !invalid_section("advanced") {
+ let was_kitty_graphics_enabled = self.state.kitty_graphics_enabled;
self.state.kitty_graphics_enabled = config.advanced.kitty_graphics;
crate::kitty_graphics::set_enabled(config.advanced.kitty_graphics);
+ if was_kitty_graphics_enabled && !config.advanced.kitty_graphics {
+ let _ = crate::kitty_graphics::clear_all_host_graphics();
+ }
self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index a29a40f6..9679e95c 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -15,10 +15,11 @@
pub(crate) mod blit;
mod input;
+use std::collections::HashSet;
use std::io::{self, Write as _};
use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::Arc;
+use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use crossterm::event::{
@@ -35,8 +36,7 @@ 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);
+static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
// ---------------------------------------------------------------------------
// Client state
@@ -50,6 +50,8 @@ struct ClientState {
reported_size: (u16, u16),
/// Client-local sound playback config, refreshed on server request.
sound_config: crate::config::SoundConfig,
+ /// Whether this client may write Kitty graphics bytes to its host terminal.
+ kitty_graphics_enabled: bool,
}
// ---------------------------------------------------------------------------
@@ -175,9 +177,7 @@ 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);
- }
+ let _ = clear_received_kitty_graphics(&mut io::stdout());
// Reset modifyOtherKeys if we enabled it.
if in_tmux {
@@ -295,6 +295,7 @@ pub fn run_client() -> io::Result<()> {
let loaded_config = crate::config::Config::load();
let sound_config = loaded_config.config.ui.sound;
+ let kitty_graphics_enabled = loaded_config.config.advanced.kitty_graphics;
let socket_path = client_socket_path();
crate::logging::startup("client");
@@ -312,7 +313,8 @@ pub fn run_client() -> io::Result<()> {
};
// Get the terminal geometry before handshake (before raw mode).
- let (cols, rows, cell_width_px, cell_height_px) = current_terminal_geometry();
+ let (cols, rows, cell_width_px, cell_height_px) =
+ current_terminal_geometry(kitty_graphics_enabled);
let requested_encoding = requested_render_encoding();
@@ -369,6 +371,7 @@ pub fn run_client() -> io::Result<()> {
rows,
should_quit,
sound_config,
+ kitty_graphics_enabled,
negotiated_encoding,
)
.await
@@ -412,12 +415,14 @@ async fn run_client_loop(
rows: u16,
should_quit: Arc<AtomicBool>,
sound_config: crate::config::SoundConfig,
+ kitty_graphics_enabled: bool,
negotiated_encoding: RenderEncoding,
) -> Result<(), ClientError> {
let mut state = ClientState {
blit_encoder: blit::BlitEncoder::new(),
reported_size: (cols, rows),
sound_config,
+ kitty_graphics_enabled,
};
debug!(?negotiated_encoding, "client render encoding active");
@@ -437,7 +442,7 @@ async fn run_client_loop(
let resize_quit = should_quit.clone();
let resize_tx = event_tx.clone();
std::thread::spawn(move || {
- resize_poll_loop(resize_tx, cols, rows, &resize_quit);
+ resize_poll_loop(resize_tx, cols, rows, kitty_graphics_enabled, &resize_quit);
});
// Spawn the server reader thread (blocking reads from the socket).
@@ -486,27 +491,31 @@ async fn run_client_loop(
ServerMessage::Frame(frame_data) => {
let encoded = state.blit_encoder.encode(&frame_data, false);
let mut stdout = io::stdout();
- let _ = write_encoded_frame_with_graphics(
- &mut stdout,
- &encoded.bytes,
- &frame_data.graphics,
- );
+ let graphics = if state.kitty_graphics_enabled {
+ frame_data.graphics.as_slice()
+ } else {
+ &[]
+ };
+ let _ =
+ write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, 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);
+ if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) {
+ record_received_kitty_graphics(&frame.bytes);
}
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();
+ if state.kitty_graphics_enabled {
+ record_received_kitty_graphics(&bytes);
+ let mut stdout = io::stdout();
+ let _ = stdout.write_all(&bytes);
+ let _ = stdout.flush();
+ }
}
ServerMessage::ServerShutdown { reason } => {
return Err(ClientError::ServerShutdown { reason });
@@ -708,7 +717,7 @@ fn write_encoded_frame_with_graphics(
return writer.write_all(encoded);
}
- RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release);
+ record_received_kitty_graphics(graphics);
if let Some(sync_end) = rfind_subslice(encoded, SYNC_OUTPUT_END) {
writer.write_all(&encoded[..sync_end])?;
@@ -727,6 +736,73 @@ fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool {
bytes.windows(3).any(|window| window == b"\x1b_G")
}
+fn record_received_kitty_graphics(bytes: &[u8]) {
+ let ids = kitty_graphics_image_ids(bytes);
+ if ids.is_empty() {
+ return;
+ }
+ let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new()));
+ if let Ok(mut set) = set.lock() {
+ set.extend(ids);
+ }
+}
+
+fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> {
+ let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else {
+ return Ok(());
+ };
+ let Ok(mut set) = set.lock() else {
+ return Ok(());
+ };
+ for id in set.drain() {
+ write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?;
+ }
+ writer.flush()
+}
+
+fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec<u32> {
+ let mut ids = Vec::new();
+ let mut index = 0usize;
+ while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") {
+ let command_start = index + start + 3;
+ let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else {
+ break;
+ };
+ let command = &bytes[command_start..command_start + end];
+ if let Some(id) = kitty_graphics_command_image_id(command) {
+ ids.push(id);
+ }
+ index = command_start + end + 2;
+ }
+ ids
+}
+
+fn kitty_graphics_command_image_id(command: &[u8]) -> Option<u32> {
+ let header_end = command
+ .iter()
+ .position(|byte| *byte == b';')
+ .unwrap_or(command.len());
+ for part in command[..header_end].split(|byte| *byte == b',') {
+ let Some(value) = part.strip_prefix(b"i=") else {
+ continue;
+ };
+ let text = std::str::from_utf8(value).ok()?;
+ if let Ok(id) = text.parse::<u32>() {
+ return Some(id);
+ }
+ }
+ None
+}
+
+fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
+ if needle.is_empty() || needle.len() > haystack.len() {
+ return None;
+ }
+ haystack
+ .windows(needle.len())
+ .position(|window| window == needle)
+}
+
fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return None;
@@ -741,8 +817,11 @@ fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
// Resize polling
// ---------------------------------------------------------------------------
-fn current_terminal_geometry() -> (u16, u16, u32, u32) {
+fn current_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
+ if !kitty_graphics_enabled {
+ return (cols, rows, 0, 0);
+ }
let Ok(size) = crossterm::terminal::window_size() else {
return (cols, rows, 8, 16);
};
@@ -762,9 +841,11 @@ fn resize_poll_loop(
resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
initial_cols: u16,
initial_rows: u16,
+ kitty_graphics_enabled: bool,
should_quit: &Arc<AtomicBool>,
) {
- let (_, _, initial_cell_width, initial_cell_height) = current_terminal_geometry();
+ let (_, _, initial_cell_width, initial_cell_height) =
+ current_terminal_geometry(kitty_graphics_enabled);
let mut last_size = (
initial_cols,
initial_rows,
@@ -773,7 +854,7 @@ fn resize_poll_loop(
);
while !should_quit.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(100));
- let new_size = current_terminal_geometry();
+ let new_size = current_terminal_geometry(kitty_graphics_enabled);
if new_size != last_size {
last_size = new_size;
if resize_tx
@@ -842,6 +923,24 @@ mod tests {
}
#[test]
+ fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() {
+ let ids = kitty_graphics_image_ids(
+ b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\",
+ );
+ assert_eq!(ids, vec![10023, 10023]);
+ }
+
+ #[test]
+ fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() {
+ record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\");
+ let mut output = Vec::new();
+ clear_received_kitty_graphics(&mut output).unwrap();
+ let text = String::from_utf8(output).unwrap();
+ assert!(text.contains("a=d,d=I,i=123"));
+ assert!(!text.contains("d=A"));
+ }
+
+ #[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/ghostty/mod.rs b/src/ghostty/mod.rs
index 1652621a..b69dfdb7 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -1188,12 +1188,7 @@ fn kitty_image_fingerprint(
}
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);
- }
+ data.hash(&mut hasher);
hasher.finish()
}
diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs
index 43cbacbd..329e67d6 100644
--- a/src/kitty_graphics.rs
+++ b/src/kitty_graphics.rs
@@ -16,7 +16,6 @@ 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 {
@@ -86,12 +85,6 @@ struct ImageSignature {
data_fingerprint: u64,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct HostPlacementState {
- signature: PlacementSignature,
- missed_frames: u8,
-}
-
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
struct PlacementSignature {
x: u16,
@@ -104,6 +97,7 @@ struct PlacementSignature {
source_height: u32,
x_offset: u32,
y_offset: u32,
+ z: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -123,7 +117,7 @@ struct ClippedPlacement {
#[derive(Debug, Default, Clone)]
pub(crate) struct HostGraphicsCache {
images: HashMap<u32, ImageSignature>,
- placements: HashMap<(u32, u32), HostPlacementState>,
+ placements: HashMap<(u32, u32), PlacementSignature>,
view: Option<HostViewKey>,
}
@@ -216,9 +210,9 @@ pub(crate) fn encode_local_pane_graphics(
fn encode_graphics_update(
bytes: &mut Vec<u8>,
placements: &[HostPlacement],
- view_changed: bool,
+ _view_changed: bool,
host_images: &mut HashMap<u32, ImageSignature>,
- host_placements: &mut HashMap<(u32, u32), HostPlacementState>,
+ host_placements: &mut HashMap<(u32, u32), PlacementSignature>,
) {
let mut current_placements = HashSet::new();
for placement in placements {
@@ -240,7 +234,7 @@ fn encode_graphics_update(
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_signature = placement_signature(clipped, placement.placement.z);
let placement_key = (host_id, host_placement_id);
current_placements.insert(placement_key);
@@ -256,19 +250,21 @@ fn encode_graphics_update(
true
}
});
- encode_upload_image(bytes, placement, format_code, host_id);
+ if !encode_upload_image(bytes, placement, format_code, host_id) {
+ continue;
+ }
host_images.insert(host_id, image_signature);
}
None => {
- encode_upload_image(bytes, placement, format_code, host_id);
+ if !encode_upload_image(bytes, placement, format_code, host_id) {
+ continue;
+ }
host_images.insert(host_id, image_signature);
}
}
match host_placements.get_mut(&placement_key) {
- Some(existing) if existing.signature == placement_signature => {
- existing.missed_frames = 0;
- }
+ Some(existing) if *existing == placement_signature => {}
Some(existing) => {
encode_display_placement(
bytes,
@@ -277,8 +273,7 @@ fn encode_graphics_update(
host_placement_id,
placement.placement.z,
);
- existing.signature = placement_signature;
- existing.missed_frames = 0;
+ *existing = placement_signature;
}
None => {
encode_display_placement(
@@ -288,26 +283,16 @@ fn encode_graphics_update(
host_placement_id,
placement.placement.z,
);
- host_placements.insert(
- placement_key,
- HostPlacementState {
- signature: placement_signature,
- missed_frames: 0,
- },
- );
+ host_placements.insert(placement_key, placement_signature);
}
}
}
let mut stale_placements = Vec::new();
- for (key, state) in &mut *host_placements {
+ for key in host_placements.keys() {
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 {
@@ -460,9 +445,9 @@ fn encode_upload_image(
placement: &HostPlacement,
format_code: u32,
host_id: u32,
-) {
+) -> bool {
if placement.placement.data.is_empty() {
- return;
+ return false;
}
let control = format!(
@@ -470,6 +455,7 @@ fn encode_upload_image(
placement.placement.image_width, placement.placement.image_height,
);
encode_kitty_data(out, &control, &placement.placement.data);
+ true
}
fn encode_display_placement(
@@ -677,7 +663,7 @@ fn image_signature_from_descriptor(
}
}
-fn placement_signature(clipped: ClippedPlacement) -> PlacementSignature {
+fn placement_signature(clipped: ClippedPlacement, z: i32) -> PlacementSignature {
PlacementSignature {
x: clipped.x,
y: clipped.y,
@@ -689,6 +675,7 @@ fn placement_signature(clipped: ClippedPlacement) -> PlacementSignature {
source_height: clipped.source_height,
x_offset: clipped.x_offset,
y_offset: clipped.y_offset,
+ z,
}
}
@@ -807,6 +794,20 @@ mod tests {
encode_graphics_update(&mut bytes, &[same], false, &mut images, &mut placements);
assert!(bytes.is_empty());
+ let mut z_changed = test_placement(0, 0);
+ z_changed.placement.z = 1;
+ encode_graphics_update(
+ &mut bytes,
+ &[z_changed],
+ false,
+ &mut images,
+ &mut placements,
+ );
+ let z_changed_bytes = String::from_utf8_lossy(&bytes);
+ assert!(!z_changed_bytes.contains("a=t"));
+ assert!(z_changed_bytes.contains("a=p"));
+
+ bytes.clear();
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);
@@ -815,11 +816,12 @@ mod tests {
}
#[test]
- fn stale_placement_grace_deletes_placement_not_image() {
+ fn empty_image_data_does_not_mark_image_uploaded() {
let mut images = HashMap::new();
let mut placements = HashMap::new();
let mut bytes = Vec::new();
- let placement = test_placement(0, 0);
+ let mut placement = test_placement(0, 0);
+ placement.placement.data.clear();
encode_graphics_update(
&mut bytes,
@@ -828,13 +830,29 @@ mod tests {
&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!(images.is_empty());
+ assert!(placements.is_empty());
+ }
+
+ #[test]
+ fn stale_placement_deletes_placement_not_image_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,
+ );
assert_eq!(placements.len(), 1);
+ bytes.clear();
encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements);
let delete = String::from_utf8_lossy(&bytes);
assert!(delete.contains("a=d,d=i"));
diff --git a/src/main.rs b/src/main.rs
index d2dfedd9..7fcf624f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -427,7 +427,9 @@ 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();
+ if crate::kitty_graphics::is_enabled() {
+ let _ = crate::kitty_graphics::clear_all_host_graphics();
+ }
let _ = execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
@@ -490,7 +492,9 @@ fn main() -> io::Result<()> {
std::io::stdout().flush()?;
}
- crate::kitty_graphics::clear_all_host_graphics()?;
+ if crate::kitty_graphics::is_enabled() {
+ crate::kitty_graphics::clear_all_host_graphics()?;
+ }
execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
diff --git a/src/pane.rs b/src/pane.rs
index 8a89fa54..d9b63fb9 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -357,9 +357,11 @@ impl PaneRuntime {
let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes)
.map_err(|e| std::io::Error::other(e.to_string()))?;
- terminal
- .enable_kitty_graphics()
- .map_err(|e| std::io::Error::other(e.to_string()))?;
+ if crate::kitty_graphics::is_enabled() {
+ terminal
+ .enable_kitty_graphics()
+ .map_err(|e| std::io::Error::other(e.to_string()))?;
+ }
let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?;
pane_terminal.apply_host_terminal_theme(host_terminal_theme);
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index a74bf7f6..348d00c7 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -146,8 +146,8 @@ pub(crate) fn handle_client_handshake(
(
clamped_cols,
clamped_rows,
- cell_width_px.max(1),
- cell_height_px.max(1),
+ cell_width_px,
+ cell_height_px,
requested_encoding,
)
}
@@ -349,8 +349,8 @@ fn client_read_loop(
client_id,
cols: clamped_cols,
rows: clamped_rows,
- cell_width_px: cell_width_px.max(1),
- cell_height_px: cell_height_px.max(1),
+ cell_width_px,
+ cell_height_px,
}
}
ClientMessage::Detach => ServerEvent::ClientDetach { client_id },
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 3e978d5a..f490ec45 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -505,7 +505,11 @@ impl HeadlessServer {
};
let (cols, rows) = self.effective_size;
let area = Rect::new(0, 0, cols, rows);
- crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size);
+ if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() {
+ crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size);
+ } else {
+ crate::ui::compute_view(&mut self.app.state, area);
+ }
// Shared runtime size changes affect pane wrapping and foreground-driven
// rendering semantics. Force one fresh frame to every remaining client
@@ -1458,12 +1462,21 @@ impl HeadlessServer {
let mut broken_clients: Vec<u64> = Vec::new();
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_with_cell_size(
- &mut self.app.state,
- area,
- is_foreground,
- cell_size,
- );
+ let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known()
+ {
+ crate::server::render_stream::render_virtual_with_cell_size(
+ &mut self.app.state,
+ area,
+ is_foreground,
+ cell_size,
+ )
+ } else {
+ crate::server::render_stream::render_virtual(
+ &mut self.app.state,
+ area,
+ is_foreground,
+ )
+ };
let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state);
let mut frame =
FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks);
@@ -1472,7 +1485,7 @@ impl HeadlessServer {
continue;
};
let mut next_graphics_cache = client.graphics_cache.clone();
- if self.app.state.kitty_graphics_enabled {
+ if self.app.state.kitty_graphics_enabled && cell_size.is_known() {
frame.graphics = crate::kitty_graphics::encode_local_pane_graphics(
&self.app.state,
cell_size,
@@ -1486,13 +1499,54 @@ impl HeadlessServer {
continue;
};
- let Some(prepared) = client.render_state.prepare_frame(&frame) else {
+ let mut commit_graphics_cache = true;
+ if frame.graphics.len() > MAX_FRAME_SIZE {
+ warn!(
+ client_id,
+ graphics_bytes = frame.graphics.len(),
+ max = MAX_FRAME_SIZE,
+ "dropping oversized graphics payload for client frame"
+ );
+ frame.graphics.clear();
+ commit_graphics_cache = false;
+ }
+
+ let Some(mut prepared) = client.render_state.prepare_frame(&frame) else {
client.render_pending = false;
continue;
};
+ let mut frame_to_commit = frame.clone();
let serialized = match Self::frame_server_message(prepared.message()) {
Ok(framed) => framed,
+ Err(protocol::FramingError::Oversized { claimed, max })
+ if !frame.graphics.is_empty() =>
+ {
+ warn!(
+ client_id,
+ claimed, max, "dropping graphics from oversized frame for client"
+ );
+ let mut text_only_frame = frame.clone();
+ text_only_frame.graphics.clear();
+ let Some(text_only_prepared) =
+ client.render_state.prepare_frame(&text_only_frame)
+ else {
+ client.render_pending = false;
+ continue;
+ };
+ let framed = match Self::frame_server_message(text_only_prepared.message()) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize text-only frame for client");
+ broken_clients.push(client_id);
+ continue;
+ }
+ };
+ prepared = text_only_prepared;
+ frame_to_commit = text_only_frame;
+ commit_graphics_cache = false;
+ framed
+ }
Err(protocol::FramingError::Oversized { claimed, max }) => {
warn!(
client_id,
@@ -1510,8 +1564,12 @@ 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);
+ if commit_graphics_cache {
+ client.graphics_cache = next_graphics_cache;
+ }
+ client
+ .render_state
+ .commit_sent_frame(frame_to_commit, prepared);
}
Err(std::sync::mpsc::TrySendError::Full(_)) => {
client.render_pending = true;
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 743817ac..8748c41c 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -15,10 +15,9 @@ use serde::{Deserialize, Serialize};
/// Current protocol version. Bumped when wire format changes incompatibly.
pub const PROTOCOL_VERSION: u32 = 5;
-/// 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;
+/// 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;
/// Length of the u32 little-endian length prefix in bytes.
const LENGTH_PREFIX_BYTES: usize = 4;
@@ -47,9 +46,9 @@ pub enum ClientMessage {
cols: u16,
/// Terminal height in rows.
rows: u16,
- /// Width of a terminal cell in physical pixels.
+ /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
cell_width_px: u32,
- /// Height of a terminal cell in physical pixels.
+ /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
cell_height_px: u32,
/// Render encoding requested by the client.
requested_encoding: RenderEncoding,
@@ -67,9 +66,9 @@ pub enum ClientMessage {
cols: u16,
/// New terminal height in rows.
rows: u16,
- /// Width of a terminal cell in physical pixels.
+ /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
cell_width_px: u32,
- /// Height of a terminal cell in physical pixels.
+ /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled.
cell_height_px: u32,
},