aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorWakaTaira <50697207+WakaTaira@users.noreply.github.com>2026-08-02 22:53:08 +0900
committerGitHub <noreply@github.com>2026-08-02 16:53:08 +0300
commit890113943999bd0332f535864b982190907449c4 (patch)
tree602fa09cf67a48ef19eab38ba3cb0b67a9f17189 /src
parentd742e51530a78b77e1debe23a72ea7178810f3fd (diff)
fix(client): query host cell size when the ioctl reports no pixels (#2160)
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/app/runtime.rs2
-rw-r--r--src/client/input.rs13
-rw-r--r--src/client/mod.rs178
-rw-r--r--src/raw_input.rs291
5 files changed, 449 insertions, 36 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 5d070afa..d049c0f2 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1768,6 +1768,7 @@ impl App {
self.set_host_terminal_appearance(appearance, true);
}
}
+ crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => {}
crate::raw_input::RawInputEvent::Unsupported => {}
}
self.sync_prefix_input_source(previous_mode);
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index ef0d1224..e05b9599 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -257,6 +257,8 @@ impl App {
self.query_host_terminal_theme();
self.set_host_terminal_appearance(appearance, true)
}
+ // Cell size reports are consumed by the thin client, not the runtime.
+ crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => false,
crate::raw_input::RawInputEvent::Unsupported => false,
};
self.sync_prefix_input_source(previous_mode);
diff --git a/src/client/input.rs b/src/client/input.rs
index 09e0eb92..fd374491 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -39,11 +39,16 @@ pub fn stdin_reader_loop(
event_tx: mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
host_color_query_sent: bool,
+ host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
) {
#[cfg(windows)]
{
- let _ = (host_color_query_sent, host_mouse_capture_active);
+ let _ = (
+ host_color_query_sent,
+ host_cell_size_query_sent,
+ host_mouse_capture_active,
+ );
windows_stdin_reader_loop(event_tx, should_quit);
}
@@ -52,6 +57,7 @@ pub fn stdin_reader_loop(
event_tx,
should_quit,
host_color_query_sent,
+ host_cell_size_query_sent,
host_mouse_capture_active,
);
}
@@ -61,6 +67,7 @@ fn unix_stdin_reader_loop(
event_tx: mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
host_color_query_sent: bool,
+ host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
) {
let stdin = io::stdin();
@@ -71,6 +78,9 @@ fn unix_stdin_reader_loop(
framer.host_color_query_sent();
framer.enable_host_color_scheme_change_tracking();
}
+ if host_cell_size_query_sent {
+ framer.host_cell_size_query_sent();
+ }
let mut pending_palette = Vec::new();
while !should_quit.load(Ordering::Acquire) {
@@ -422,6 +432,7 @@ fn windows_client_input_event_from_raw(
crate::raw_input::RawInputEvent::HostDefaultColor { .. }
| crate::raw_input::RawInputEvent::HostPaletteColors { .. }
| crate::raw_input::RawInputEvent::HostColorSchemeChanged(_)
+ | crate::raw_input::RawInputEvent::HostCellSizeReport { .. }
| crate::raw_input::RawInputEvent::Unsupported => None,
}
}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index d7fcdf71..c0fe73d3 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -16,7 +16,7 @@ mod input;
use std::collections::HashSet;
use std::io::{self, BufRead, Write as _};
-use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
@@ -1158,7 +1158,7 @@ fn run_client_with_mode(
// Get the terminal geometry before handshake (before raw mode).
let (cols, rows, cell_width_px, cell_height_px) =
- current_terminal_geometry(kitty_graphics_enabled);
+ initial_terminal_geometry(kitty_graphics_enabled);
// Perform handshake while the stream is still in blocking mode.
let negotiated_encoding = match do_handshake(
@@ -1239,6 +1239,8 @@ fn run_client_with_mode(
stream,
cols,
rows,
+ cell_width_px,
+ cell_height_px,
should_quit,
loop_config,
negotiated_encoding,
@@ -1283,6 +1285,8 @@ async fn run_client_loop(
stream: LocalStream,
cols: u16,
rows: u16,
+ initial_cell_width_px: u32,
+ initial_cell_height_px: u32,
should_quit: Arc<AtomicBool>,
config: ClientLoopConfig,
negotiated_encoding: RenderEncoding,
@@ -1312,6 +1316,9 @@ async fn run_client_loop(
};
debug!(?negotiated_encoding, "client render encoding active");
let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active));
+ // Cell size reported by the host terminal, packed as width<<32 | height.
+ // Zero means the host has not reported one.
+ let reported_cell_size = Arc::new(AtomicU64::new(0));
// Channel for events from the stdin, resize, and server reader threads.
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
@@ -1319,6 +1326,10 @@ async fn run_client_loop(
// Spawn the stdin reader thread.
let will_query_host_terminal_theme =
state.attach_escape.is_none() && should_query_host_terminal_theme();
+ // Terminals behind ConPTY report no pixel size through the ioctl, so ask the
+ // host terminal directly instead of falling back to an assumed cell size.
+ let will_query_host_cell_size = state.attach_escape.is_none()
+ && host_cell_size_query_required(state.kitty_graphics_enabled);
let stdin_quit = should_quit.clone();
let stdin_tx = event_tx.clone();
let stdin_mouse_capture_active = host_mouse_capture_active.clone();
@@ -1327,6 +1338,7 @@ async fn run_client_loop(
stdin_tx,
&stdin_quit,
will_query_host_terminal_theme,
+ will_query_host_cell_size,
stdin_mouse_capture_active,
);
});
@@ -1335,12 +1347,26 @@ async fn run_client_loop(
query_host_terminal_theme();
}
+ if will_query_host_cell_size {
+ query_host_cell_size();
+ }
+
// Spawn the resize poller thread.
let resize_quit = should_quit.clone();
let resize_tx = event_tx.clone();
+ let resize_cell_size = reported_cell_size.clone();
let kitty_graphics_enabled = state.kitty_graphics_enabled;
std::thread::spawn(move || {
- resize_poll_loop(resize_tx, cols, rows, kitty_graphics_enabled, &resize_quit);
+ resize_poll_loop(
+ resize_tx,
+ cols,
+ rows,
+ initial_cell_width_px,
+ initial_cell_height_px,
+ kitty_graphics_enabled,
+ &resize_cell_size,
+ &resize_quit,
+ );
});
// Spawn the server reader thread (blocking reads from the socket).
@@ -1429,6 +1455,9 @@ async fn run_client_loop(
if crate::raw_input::events_require_host_terminal_theme_query(&events) {
query_host_terminal_theme();
}
+ if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) {
+ store_reported_cell_size(&reported_cell_size, width_px, height_px);
+ }
data
};
if should_bridge_clipboard_image_paste(
@@ -2085,23 +2114,56 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
// Resize polling
// ---------------------------------------------------------------------------
-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);
- };
+/// Cell size assumed when neither the terminal size ioctl nor the host
+/// terminal reports pixel dimensions.
+const DEFAULT_CELL_WIDTH_PX: u32 = 8;
+const DEFAULT_CELL_HEIGHT_PX: u32 = 16;
+
+/// Cell size derived from the terminal size ioctl, if it reports pixels.
+fn ioctl_cell_size() -> Option<(u32, u32)> {
+ let size = crossterm::terminal::window_size().ok()?;
if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 {
- return (cols, rows, 8, 16);
+ return None;
}
- (
- cols,
- rows,
+ Some((
(size.width as u32 / size.columns as u32).max(1),
(size.height as u32 / size.rows as u32).max(1),
- )
+ ))
+}
+
+/// Cell size used when the ioctl reports no pixels.
+fn cell_size_fallback(reported: u64) -> (u32, u32) {
+ unpack_cell_size(reported).unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX))
+}
+
+#[cfg(any(unix, test))]
+fn pack_cell_size(width_px: u32, height_px: u32) -> u64 {
+ (u64::from(width_px) << 32) | u64::from(height_px)
+}
+
+fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> {
+ let width_px = (packed >> 32) as u32;
+ let height_px = (packed & u64::from(u32::MAX)) as u32;
+ (width_px > 0 && height_px > 0).then_some((width_px, height_px))
+}
+
+fn current_terminal_geometry(
+ kitty_graphics_enabled: bool,
+ reported_cell_size: &AtomicU64,
+) -> (u16, u16, u32, u32) {
+ let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
+ if !kitty_graphics_enabled {
+ return (cols, rows, 0, 0);
+ }
+ let (cell_width_px, cell_height_px) = ioctl_cell_size()
+ .unwrap_or_else(|| cell_size_fallback(reported_cell_size.load(Ordering::Acquire)));
+ (cols, rows, cell_width_px, cell_height_px)
+}
+
+/// Reads the terminal geometry before the handshake, before any host cell
+/// size report can exist.
+fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) {
+ current_terminal_geometry(kitty_graphics_enabled, &AtomicU64::new(0))
}
/// Reports polled changes and signalled resizes that return to the same size.
@@ -2114,16 +2176,21 @@ fn resize_report_required(
}
/// Watches the terminal size and sends resize events when it changes.
+///
+/// The baseline cell size must match what the handshake sent to the server:
+/// reading a fresh one here would race the host cell size reply and could
+/// swallow the first change.
fn resize_poll_loop(
resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
initial_cols: u16,
initial_rows: u16,
+ initial_cell_width: u32,
+ initial_cell_height: u32,
kitty_graphics_enabled: bool,
+ reported_cell_size: &AtomicU64,
should_quit: &Arc<AtomicBool>,
) {
crate::platform::watch_terminal_resize_signal();
- let (_, _, initial_cell_width, initial_cell_height) =
- current_terminal_geometry(kitty_graphics_enabled);
let mut last_size = (
initial_cols,
initial_rows,
@@ -2133,7 +2200,7 @@ fn resize_poll_loop(
while !should_quit.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(100));
let signalled = crate::platform::take_terminal_resize_signal();
- let new_size = current_terminal_geometry(kitty_graphics_enabled);
+ let new_size = current_terminal_geometry(kitty_graphics_enabled, reported_cell_size);
if resize_report_required(signalled, new_size, last_size) {
last_size = new_size;
if resize_tx
@@ -2167,6 +2234,49 @@ fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()>
writer.flush()
}
+/// XTWINOPS request for the host terminal cell size in pixels.
+const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t";
+
+fn query_host_cell_size() {
+ let _ = write_host_cell_size_query(io::stdout());
+}
+
+fn should_query_host_cell_size() -> bool {
+ !cfg!(windows)
+}
+
+/// Only pane graphics need pixel dimensions, and only when the ioctl cannot
+/// provide them.
+fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool {
+ kitty_graphics_enabled && should_query_host_cell_size() && ioctl_cell_size().is_none()
+}
+
+fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> {
+ writer.write_all(HOST_CELL_SIZE_QUERY)?;
+ writer.flush()
+}
+
+#[cfg(any(unix, test))]
+fn store_reported_cell_size(reported_cell_size: &AtomicU64, width_px: u32, height_px: u32) {
+ let packed = pack_cell_size(width_px, height_px);
+ if reported_cell_size.swap(packed, Ordering::AcqRel) != packed {
+ debug!(width_px, height_px, "host terminal reported cell size");
+ }
+}
+
+#[cfg(any(unix, test))]
+fn reported_cell_size_from_events(
+ events: &[crate::raw_input::RawInputEvent],
+) -> Option<(u32, u32)> {
+ events.iter().rev().find_map(|event| match event {
+ crate::raw_input::RawInputEvent::HostCellSizeReport {
+ width_px,
+ height_px,
+ } => Some((*width_px, *height_px)),
+ _ => None,
+ })
+}
+
fn init_logging() {
crate::logging::init_file_logging("herdr-client.log");
}
@@ -2484,6 +2594,36 @@ mod tests {
}
#[test]
+ fn write_host_cell_size_query_emits_xtwinops_request() {
+ let mut output = Vec::new();
+ write_host_cell_size_query(&mut output).unwrap();
+
+ assert_eq!(output, b"\x1b[16t");
+ }
+
+ #[test]
+ fn host_cell_size_query_is_disabled_on_windows() {
+ assert_eq!(should_query_host_cell_size(), !cfg!(windows));
+ }
+
+ #[test]
+ fn cell_size_fallback_prefers_reported_size_over_default() {
+ assert_eq!(cell_size_fallback(0), (8, 16));
+ assert_eq!(cell_size_fallback(pack_cell_size(10, 21)), (10, 21));
+ assert_eq!(cell_size_fallback(pack_cell_size(10, 0)), (8, 16));
+ assert_eq!(cell_size_fallback(pack_cell_size(0, 21)), (8, 16));
+ }
+
+ #[test]
+ fn reported_cell_size_is_taken_from_host_cell_size_events() {
+ let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n");
+ assert_eq!(reported_cell_size_from_events(&events), None);
+
+ let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t");
+ assert_eq!(reported_cell_size_from_events(&events), Some((9, 18)));
+ }
+
+ #[test]
fn color_scheme_reports_are_enabled_only_for_full_clients() {
assert_eq!(
should_enable_host_color_scheme_reports(true),
diff --git a/src/raw_input.rs b/src/raw_input.rs
index ce7aec19..60c57af3 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -139,6 +139,12 @@ pub enum RawInputEvent {
colors: Vec<(u8, RgbColor)>,
},
HostColorSchemeChanged(HostAppearance),
+ // The dimensions are only read by the Unix client.
+ #[cfg_attr(not(any(unix, test)), allow(dead_code))]
+ HostCellSizeReport {
+ width_px: u32,
+ height_px: u32,
+ },
Unsupported,
}
@@ -209,12 +215,15 @@ pub(crate) struct RawInputByteFramer {
discarded_tail_bytes: usize,
lone_escape_recently_flushed: bool,
host_color_replies_awaited: u16,
- held_pending_color_esc: bool,
+ host_cell_size_replies_awaited: u16,
+ held_pending_host_reply_esc: bool,
host_color_scheme_change_tracking: bool,
split_coalesced_escape: bool,
}
const HOST_COLOR_QUERY_REPLIES: u16 = 258;
+#[cfg(any(unix, test))]
+const HOST_CELL_SIZE_QUERY_REPLIES: u16 = 1;
const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32;
impl RawInputByteFramer {
@@ -240,7 +249,19 @@ impl RawInputByteFramer {
/// at its ESC introducer stitches back together instead of leaking (#549).
pub(crate) fn host_color_query_sent(&mut self) {
self.host_color_replies_awaited = HOST_COLOR_QUERY_REPLIES;
- self.held_pending_color_esc = false;
+ self.held_pending_host_reply_esc = false;
+ }
+
+ /// Same hold window as `host_color_query_sent`, for the XTWINOPS cell size
+ /// reply. Only the Unix client sends this query.
+ #[cfg(any(unix, test))]
+ pub(crate) fn host_cell_size_query_sent(&mut self) {
+ self.host_cell_size_replies_awaited = HOST_CELL_SIZE_QUERY_REPLIES;
+ self.held_pending_host_reply_esc = false;
+ }
+
+ fn awaiting_host_reply(&self) -> bool {
+ self.host_color_replies_awaited > 0 || self.host_cell_size_replies_awaited > 0
}
pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) {
@@ -270,6 +291,9 @@ impl RawInputByteFramer {
let mut chunks = self.drain_available_chunks();
if let Some(family) = self.discard_until {
+ if family == ControlStringFamily::HostReplyCsi {
+ return chunks;
+ }
if family == ControlStringFamily::OrphanedSgrMouseTail {
self.buffer.clear();
self.discard_until = None;
@@ -340,12 +364,37 @@ impl RawInputByteFramer {
return chunks;
}
+ if self.host_cell_size_replies_awaited > 0 && self.buffer.as_slice() == b"\x1b[" {
+ if !self.held_pending_host_reply_esc {
+ self.held_pending_host_reply_esc = true;
+ tracing::trace!("holding incomplete cell size reply one flush");
+ return chunks;
+ }
+ self.host_cell_size_replies_awaited = 0;
+ self.held_pending_host_reply_esc = false;
+ }
+
+ if self.host_cell_size_replies_awaited > 0
+ && starts_with_incomplete_host_cell_size_report(&self.buffer)
+ {
+ tracing::debug!(
+ len = self.buffer.len(),
+ "discarding incomplete host cell size report after input timeout"
+ );
+ self.host_cell_size_replies_awaited = 0;
+ self.held_pending_host_reply_esc = false;
+ self.discard_until = Some(ControlStringFamily::HostReplyCsi);
+ self.discarded_tail_bytes = 0;
+ self.buffer.clear();
+ return chunks;
+ }
+
if starts_with_incomplete_host_color_scheme_report(&self.buffer) {
tracing::debug!(
len = self.buffer.len(),
"discarding incomplete host color scheme report after input timeout"
);
- self.discard_until = Some(ControlStringFamily::HostColorSchemeCsi);
+ self.discard_until = Some(ControlStringFamily::HostReplyCsi);
self.discarded_tail_bytes = 0;
self.buffer.clear();
return chunks;
@@ -365,14 +414,15 @@ impl RawInputByteFramer {
}
if self.buffer.as_slice() == [ESC] {
- if self.host_color_replies_awaited > 0 && !self.held_pending_color_esc {
- self.held_pending_color_esc = true;
- tracing::trace!("holding lone escape one flush while awaiting host color reply");
+ if self.awaiting_host_reply() && !self.held_pending_host_reply_esc {
+ self.held_pending_host_reply_esc = true;
+ tracing::trace!("holding lone escape one flush while awaiting host reply");
return chunks;
}
// No continuation arrived; give up the window so Escape is not delayed again.
self.host_color_replies_awaited = 0;
- self.held_pending_color_esc = false;
+ self.host_cell_size_replies_awaited = 0;
+ self.held_pending_host_reply_esc = false;
tracing::warn!(
bytes = ?self.buffer,
"flushing lone escape after input timeout; if this follows an alt chord or focus switch it may reach the pane as plain esc"
@@ -422,6 +472,15 @@ impl RawInputByteFramer {
}
if let Some(family) = self.discard_until {
+ if family == ControlStringFamily::HostReplyCsi {
+ if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes)
+ {
+ self.discard_until = None;
+ self.discarded_tail_bytes = 0;
+ continue;
+ }
+ break;
+ }
if family == ControlStringFamily::OrphanedSgrMouseTail {
if discard_orphaned_sgr_mouse_tail(
&mut self.buffer,
@@ -459,12 +518,15 @@ impl RawInputByteFramer {
RawInputEvent::HostDefaultColor { .. } | RawInputEvent::HostPaletteColors { .. }
) {
self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_sub(1);
+ } else if matches!(event, RawInputEvent::HostCellSizeReport { .. }) {
+ self.host_cell_size_replies_awaited =
+ self.host_cell_size_replies_awaited.saturating_sub(1);
} else if self.host_color_scheme_change_tracking
&& matches!(event, RawInputEvent::HostColorSchemeChanged(_))
{
self.host_color_query_sent();
}
- self.held_pending_color_esc = false;
+ self.held_pending_host_reply_esc = false;
chunks.push(self.buffer[..consumed].to_vec());
self.buffer.drain(..consumed);
}
@@ -499,9 +561,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) ->
)
}),
ControlStringFamily::StTerminated => buffer.last() == Some(&ESC),
- ControlStringFamily::HostColorSchemeCsi => buffer
- .iter()
- .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'?' | b'n')),
+ ControlStringFamily::HostReplyCsi => false,
ControlStringFamily::OrphanedSgrMouseTail => buffer
.iter()
.all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')),
@@ -748,6 +808,16 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
return Some((RawInputEvent::HostColorSchemeChanged(appearance), seq_len));
}
+ if let Some((width_px, height_px)) = parse_host_cell_size_report(&buffer[..seq_len]) {
+ return Some((
+ RawInputEvent::HostCellSizeReport {
+ width_px,
+ height_px,
+ },
+ seq_len,
+ ));
+ }
+
if let Some(mouse) = parse_sgr_mouse(seq) {
return Some((RawInputEvent::Mouse(mouse), seq_len));
}
@@ -775,7 +845,7 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
enum ControlStringFamily {
Osc,
StTerminated,
- HostColorSchemeCsi,
+ HostReplyCsi,
OrphanedSgrMouseTail,
}
@@ -798,6 +868,23 @@ fn parse_host_color_scheme_report(buffer: &[u8]) -> Option<HostAppearance> {
}
}
+/// Parses an XTWINOPS cell size report (`CSI 6 ; height ; width t`) into
+/// `(width_px, height_px)`; note the reply orders height first.
+fn parse_host_cell_size_report(buffer: &[u8]) -> Option<(u32, u32)> {
+ let body = buffer.strip_prefix(b"\x1b[")?.strip_suffix(b"t")?;
+ let text = std::str::from_utf8(body).ok()?;
+ let mut params = text.split(';');
+ if params.next()? != "6" {
+ return None;
+ }
+ let height_px = params.next()?.parse::<u32>().ok()?;
+ let width_px = params.next()?.parse::<u32>().ok()?;
+ if params.next().is_some() || width_px == 0 || height_px == 0 {
+ return None;
+ }
+ Some((width_px, height_px))
+}
+
fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool {
matches!(
control_string(buffer),
@@ -814,6 +901,26 @@ fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool {
&& buffer.len() < GHOSTTY_COLOR_SCHEME_DARK_REPORT.len()
}
+fn starts_with_incomplete_host_cell_size_report(buffer: &[u8]) -> bool {
+ let Some(body) = buffer.strip_prefix(b"\x1b[") else {
+ return false;
+ };
+ if body.is_empty() || body.last() == Some(&b't') {
+ return false;
+ }
+
+ let mut params = body.split(|byte| *byte == b';');
+ if params.next() != Some(b"6".as_slice()) {
+ return false;
+ }
+ let height = params.next();
+ let width = params.next();
+ params.next().is_none()
+ && height.is_none_or(|value| value.iter().all(u8::is_ascii_digit))
+ && width.is_none_or(|value| value.iter().all(u8::is_ascii_digit))
+ && !(height.is_some_and(<[u8]>::is_empty) && width.is_some())
+}
+
fn control_string(buffer: &[u8]) -> Option<ControlString> {
let family = match buffer.get(..2)? {
b"\x1b]" => ControlStringFamily::Osc,
@@ -959,6 +1066,29 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail(
}
}
+fn discard_host_reply_csi_tail(buffer: &mut Vec<u8>, discarded_tail_bytes: &mut usize) -> bool {
+ let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes);
+ let inspected = buffer.len().min(remaining);
+
+ for index in 0..inspected {
+ match buffer[index] {
+ 0x20..=0x3f => {}
+ 0x40..=0x7e => {
+ buffer.drain(..=index);
+ return true;
+ }
+ _ => {
+ buffer.drain(..index);
+ return true;
+ }
+ }
+ }
+
+ buffer.drain(..inspected);
+ *discarded_tail_bytes = discarded_tail_bytes.saturating_add(inspected);
+ *discarded_tail_bytes >= MAX_DISCARDED_CONTROL_TAIL_BYTES
+}
+
fn discard_orphaned_sgr_mouse_tail(buffer: &mut Vec<u8>, discarded_tail_bytes: &mut usize) -> bool {
let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes);
let inspected = buffer.len().min(remaining);
@@ -1013,10 +1143,7 @@ fn control_string_terminator_for_family(
match family {
ControlStringFamily::Osc => osc_string_terminator(buffer),
ControlStringFamily::StTerminated => st_string_terminator(buffer),
- ControlStringFamily::HostColorSchemeCsi => buffer
- .iter()
- .position(|byte| *byte == b'n')
- .map(|idx| idx + 1),
+ ControlStringFamily::HostReplyCsi => None,
ControlStringFamily::OrphanedSgrMouseTail => buffer
.iter()
.position(|byte| matches!(*byte, b'M' | b'm'))
@@ -1386,6 +1513,42 @@ mod tests {
}
#[test]
+ fn parses_host_cell_size_report() {
+ let events = parse_raw_input_bytes_sync(b"\x1b[6;21;10t");
+
+ assert_eq!(events.len(), 1);
+ assert!(matches!(
+ events[0],
+ RawInputEvent::HostCellSizeReport {
+ width_px: 10,
+ height_px: 21,
+ }
+ ));
+ }
+
+ #[test]
+ fn host_cell_size_report_parser_is_exact() {
+ for bytes in [
+ // Zero dimensions carry no usable cell size.
+ b"\x1b[6;0;10t".as_slice(),
+ b"\x1b[6;21;0t".as_slice(),
+ // Missing or extra parameters.
+ b"\x1b[6;21t".as_slice(),
+ b"\x1b[6;21;10;3t".as_slice(),
+ // Other XTWINOPS reports must not be mistaken for a cell size.
+ b"\x1b[4;1610;777t".as_slice(),
+ b"\x1b[8;37;161t".as_slice(),
+ // Non-numeric parameters.
+ b"\x1b[6;21;1-t".as_slice(),
+ ] {
+ assert!(
+ parse_host_cell_size_report(bytes).is_none(),
+ "bytes: {bytes:?}"
+ );
+ }
+ }
+
+ #[test]
fn split_color_scheme_timeout_does_not_swallow_legacy_alt_bracket() {
let mut framer = RawInputByteFramer::default();
@@ -2472,6 +2635,102 @@ mod tests {
}
#[test]
+ fn holds_lone_escape_and_stitches_split_host_cell_size_reply() {
+ let mut framer = RawInputByteFramer::default();
+ framer.host_cell_size_query_sent();
+
+ // The XTWINOPS reply is split right at its ESC introducer.
+ assert!(framer.push(b"\x1b").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+
+ let chunks = framer.push(b"[6;21;10t");
+ assert_eq!(chunks, vec![b"\x1b[6;21;10t".to_vec()]);
+ let (event, _) = extract_one_event(&chunks[0]).unwrap();
+ assert!(matches!(
+ event,
+ RawInputEvent::HostCellSizeReport {
+ width_px: 10,
+ height_px: 21,
+ }
+ ));
+ }
+
+ #[test]
+ fn timed_out_host_cell_size_reply_fragments_do_not_leak() {
+ for (prefix, tail) in [
+ (b"\x1b[6".as_slice(), b";21;10t".as_slice()),
+ (b"\x1b[6;".as_slice(), b"21;10t".as_slice()),
+ (b"\x1b[6;21;".as_slice(), b"10t".as_slice()),
+ ] {
+ let mut framer = RawInputByteFramer::default();
+ framer.host_cell_size_query_sent();
+
+ assert!(framer.push(prefix).is_empty(), "prefix: {prefix:?}");
+ assert!(framer.flush_timeout().is_empty(), "prefix: {prefix:?}");
+ assert!(framer.push(tail).is_empty(), "tail: {tail:?}");
+ assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]);
+ }
+ }
+
+ #[test]
+ fn split_host_cell_size_reply_after_csi_intro_gets_one_more_flush() {
+ let mut framer = RawInputByteFramer::default();
+ framer.host_cell_size_query_sent();
+
+ assert!(framer.push(b"\x1b[").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ assert_eq!(framer.push(b"6;21;10t"), vec![b"\x1b[6;21;10t".to_vec()]);
+
+ let mut alt_bracket = RawInputByteFramer::default();
+ alt_bracket.host_cell_size_query_sent();
+ assert!(alt_bracket.push(b"\x1b[").is_empty());
+ assert!(alt_bracket.flush_timeout().is_empty());
+ assert_eq!(alt_bracket.flush_timeout(), vec![b"\x1b[".to_vec()]);
+ }
+
+ #[test]
+ fn malformed_host_reply_tail_preserves_following_input() {
+ let mut framer = RawInputByteFramer::default();
+ framer.host_cell_size_query_sent();
+
+ assert!(framer.push(b"\x1b[6;21").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ assert_eq!(
+ framer.push(b";10xabc"),
+ vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
+ );
+ }
+
+ #[test]
+ fn host_reply_tail_discard_is_bounded_across_pushes() {
+ let mut framer = RawInputByteFramer::default();
+ framer.host_cell_size_query_sent();
+
+ assert!(framer.push(b"\x1b[6;21").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ assert!(framer.push(&[b'1'; 64]).is_empty());
+ assert_eq!(
+ framer.push(&[b'2'; 67]),
+ vec![b"2".to_vec(), b"2".to_vec(), b"2".to_vec()]
+ );
+ }
+
+ #[test]
+ fn stops_holding_lone_escape_after_host_cell_size_reply_completes() {
+ let mut framer = RawInputByteFramer::default();
+ framer.host_cell_size_query_sent();
+
+ assert_eq!(
+ framer.push(b"\x1b[6;21;10t"),
+ vec![b"\x1b[6;21;10t".to_vec()]
+ );
+
+ // Window closed: a later lone Escape flushes immediately.
+ assert!(framer.push(b"\x1b").is_empty());
+ assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
+ }
+
+ #[test]
fn default_byte_framer_does_not_rearm_after_color_scheme_report() {
let mut framer = RawInputByteFramer::default();