aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-28 17:16:27 +0200
committerGitHub <noreply@github.com>2026-09-28 17:16:27 +0200
commitd5680d84fd1df3b592424eae5c50d54642f0727d (patch)
treef8849a87322470c3e79ff6a4b53d6cd0baa59a11 /src
parent267d47fb571c0a5e771f2e31bdf6459d625d7459 (diff)
fix: capture Windows host default colors at startup (#4710)
refs #1530 refs #4369
Diffstat (limited to 'src')
-rw-r--r--src/client/input.rs27
-rw-r--r--src/client/input/windows_vti.rs303
-rw-r--r--src/client/mod.rs13
-rw-r--r--src/client/shell_runtime.rs110
-rw-r--r--src/client/terminal_geometry.rs4
-rw-r--r--src/client/terminal_setup.rs5
-rw-r--r--src/client/tests/mod.rs5
-rw-r--r--src/protocol/wire.rs17
-rw-r--r--src/raw_input.rs329
9 files changed, 773 insertions, 40 deletions
diff --git a/src/client/input.rs b/src/client/input.rs
index 1e308d54..ab46e788 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -51,14 +51,13 @@ pub fn stdin_reader_loop(
#[cfg(windows)]
{
let _ = (
- host_color_query_sent,
host_theme_query_pending,
host_cell_size_query_sent,
host_mouse_capture_active,
host_sgr_pixels_active,
);
let _ = (host_escape_disambiguation_active, initial_host_input);
- windows_stdin_reader_loop(event_tx, should_quit);
+ windows_stdin_reader_loop(event_tx, should_quit, host_color_query_sent);
}
#[cfg(unix)]
@@ -397,6 +396,7 @@ fn idle_flush_timeout_ms(
fn windows_stdin_reader_loop(
event_tx: mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
+ host_color_query_sent: bool,
) {
if !super::windows_vti_input_backend_enabled() {
windows_vti::trace_input_transport("reader=crossterm");
@@ -405,7 +405,12 @@ fn windows_stdin_reader_loop(
match windows_vti::console_input_handle() {
Ok(handle) => {
windows_vti::trace_input_transport("reader=windows-console");
- windows_vti::raw_console_reader_loop(handle, event_tx, should_quit);
+ windows_vti::raw_console_reader_loop(
+ handle,
+ event_tx,
+ should_quit,
+ host_color_query_sent,
+ );
}
_ => {
windows_vti::trace_input_transport("reader=crossterm-fallback");
@@ -650,8 +655,20 @@ fn windows_client_input_event_from_raw(
crate::raw_input::RawInputEvent::OuterFocusLost => {
Some(crate::protocol::ClientInputEvent::FocusLost)
}
- crate::raw_input::RawInputEvent::HostDefaultColor { .. }
- | crate::raw_input::RawInputEvent::HostPaletteColors { .. }
+ crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
+ Some(crate::protocol::ClientInputEvent::HostDefaultColor {
+ kind: match kind {
+ crate::terminal_theme::DefaultColorKind::Foreground => {
+ crate::protocol::ClientHostDefaultColorKind::Foreground
+ }
+ crate::terminal_theme::DefaultColorKind::Background => {
+ crate::protocol::ClientHostDefaultColorKind::Background
+ }
+ },
+ color: color.into(),
+ })
+ }
+ 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/input/windows_vti.rs b/src/client/input/windows_vti.rs
index 0deb3185..c4642885 100644
--- a/src/client/input/windows_vti.rs
+++ b/src/client/input/windows_vti.rs
@@ -20,9 +20,16 @@ pub(super) fn raw_console_reader_loop(
handle: windows_sys::Win32::Foundation::HANDLE,
event_tx: mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
+ host_color_query_sent: bool,
) {
let mut mapper = WindowsInputMapper::default();
let mut pump = WindowsInputPump::default();
+ if host_color_query_sent {
+ // Native records distinguish physical Escape from scan-code-zero host replies.
+ pump.framer
+ .host_default_color_query_sent(std::time::Duration::from_secs(1));
+ super::super::query_host_terminal_theme();
+ }
let mut handoff = WindowsInputHandoff::default();
while !should_quit.load(Ordering::Acquire) {
@@ -325,10 +332,12 @@ struct WindowsInputMapper {
pending_paste_high_surrogate: Option<u16>,
mouse_buttons: WindowsMouseButtons,
win32_input: WindowsWin32InputModeFramer,
+ pending_escape_origin: Option<EscapeOrigin>,
}
struct WindowsInputPump {
framer: crate::raw_input::RawInputFramer,
+ pending_escape_origin: Option<EscapeOrigin>,
paste_from_win32_key_records: bool,
pending_physical_escape: Option<(crate::protocol::ClientInputEvent, bool)>,
default_mouse_candidate: DefaultMouseCandidate,
@@ -346,6 +355,7 @@ impl Default for WindowsInputPump {
fn default() -> Self {
Self {
framer: crate::raw_input::RawInputFramer::for_host_input(),
+ pending_escape_origin: None,
paste_from_win32_key_records: false,
pending_physical_escape: None,
default_mouse_candidate: DefaultMouseCandidate::default(),
@@ -357,6 +367,7 @@ impl Default for WindowsInputPump {
#[derive(Clone, Debug, PartialEq, Eq)]
enum PlatformInputItem {
Bytes(Vec<u8>),
+ Escape(EscapeOrigin),
Semantic(crate::protocol::ClientInputEvent),
PasteAwareBytes {
paste_bytes: Vec<u8>,
@@ -372,6 +383,12 @@ enum PlatformInputItem {
},
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum EscapeOrigin {
+ HostReply,
+ Keyboard,
+}
+
#[derive(Default)]
struct WindowsWin32InputModeFramer {
buffer: Vec<u8>,
@@ -386,6 +403,17 @@ enum WindowsWin32InputModeItem {
}
impl WindowsInputPump {
+ fn flush_before_semantic(&mut self) -> Vec<crate::raw_input::RawInputEvent> {
+ // A scan-code-zero reply prefix can outlive an unrelated keyboard record.
+ if self.pending_escape_origin == Some(EscapeOrigin::HostReply)
+ && self.framer.holds_host_default_color_escape()
+ {
+ return Vec::new();
+ }
+ self.pending_escape_origin = None;
+ self.framer.flush_interrupted()
+ }
+
fn process(&mut self, item: PlatformInputItem) -> Vec<crate::protocol::ClientInputEvent> {
let mut events = Vec::new();
if let Some((escape, open_bracket)) = self.pending_physical_escape.take() {
@@ -412,7 +440,7 @@ impl WindowsInputPump {
}
if let Some(escape) = item.physical_escape_press() {
if !self.framer.has_pending_bracketed_paste() {
- let raw_events = self.framer.flush_interrupted();
+ let raw_events = self.flush_before_semantic();
events.extend(self.process_raw_events(raw_events));
self.pending_physical_escape = Some((escape, false));
return events;
@@ -421,11 +449,17 @@ impl WindowsInputPump {
let mut next = match item {
PlatformInputItem::Bytes(bytes) => {
+ self.pending_escape_origin = None;
let raw_events = self.framer.push(&bytes);
self.process_raw_events(raw_events)
}
+ PlatformInputItem::Escape(origin) => {
+ self.pending_escape_origin = Some(origin);
+ let raw_events = self.framer.push(b"\x1b");
+ self.process_raw_events(raw_events)
+ }
PlatformInputItem::Semantic(event) => {
- let raw_events = self.framer.flush_interrupted();
+ let raw_events = self.flush_before_semantic();
let mut events = self.process_raw_events(raw_events);
events.push(event);
events
@@ -444,6 +478,11 @@ impl WindowsInputPump {
self.stage_default_mouse_record(nested_record, &raw_bytes);
}
let decode_win32_record = self.paste_from_win32_key_records || !pending_paste;
+ self.pending_escape_origin = if decode_win32_record && !pending_paste {
+ WindowsInputMapper::raw_escape_origin(nested_record)
+ } else {
+ None
+ };
let raw_events = if decode_win32_record {
if pending_paste {
self.framer.push(&win32_paste_bytes)
@@ -487,7 +526,7 @@ impl WindowsInputPump {
let raw_events = if events.is_empty() {
self.framer.flush_timeout()
} else {
- self.framer.flush_interrupted()
+ self.flush_before_semantic()
};
let mut output = self.process_raw_events(raw_events);
output.extend(events);
@@ -508,7 +547,12 @@ impl WindowsInputPump {
events.extend(self.process_raw_events(raw_events));
}
}
- let raw_events = self.framer.flush_timeout();
+ let raw_events = if self.pending_escape_origin == Some(EscapeOrigin::Keyboard) {
+ self.pending_escape_origin = None;
+ self.framer.flush_keyboard_escape()
+ } else {
+ self.framer.flush_timeout()
+ };
events.extend(self.process_raw_events(raw_events));
events
}
@@ -606,6 +650,7 @@ fn remove_matching_key(keys: &mut Vec<WindowsKeyRecord>, record: WindowsKeyRecor
impl PlatformInputItem {
fn raw_bytes(&self) -> Option<&[u8]> {
match self {
+ Self::Escape(_) => Some(b"\x1b"),
Self::Bytes(bytes)
| Self::PasteAwareBytes {
raw_bytes: bytes, ..
@@ -684,10 +729,18 @@ impl WindowsInputMapper {
self.win32_input
.flush_timeout()
.into_iter()
- .map(PlatformInputItem::Bytes)
+ .map(|bytes| self.raw_byte_item(bytes))
.collect()
}
+ fn raw_byte_item(&mut self, bytes: Vec<u8>) -> PlatformInputItem {
+ let origin = self.pending_escape_origin.take();
+ match origin {
+ Some(origin) if bytes.as_slice() == b"\x1b" => PlatformInputItem::Escape(origin),
+ _ => PlatformInputItem::Bytes(bytes),
+ }
+ }
+
fn translate(&mut self, record: WindowsInputRecord) -> Vec<PlatformInputItem> {
match record {
WindowsInputRecord::Key(key) => self.translate_key(key),
@@ -714,7 +767,10 @@ impl WindowsInputMapper {
return Vec::new();
}
- if self.key_record_is_raw_escape(key) {
+ if let Some(origin) = Self::raw_escape_origin(key) {
+ if self.win32_input.buffer.is_empty() {
+ self.pending_escape_origin = Some(origin);
+ }
return self.translate_win32_input_mode_bytes(&[0x1b]);
}
@@ -760,13 +816,13 @@ impl WindowsInputMapper {
self.with_pending_win32_flush(items)
}
- fn key_record_is_raw_escape(&self, key: WindowsKeyRecord) -> bool {
+ fn raw_escape_origin(key: WindowsKeyRecord) -> Option<EscapeOrigin> {
let modifiers = windows_key_modifiers(key.control_key_state);
if !key.key_down
|| key.repeat_count.max(1) != 1
|| modifiers.contains(crossterm::event::KeyModifiers::ALT)
{
- return false;
+ return None;
}
// Physical Escape carries a scan code. Scan-code-zero Escape can
@@ -778,17 +834,22 @@ impl WindowsInputMapper {
&& key.unicode == 0x1b
&& modifiers == crossterm::event::KeyModifiers::CONTROL;
- bare_escape || ctrl_bracket
+ if bare_escape && key.virtual_scan_code == 0 {
+ Some(EscapeOrigin::HostReply)
+ } else if bare_escape || ctrl_bracket {
+ Some(EscapeOrigin::Keyboard)
+ } else {
+ None
+ }
}
fn translate_win32_input_mode_bytes(&mut self, bytes: &[u8]) -> Vec<PlatformInputItem> {
let mut items = Vec::new();
for item in self.win32_input.push(bytes) {
match item {
- WindowsWin32InputModeItem::Bytes(bytes) => {
- items.push(PlatformInputItem::Bytes(bytes))
- }
+ WindowsWin32InputModeItem::Bytes(bytes) => items.push(self.raw_byte_item(bytes)),
WindowsWin32InputModeItem::Key { bytes, record } => {
+ self.pending_escape_origin = None;
trace_input_transport("transport=win32-serialized");
let win32_paste_bytes =
self.paste_payload_bytes_for_key(record).unwrap_or_default();
@@ -818,7 +879,7 @@ impl WindowsInputMapper {
&mut self,
record: WindowsKeyRecord,
) -> Option<Vec<u8>> {
- if self.key_record_is_raw_escape(record) {
+ if Self::raw_escape_origin(record).is_some() {
return Some(vec![0x1b]);
}
@@ -1389,6 +1450,217 @@ fn windows_input_trace_enabled() -> bool {
mod tests {
use super::*;
+ #[cfg(windows)]
+ #[test]
+ fn host_default_color_replies_reach_shell_without_pane_input() {
+ use crate::protocol::{
+ ClientHostColor, ClientHostDefaultColorKind, ClientHostThemeUpdate, ClientMessage,
+ };
+
+ let mut translator = WindowsInputTranslator::default();
+ translator
+ .pump
+ .framer
+ .host_default_color_query_sent(std::time::Duration::from_secs(1));
+ assert!(translator.translate(key_char('\x1b')).is_empty());
+ for _ in 0..3 {
+ assert!(translator.idle().is_empty());
+ }
+ assert!(matches!(
+ translator
+ .translate(key_vk_with_unicode(0x41, 'a', 0))
+ .as_slice(),
+ [crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('a'),
+ ..
+ }]
+ ));
+
+ let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\\x1b]11;rgb:1111/2222/3333\x1b\\"
+ .chars()
+ .map(key_char)
+ .flat_map(|record| translator.translate(record))
+ .collect::<Vec<_>>();
+ let mut shell = crate::client::shell::ClientShellState::new(
+ crate::client::shell::ClientShellConfig::from_config(&crate::config::Config::default()),
+ );
+ let outcome = shell.handle_client_events(&events);
+ assert_eq!(
+ outcome.requests,
+ vec![
+ ClientMessage::ClientShellHostTheme {
+ update: ClientHostThemeUpdate::DefaultColor {
+ kind: ClientHostDefaultColorKind::Foreground,
+ color: ClientHostColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ },
+ },
+ },
+ ClientMessage::ClientShellHostTheme {
+ update: ClientHostThemeUpdate::DefaultColor {
+ kind: ClientHostDefaultColorKind::Background,
+ color: ClientHostColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ },
+ },
+ },
+ ]
+ );
+ assert!(outcome.actions.is_empty());
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn ctrl_bracket_precedes_next_key_while_host_color_reply_is_pending() {
+ let mut translator = WindowsInputTranslator::default();
+ translator
+ .pump
+ .framer
+ .host_default_color_query_sent(std::time::Duration::from_secs(1));
+ assert!(translator
+ .translate(key_vk_with_utf16_mods(0xdb, 0x1b, 0x0008))
+ .is_empty());
+ let events = translator.translate(key_vk_with_unicode(0x0d, '\r', 0));
+ assert!(matches!(
+ events.as_slice(),
+ [
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ ..
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ ..
+ }
+ ]
+ ));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn ctrl_bracket_idle_does_not_cancel_later_host_color_reply() {
+ let mut translator = WindowsInputTranslator::default();
+ translator
+ .pump
+ .framer
+ .host_default_color_query_sent(std::time::Duration::from_secs(1));
+ assert!(translator
+ .translate(key_vk_with_utf16_mods(0xdb, 0x1b, 0x0008))
+ .is_empty());
+ assert!(matches!(
+ translator.idle().as_slice(),
+ [crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ ..
+ }]
+ ));
+ assert!(translator.translate(key_char('\x1b')).is_empty());
+ assert!(translator.idle().is_empty());
+ let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\"
+ .chars()
+ .map(key_char)
+ .flat_map(|record| translator.translate(record))
+ .collect::<Vec<_>>();
+ assert!(matches!(
+ events.as_slice(),
+ [crate::protocol::ClientInputEvent::HostDefaultColor {
+ kind: crate::protocol::ClientHostDefaultColorKind::Foreground,
+ ..
+ }]
+ ));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn serialized_host_reply_escape_survives_intervening_key() {
+ let mut translator = WindowsInputTranslator::default();
+ translator
+ .pump
+ .framer
+ .host_default_color_query_sent(std::time::Duration::from_secs(1));
+ for record in win32_input_mode_encoded_raw_bytes(b"\x1b") {
+ assert!(translator.translate(record).is_empty());
+ }
+ let key_events = translator.translate(key_vk_with_unicode(0x0d, '\r', 0));
+ assert!(matches!(
+ key_events.as_slice(),
+ [crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ ..
+ }]
+ ));
+ let events = "]10;rgb:aaaa/bbbb/cccc\x1b\\"
+ .chars()
+ .map(key_char)
+ .flat_map(|record| translator.translate(record))
+ .collect::<Vec<_>>();
+ assert!(matches!(
+ events.as_slice(),
+ [crate::protocol::ClientInputEvent::HostDefaultColor {
+ kind: crate::protocol::ClientHostDefaultColorKind::Foreground,
+ ..
+ }]
+ ));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn expired_host_color_reply_tail_does_not_swallow_semantic_key() {
+ let mut translator = WindowsInputTranslator::default();
+ translator
+ .pump
+ .framer
+ .host_default_color_query_sent(std::time::Duration::ZERO);
+ for record in "\x1b]11;rgb:1111/".chars().map(key_char) {
+ assert!(translator.translate(record).is_empty());
+ }
+ assert!(translator.idle().is_empty());
+ assert!(matches!(
+ translator
+ .translate(key_vk_with_unicode(0x0d, '\r', 0))
+ .as_slice(),
+ [crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ ..
+ }]
+ ));
+ for record in "2222/3333\x1b\\".chars().map(key_char) {
+ assert!(translator.translate(record).is_empty());
+ }
+ assert!(matches!(
+ translator
+ .translate(key_vk_with_unicode(0x41, 'a', 0))
+ .as_slice(),
+ [crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('a'),
+ ..
+ }]
+ ));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn unanswered_host_color_query_stops_holding_escape() {
+ let mut translator = WindowsInputTranslator::default();
+ translator
+ .pump
+ .framer
+ .host_default_color_query_sent(std::time::Duration::from_millis(1));
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ assert!(translator.translate(key_char('\x1b')).is_empty());
+ assert!(matches!(
+ translator.idle().as_slice(),
+ [crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ ..
+ }]
+ ));
+ }
+
fn key_char(ch: char) -> WindowsInputRecord {
WindowsInputRecord::Key(WindowsKeyRecord {
key_down: true,
@@ -2083,6 +2355,11 @@ mod tests {
},
] {
let mut translator = WindowsInputTranslator::default();
+ #[cfg(windows)]
+ translator
+ .pump
+ .framer
+ .host_default_color_query_sent(std::time::Duration::from_secs(1));
assert!(translator
.translate(WindowsInputRecord::Key(record))
.is_empty());
diff --git a/src/client/mod.rs b/src/client/mod.rs
index b78517ac..d55e4c48 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -70,7 +70,7 @@ use terminal_geometry::{
};
use terminal_geometry::{
host_cell_size_query_required, initial_terminal_geometry, query_host_cell_size,
- query_host_terminal_theme, resize_poll_loop, should_query_host_terminal_theme,
+ query_host_terminal_theme, resize_poll_loop,
};
#[cfg(unix)]
use terminal_geometry::{reported_cell_size_from_events, store_reported_cell_size};
@@ -517,8 +517,7 @@ async fn run_client_loop(
let mut endpoint_commands = endpoint_commands::EndpointCommands::default();
// Spawn the stdin reader thread.
- let will_query_host_terminal_theme =
- state.attach_escape.is_none() && should_query_host_terminal_theme();
+ let will_query_host_terminal_theme = state.attach_escape.is_none();
let host_theme_query_pending = Arc::new(AtomicU32::new(0));
let stdin_host_theme_query_pending = host_theme_query_pending.clone();
// Terminals behind ConPTY report no pixel size through the ioctl, so ask the
@@ -555,9 +554,9 @@ async fn run_client_loop(
);
});
+ #[cfg(unix)]
if will_query_host_terminal_theme {
query_host_terminal_theme();
- #[cfg(not(windows))]
if state.shell.is_some() {
query_host_terminal_appearance();
}
@@ -1191,9 +1190,9 @@ async fn run_client_loop(
cell_height_px,
pixel_geometry_exact,
) => {
- // Palette updates through OSC do not necessarily produce a color-scheme
- // notification. Re-query on redraw, including SIGWINCH without a resize,
- // so desktop theme switchers can refresh the existing panes in place.
+ // On Unix, palette changes may lack a color-scheme notification.
+ // Re-query on redraw, including SIGWINCH without a resize.
+ #[cfg(unix)]
if will_query_host_terminal_theme {
host_theme_query_pending.fetch_add(1, Ordering::AcqRel);
query_host_terminal_theme();
diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs
index d0eafdf6..5e1da58c 100644
--- a/src/client/shell_runtime.rs
+++ b/src/client/shell_runtime.rs
@@ -751,6 +751,10 @@ pub(super) fn finish_client_shell_input(
}
continue;
}
+ if endpoints.active_surface_available() {
+ write_to_server(endpoints, &request).map_err(ClientError::ConnectionLost)?;
+ }
+ continue;
}
// Host focus belongs to a pending target even when the source has gone offline or has
// already had its surface revoked. Route it before the ordinary source-online gate.
@@ -791,3 +795,109 @@ pub(super) fn finish_client_shell_input(
}
Ok(false)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn host_color_reaches_server_before_first_snapshot() {
+ #[derive(Clone)]
+ struct Capture(std::sync::Arc<std::sync::Mutex<Vec<ClientMessage>>>);
+
+ impl endpoint::EndpointTransport for Capture {
+ fn send(&mut self, message: &ClientMessage) -> std::io::Result<()> {
+ self.0.lock().unwrap().push(message.clone());
+ Ok(())
+ }
+ }
+
+ let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let mut endpoints = endpoint::EndpointRegistry::new(
+ Capture(sent.clone()),
+ 1,
+ endpoint::EndpointNegotiation::new(Vec::new(), Vec::new()),
+ );
+ let mut state = ClientState::test_new();
+ assert!(!state
+ .shell
+ .as_ref()
+ .unwrap()
+ .endpoint_is_online(&endpoint::ClientEndpointId::Local));
+ let outcome = state.shell.as_mut().unwrap().handle_raw_events(vec![
+ crate::raw_input::RawInputEvent::HostDefaultColor {
+ kind: crate::terminal_theme::DefaultColorKind::Background,
+ color: crate::terminal_theme::RgbColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ },
+ },
+ ]);
+ let mut pending_activation = None;
+ let mut endpoint_commands = endpoint_commands::EndpointCommands::default();
+ let mut prefix_input_source = crate::platform::RealPrefixInputSource::default();
+ let mut scheduled_activation = None;
+ finish_client_shell_input(
+ &mut state,
+ outcome,
+ None,
+ &mut endpoints,
+ &mut pending_activation,
+ &mut endpoint_commands,
+ &mut prefix_input_source,
+ &mut scheduled_activation,
+ )
+ .unwrap();
+
+ assert_eq!(
+ *sent.lock().unwrap(),
+ vec![ClientMessage::ClientShellHostTheme {
+ update: crate::protocol::ClientHostThemeUpdate::DefaultColor {
+ kind: crate::protocol::ClientHostDefaultColorKind::Background,
+ color: crate::protocol::ClientHostColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ },
+ },
+ }]
+ );
+
+ let local = endpoint::ClientEndpointId::Local;
+ assert!(endpoints.set_surface_active(&local, false));
+ let outcome = state.shell.as_mut().unwrap().handle_raw_events(vec![
+ crate::raw_input::RawInputEvent::HostDefaultColor {
+ kind: crate::terminal_theme::DefaultColorKind::Foreground,
+ color: crate::terminal_theme::RgbColor { r: 4, g: 5, b: 6 },
+ },
+ ]);
+ finish_client_shell_input(
+ &mut state,
+ outcome,
+ None,
+ &mut endpoints,
+ &mut pending_activation,
+ &mut endpoint_commands,
+ &mut prefix_input_source,
+ &mut scheduled_activation,
+ )
+ .unwrap();
+ assert_eq!(sent.lock().unwrap().len(), 1);
+
+ assert!(endpoints.set_surface_active(&local, true));
+ state.replay_host_theme(&mut endpoints, &local);
+ let messages = sent.lock().unwrap();
+ assert_eq!(messages.len(), 3);
+ assert_eq!(messages[1], messages[0]);
+ assert_eq!(
+ messages[2],
+ ClientMessage::ClientShellHostTheme {
+ update: crate::protocol::ClientHostThemeUpdate::DefaultColor {
+ kind: crate::protocol::ClientHostDefaultColorKind::Foreground,
+ color: crate::protocol::ClientHostColor { r: 4, g: 5, b: 6 },
+ },
+ }
+ );
+ }
+}
diff --git a/src/client/terminal_geometry.rs b/src/client/terminal_geometry.rs
index f15141bb..02d6d05a 100644
--- a/src/client/terminal_geometry.rs
+++ b/src/client/terminal_geometry.rs
@@ -192,10 +192,6 @@ pub(super) fn query_host_terminal_theme() {
let _ = write_host_terminal_theme_query(io::stdout());
}
-pub(super) fn should_query_host_terminal_theme() -> bool {
- !cfg!(windows)
-}
-
pub(super) fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
let query = crate::terminal_theme::host_terminal_theme_query_sequence(
crate::platform::should_query_host_terminal_palette(),
diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs
index 9c84985a..249e29ad 100644
--- a/src/client/terminal_setup.rs
+++ b/src/client/terminal_setup.rs
@@ -20,7 +20,6 @@ use crossterm::execute;
use crossterm::terminal::{DisableLineWrap, EnableLineWrap};
use super::frame_output::clear_received_kitty_graphics;
-use super::terminal_geometry::should_query_host_terminal_theme;
// ---------------------------------------------------------------------------
// Terminal setup / restore
@@ -86,7 +85,7 @@ pub(super) fn setup_terminal_with_capabilities(
}
(active, buffered_input)
} else {
- if should_query_host_terminal_theme() {
+ if should_enable_host_color_scheme_reports(true) {
write_host_color_scheme_report_mode(&mut io::stdout(), false)?;
}
set_mouse_capture(mouse_capture, false)?;
@@ -128,7 +127,7 @@ pub(super) fn setup_terminal_with_capabilities(
}
pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> bool {
- enable_client_protocols && should_query_host_terminal_theme()
+ enable_client_protocols && !cfg!(windows)
}
/// Guard that restores the terminal when dropped.
diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs
index 2fcac6eb..035d00d3 100644
--- a/src/client/tests/mod.rs
+++ b/src/client/tests/mod.rs
@@ -439,11 +439,6 @@ fn color_scheme_change_event_requests_host_theme_query() {
}
#[test]
-fn host_terminal_theme_query_is_disabled_on_windows() {
- assert_eq!(should_query_host_terminal_theme(), !cfg!(windows));
-}
-
-#[test]
fn write_host_cell_size_query_emits_xtwinops_request() {
let mut output = Vec::new();
write_host_cell_size_query(&mut output).unwrap();
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index e7fb2743..6d49592d 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -147,6 +147,10 @@ pub enum ClientInputEvent {
},
FocusGained,
FocusLost,
+ HostDefaultColor {
+ kind: ClientHostDefaultColorKind,
+ color: ClientHostColor,
+ },
}
/// Pane-domain input after the client has classified and consumed shell actions.
@@ -458,6 +462,19 @@ impl ClientInputEvent {
Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()),
Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained,
Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost,
+ Self::HostDefaultColor { kind, color } => {
+ crate::raw_input::RawInputEvent::HostDefaultColor {
+ kind: match kind {
+ ClientHostDefaultColorKind::Foreground => {
+ crate::terminal_theme::DefaultColorKind::Foreground
+ }
+ ClientHostDefaultColorKind::Background => {
+ crate::terminal_theme::DefaultColorKind::Background
+ }
+ },
+ color: (*color).into(),
+ }
+ }
}
}
}
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 2694912c..1af4e894 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -1,4 +1,6 @@
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
+#[cfg(windows)]
+use std::time::{Duration, Instant};
/// Parse raw terminal input bytes into a list of `RawInputEvent`s.
///
@@ -84,6 +86,11 @@ impl RawInputFramer {
}
}
+ #[cfg(windows)]
+ pub(crate) fn host_default_color_query_sent(&mut self, escape_grace: Duration) {
+ self.byte_framer.host_default_color_query_sent(escape_grace);
+ }
+
pub(crate) fn push(&mut self, data: &[u8]) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.push(data))
}
@@ -93,6 +100,21 @@ impl RawInputFramer {
self.byte_framer.has_pending_input()
}
+ #[cfg(windows)]
+ pub(crate) fn holds_host_default_color_escape(&self) -> bool {
+ self.byte_framer.buffer.as_slice() == [ESC]
+ && self.byte_framer.host_color_replies_awaited > 0
+ && self
+ .byte_framer
+ .host_default_color_query_deadline
+ .is_some_and(|deadline| Instant::now() < deadline)
+ }
+
+ #[cfg(all(test, not(windows)))]
+ pub(crate) fn holds_host_default_color_escape(&self) -> bool {
+ false
+ }
+
#[cfg(any(windows, test))]
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
self.byte_framer.has_pending_bracketed_paste()
@@ -107,9 +129,21 @@ impl RawInputFramer {
Self::events_from_chunks(self.byte_framer.flush_timeout())
}
+ #[cfg(any(windows, test))]
+ pub(crate) fn flush_keyboard_escape(&mut self) -> Vec<RawInputEvent> {
+ if self.byte_framer.buffer.as_slice() == [ESC] {
+ self.byte_framer.lone_escape_recently_flushed = true;
+ Self::events_from_chunks(vec![std::mem::take(&mut self.byte_framer.buffer)])
+ } else {
+ self.flush_timeout()
+ }
+ }
+
/// Semantic input ends mouse recovery, unlike another idle interval.
#[cfg(any(windows, test))]
pub(crate) fn flush_interrupted(&mut self) -> Vec<RawInputEvent> {
+ #[cfg(windows)]
+ self.byte_framer.flush_lone_escape_on_interruption();
let mut chunks = self.byte_framer.flush_timeout();
self.byte_framer.timed_out_mouse_prefix = None;
chunks.extend(self.byte_framer.drain_available_chunks());
@@ -144,6 +178,14 @@ pub(crate) struct RawInputByteFramer {
timed_out_mouse_prefix: Option<Vec<u8>>,
lone_escape_recently_flushed: bool,
host_color_replies_awaited: u16,
+ #[cfg(windows)]
+ host_default_color_query_issued: bool,
+ #[cfg(windows)]
+ host_default_color_query_deadline: Option<Instant>,
+ #[cfg(windows)]
+ host_color_tail_deadline: Option<Instant>,
+ #[cfg(windows)]
+ host_color_tail_split_st: bool,
host_cell_size_replies_awaited: u16,
host_appearance_reply_awaited: bool,
held_pending_host_reply_esc: bool,
@@ -181,12 +223,29 @@ impl RawInputByteFramer {
/// at its ESC introducer stitches back together instead of leaking (#549).
/// Overlapping queries each add their own replies to the window.
pub(crate) fn host_color_query_sent(&mut self) {
- self.host_color_replies_awaited = self
- .host_color_replies_awaited
- .saturating_add(HOST_COLOR_QUERY_REPLIES);
+ self.host_color_query_sent_with_replies(HOST_COLOR_QUERY_REPLIES);
+ }
+
+ #[cfg(windows)]
+ pub(crate) fn host_default_color_query_sent(&mut self, reply_window: Duration) {
+ self.host_color_query_sent_with_replies(2);
+ self.host_default_color_query_issued = true;
+ self.host_default_color_query_deadline = Some(Instant::now() + reply_window);
+ }
+
+ fn host_color_query_sent_with_replies(&mut self, replies: u16) {
+ self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_add(replies);
self.held_pending_host_reply_esc = false;
}
+ #[cfg(windows)]
+ fn flush_lone_escape_on_interruption(&mut self) {
+ if self.buffer.as_slice() == [ESC] {
+ self.held_pending_host_reply_esc = true;
+ self.host_default_color_query_deadline = None;
+ }
+ }
+
fn host_appearance_query_sent(&mut self) {
self.host_appearance_reply_awaited = true;
self.held_pending_host_reply_esc = false;
@@ -252,6 +311,16 @@ impl RawInputByteFramer {
pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> {
let mut chunks = self.drain_available_chunks();
+ #[cfg(windows)]
+ let host_color_query_expired = self
+ .host_default_color_query_deadline
+ .is_some_and(|deadline| Instant::now() >= deadline);
+ #[cfg(windows)]
+ if host_color_query_expired {
+ self.host_default_color_query_deadline = None;
+ self.host_color_replies_awaited = 0;
+ }
+
// Idle is not evidence that a mouse report has ended. The continuation
// stays bounded and is released if it cannot complete a valid report.
if self.timed_out_mouse_prefix.is_some() {
@@ -262,6 +331,10 @@ impl RawInputByteFramer {
if family == ControlStringFamily::HostReplyCsi {
return chunks;
}
+ #[cfg(windows)]
+ if self.host_color_tail_deadline.is_some() {
+ return chunks;
+ }
let keep_split_st = self.buffer.last() == Some(&ESC);
let keep_discarding = plausible_control_string_tail(family, &self.buffer);
self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len());
@@ -323,6 +396,32 @@ impl RawInputByteFramer {
return chunks;
}
+ #[cfg(windows)]
+ if self.host_default_color_query_issued
+ && is_incomplete_host_default_color_prefix(&self.buffer)
+ {
+ if self.host_default_color_query_deadline.is_some()
+ && self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES
+ {
+ return chunks;
+ }
+ if host_color_query_expired && self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES {
+ self.discard_until = Some(ControlStringFamily::Osc);
+ let split_st = self.buffer.last() == Some(&ESC);
+ self.discarded_tail_bytes = self.buffer.len() - usize::from(split_st);
+ self.host_color_tail_deadline =
+ Some(Instant::now() + HOST_COLOR_TAIL_RECOVERY_GRACE);
+ self.host_color_tail_split_st = split_st;
+ self.buffer.clear();
+ if split_st {
+ self.buffer.push(ESC);
+ }
+ return chunks;
+ }
+ self.buffer.clear();
+ return chunks;
+ }
+
if starts_with_incomplete_default_color_response(&self.buffer) {
tracing::trace!(
len = self.buffer.len(),
@@ -394,6 +493,13 @@ impl RawInputByteFramer {
}
if self.buffer.as_slice() == [ESC] {
+ #[cfg(windows)]
+ if self.host_color_replies_awaited > 0
+ && self.host_default_color_query_deadline.is_some()
+ {
+ // Native console reply fragments can arrive across several idle polls.
+ return chunks;
+ }
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");
@@ -401,6 +507,10 @@ impl RawInputByteFramer {
}
// No continuation arrived; give up the window so Escape is not delayed again.
self.host_color_replies_awaited = 0;
+ #[cfg(windows)]
+ {
+ self.host_default_color_query_deadline = None;
+ }
self.host_cell_size_replies_awaited = 0;
self.host_appearance_reply_awaited = false;
self.held_pending_host_reply_esc = false;
@@ -470,6 +580,57 @@ impl RawInputByteFramer {
}
if let Some(family) = self.discard_until {
+ #[cfg(windows)]
+ if let Some(deadline) = self.host_color_tail_deadline {
+ if self.host_color_tail_split_st
+ && self.buffer.len() > 1
+ && self.buffer[1] != b'\\'
+ {
+ self.buffer.drain(..1);
+ self.end_host_color_tail_recovery();
+ continue;
+ }
+ let terminator_len = osc_string_terminator(&self.buffer);
+ if let Some(new_escape) = self
+ .buffer
+ .windows(2)
+ .position(|pair| pair[0] == ESC && pair[1] != b'\\')
+ {
+ if terminator_len.is_none_or(|len| new_escape < len) {
+ self.buffer.drain(..new_escape);
+ self.end_host_color_tail_recovery();
+ continue;
+ }
+ }
+ let body_len = terminator_len
+ .map(|len| len - if self.buffer[len - 1] == 0x07 { 1 } else { 2 })
+ .unwrap_or(self.buffer.len());
+ if let Some(invalid) = self.buffer[..body_len]
+ .iter()
+ .position(|byte| !plausible_host_color_tail_byte(*byte))
+ {
+ self.buffer.drain(..invalid);
+ self.end_host_color_tail_recovery();
+ continue;
+ }
+ if let Some(terminator_len) = terminator_len {
+ if self.discarded_tail_bytes + terminator_len
+ <= MAX_DISCARDED_CONTROL_TAIL_BYTES
+ {
+ self.buffer.drain(..terminator_len);
+ self.end_host_color_tail_recovery();
+ continue;
+ }
+ }
+ if Instant::now() >= deadline
+ || self.discarded_tail_bytes + self.buffer.len()
+ >= MAX_DISCARDED_CONTROL_TAIL_BYTES
+ {
+ self.end_host_color_tail_recovery();
+ continue;
+ }
+ break;
+ }
if family == ControlStringFamily::HostReplyCsi {
if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes)
{
@@ -533,9 +694,42 @@ impl RawInputByteFramer {
chunks
}
+
+ #[cfg(windows)]
+ fn end_host_color_tail_recovery(&mut self) {
+ self.discard_until = None;
+ self.discarded_tail_bytes = 0;
+ self.host_color_tail_deadline = None;
+ self.host_color_tail_split_st = false;
+ }
}
const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128;
+#[cfg(windows)]
+const HOST_COLOR_TAIL_RECOVERY_GRACE: Duration = Duration::from_millis(100);
+
+#[cfg(windows)]
+fn plausible_host_color_tail_byte(byte: u8) -> bool {
+ byte.is_ascii_hexdigit()
+ || matches!(
+ byte,
+ b';' | b':'
+ | b'/'
+ | b'#'
+ | b'?'
+ | b'.'
+ | b'_'
+ | b'-'
+ | b'+'
+ | b'r'
+ | b'g'
+ | b'b'
+ | b'R'
+ | b'G'
+ | b'B'
+ | ESC
+ )
+}
fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> bool {
match family {
@@ -728,6 +922,20 @@ fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool {
) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;"))
}
+#[cfg(windows)]
+fn is_incomplete_host_default_color_prefix(buffer: &[u8]) -> bool {
+ buffer.starts_with(b"\x1b]")
+ && matches!(
+ control_string(buffer),
+ Some(ControlString::Incomplete {
+ family: ControlStringFamily::Osc
+ })
+ )
+ && [b"\x1b]10;".as_slice(), b"\x1b]11;".as_slice()]
+ .iter()
+ .any(|prefix| prefix.starts_with(buffer) || buffer.starts_with(prefix))
+}
+
fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool {
buffer.starts_with(b"\x1b[?")
&& (GHOSTTY_COLOR_SCHEME_DARK_REPORT.starts_with(buffer)
@@ -2537,6 +2745,121 @@ mod tests {
assert!(framer.flush_timeout().is_empty());
}
+ #[cfg(windows)]
+ #[test]
+ fn windows_host_color_prefix_respects_query_deadline() {
+ let mut framer = RawInputFramer::for_host_input();
+ framer.host_default_color_query_sent(Duration::from_secs(1));
+ assert!(framer.push(b"\x1b]10").is_empty());
+ for _ in 0..3 {
+ assert!(framer.flush_timeout().is_empty());
+ }
+ assert!(framer.push(b";rgb:aaaa/").is_empty());
+ for _ in 0..3 {
+ assert!(framer.flush_timeout().is_empty());
+ }
+ assert!(matches!(
+ framer.push(b"bbbb/cccc\x1b\\").as_slice(),
+ [RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Foreground,
+ ..
+ }]
+ ));
+
+ let mut expired = RawInputFramer::for_host_input();
+ expired.host_default_color_query_sent(Duration::ZERO);
+ assert!(expired.push(b"\x1b]11;").is_empty());
+ assert!(expired.flush_timeout().is_empty());
+ assert!(matches!(
+ expired.push(b"x").as_slice(),
+ [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x')
+ ));
+ assert!(matches!(
+ expired.push(b"\x1b]11;rgb:1111/2222/3333\x1b\\").as_slice(),
+ [RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Background,
+ ..
+ }]
+ ));
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_expired_host_color_reply_tail_does_not_reach_pane() {
+ let mut framer = RawInputFramer::for_host_input();
+ framer.host_default_color_query_sent(Duration::ZERO);
+ assert!(framer.push(b"\x1b]11;rgb:1111/").is_empty());
+ assert!(framer.flush_timeout().is_empty());
+ assert!(framer.push(b"2222/").is_empty());
+ for _ in 0..3 {
+ assert!(framer.flush_timeout().is_empty());
+ }
+ assert!(matches!(
+ framer.push(b"3333\x1b\\x").as_slice(),
+ [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x')
+ ));
+
+ let mut expired = RawInputFramer::for_host_input();
+ expired.host_default_color_query_sent(Duration::ZERO);
+ assert!(expired.push(b"\x1b]10;rgb:aaaa/").is_empty());
+ assert!(expired.flush_timeout().is_empty());
+ expired.byte_framer.host_color_tail_deadline = Some(Instant::now());
+ assert!(expired.flush_timeout().is_empty());
+ assert!(matches!(
+ expired.push(b"y").as_slice(),
+ [RawInputEvent::Key(key)] if key.code == KeyCode::Char('y')
+ ));
+
+ let mut raw_keyboard = RawInputFramer::for_host_input();
+ raw_keyboard.host_default_color_query_sent(Duration::ZERO);
+ assert!(raw_keyboard.push(b"\x1b]11;rgb:1111/").is_empty());
+ assert!(raw_keyboard.flush_timeout().is_empty());
+ assert!(raw_keyboard.push(b"a").is_empty());
+ raw_keyboard.byte_framer.host_color_tail_deadline = Some(Instant::now());
+ assert!(matches!(
+ raw_keyboard.flush_timeout().as_slice(),
+ [RawInputEvent::Key(key)] if key.code == KeyCode::Char('a')
+ ));
+
+ let mut split_st = RawInputFramer::for_host_input();
+ split_st.host_default_color_query_sent(Duration::ZERO);
+ assert!(split_st.push(b"\x1b]11;rgb:1111/2222/3333\x1b").is_empty());
+ assert!(split_st.flush_timeout().is_empty());
+ assert!(matches!(
+ split_st.push(b"\\x").as_slice(),
+ [RawInputEvent::Key(key)] if key.code == KeyCode::Char('x')
+ ));
+
+ let mut abandoned_st = RawInputFramer::for_host_input();
+ abandoned_st.host_default_color_query_sent(Duration::ZERO);
+ assert!(abandoned_st
+ .push(b"\x1b]11;rgb:1111/2222/3333\x1b")
+ .is_empty());
+ assert!(abandoned_st.flush_timeout().is_empty());
+ assert!(matches!(
+ abandoned_st
+ .push(b"\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\")
+ .as_slice(),
+ [RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Foreground,
+ ..
+ }]
+ ));
+
+ let mut next_reply = RawInputFramer::for_host_input();
+ next_reply.host_default_color_query_sent(Duration::ZERO);
+ assert!(next_reply.push(b"\x1b]11;rgb:1111/").is_empty());
+ assert!(next_reply.flush_timeout().is_empty());
+ assert!(next_reply.push(b"\x1b").is_empty());
+ assert!(matches!(
+ next_reply.push(b"]10;rgb:aaaa/bbbb/cccc\x1b\\").as_slice(),
+ [RawInputEvent::HostDefaultColor {
+ kind: DefaultColorKind::Foreground,
+ ..
+ }]
+ ));
+ }
+
#[test]
fn holds_lone_escape_and_stitches_split_host_color_reply() {
let mut framer = RawInputByteFramer::default();