From bd69871d51d683e27c2e7db905704a5964fd94eb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 26 Sep 2026 19:17:55 +0200 Subject: fix: refresh host palette on terminal redraw (#4349) * fix: refresh host palette on terminal redraw * fix: count overlapping host palette queries --------- Co-authored-by: Ogulcan Celik --- .../website/src/content/docs/configuration.mdx | 2 + src/client/input.rs | 11 +++- src/client/mod.rs | 12 +++- src/raw_input.rs | 35 ++++++++++- tests/client_mode.rs | 67 ++++++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 0d68a0f4..1a0ce53c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -248,6 +248,8 @@ name = "catppuccin" Search `theme.name` in the [Config reference](/docs/config-reference/) for every built-in theme. Use `terminal` when you want Herdr UI colors to follow your host terminal's ANSI palette. +On Unix, Herdr also re-reads the host terminal's colors when redrawing after a resize or `SIGWINCH`. If your theme switcher changes terminal colors through OSC without a light/dark notification, send `kill -WINCH ` after applying the new palette. Target the attached Herdr client, not the server; existing panes stay running. Direct `herdr terminal attach` sessions leave palette queries to the attached application. + To let Herdr switch its own UI theme when the host terminal reports a light/dark appearance change, enable theme auto-switching: ```toml diff --git a/src/client/input.rs b/src/client/input.rs index 23067576..1e308d54 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -10,7 +10,7 @@ //! - We avoid duplicating parsing logic in the client //! - Host terminal control replies can be buffered or discarded before they leak -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; #[cfg(unix)] @@ -39,6 +39,7 @@ pub fn stdin_reader_loop( event_tx: mpsc::Sender, should_quit: &Arc, host_color_query_sent: bool, + host_theme_query_pending: Arc, host_cell_size_query_sent: bool, host_mouse_capture_active: Arc, host_sgr_pixels_active: Arc, @@ -51,6 +52,7 @@ pub fn stdin_reader_loop( { let _ = ( host_color_query_sent, + host_theme_query_pending, host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, @@ -64,6 +66,7 @@ pub fn stdin_reader_loop( event_tx, should_quit, host_color_query_sent, + host_theme_query_pending, host_cell_size_query_sent, host_mouse_capture_active, host_sgr_pixels_active, @@ -79,6 +82,7 @@ fn unix_stdin_reader_loop( event_tx: mpsc::Sender, should_quit: &Arc, host_color_query_sent: bool, + host_theme_query_pending: Arc, host_cell_size_query_sent: bool, host_mouse_capture_active: Arc, host_sgr_pixels_active: Arc, @@ -178,6 +182,11 @@ fn unix_stdin_reader_loop( match reader.read(&mut scratch) { Ok(0) => break, Ok(n) => { + // A redraw can issue queries while this thread is blocked in read(). + // Arm the split-reply guard before framing the returned bytes. + for _ in 0..host_theme_query_pending.swap(0, Ordering::AcqRel) { + framer.host_color_query_sent(); + } let sgr_pixels = *pending_mode .get_or_insert_with(|| host_sgr_pixels_active.load(Ordering::Acquire)); if sgr_pixels { diff --git a/src/client/mod.rs b/src/client/mod.rs index 1a0f8dd5..2cec6d2d 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -128,7 +128,7 @@ use terminal_sessions::terminal_control_command_from_json; #[cfg(unix)] use std::collections::HashMap; use std::io::{self, Write as _}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; #[cfg(unix)] use std::sync::Mutex; @@ -509,6 +509,8 @@ 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(); + 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 // host terminal directly instead of falling back to an assumed cell size. let will_query_host_cell_size = state.attach_escape.is_none() @@ -530,6 +532,7 @@ async fn run_client_loop( stdin_tx, &stdin_quit, will_query_host_terminal_theme, + stdin_host_theme_query_pending, will_query_host_cell_size, stdin_mouse_capture_active, stdin_sgr_pixels_active, @@ -1181,6 +1184,13 @@ 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. + if will_query_host_terminal_theme { + host_theme_query_pending.fetch_add(1, Ordering::AcqRel); + query_host_terminal_theme(); + } if !pixel_geometry_exact && host_sgr_pixels_active.load(Ordering::Acquire) { set_mouse_capture(state.mouse_capture_active, false) .map_err(ClientError::ConnectionFailed)?; diff --git a/src/raw_input.rs b/src/raw_input.rs index b2c3beda..2694912c 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -179,8 +179,11 @@ impl RawInputByteFramer { /// Hold a lone trailing ESC for one idle flush so an OSC 10/11 reply split /// 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 = HOST_COLOR_QUERY_REPLIES; + self.host_color_replies_awaited = self + .host_color_replies_awaited + .saturating_add(HOST_COLOR_QUERY_REPLIES); self.held_pending_host_reply_esc = false; } @@ -2859,4 +2862,34 @@ mod tests { assert!(framer.push(b"\x1b").is_empty()); assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); } + + #[test] + fn overlapping_host_color_queries_keep_holding_split_reply_escape() { + use std::fmt::Write as _; + + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + framer.host_color_query_sent(); + let mut first_batch = + String::from("\x1b]10;rgb:6565/7b7b/8383\x1b\\\x1b]11;rgb:2424/2727/3a3a\x1b\\"); + for index in 0..=u8::MAX { + let _ = write!(first_batch, "\x1b]4;{index};rgb:1111/2222/3333\x1b\\"); + } + assert_eq!(framer.push(first_batch.as_bytes()).len(), 258); + + // The second batch is still outstanding, so its first reply split at ESC + // must not leak into the pane as an Escape key. + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let chunks = framer.push(b"]10;rgb:eeee/eeee/eeee\x1b\\"); + assert_eq!(chunks.len(), 1); + let (event, _) = extract_one_event(&chunks[0]).unwrap(); + assert!(matches!( + event, + RawInputEvent::HostDefaultColor { + kind: DefaultColorKind::Foreground, + .. + } + )); + } } diff --git a/tests/client_mode.rs b/tests/client_mode.rs index 9bcb4b2c..b5532317 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -714,6 +714,73 @@ fn read_output(output: &SharedOutput) -> String { .clone() } +#[test] +fn sigwinch_refreshes_host_palette_without_resizing() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let server = spawn_server_with_config( + &config_home, + &runtime_dir, + &api_socket, + &client_socket, + "onboarding = false\n[theme]\nname = \"terminal\"\n", + ); + wait_for_socket(&api_socket, Duration::from_secs(10)); + wait_for_socket(&client_socket, Duration::from_secs(10)); + let client = spawn_client_shell_process(&config_home, &runtime_dir, &api_socket); + let master = client._master.as_ref().expect("client PTY"); + let output = spawn_pty_drain(master.try_clone_reader().unwrap()); + let mut writer = master.take_writer().unwrap(); + let queries = "\x1b]10;?\x1b\\\x1b]11;?\x1b\\"; + assert!( + wait_until(Duration::from_secs(5), Duration::from_millis(25), || { + read_output(&output).contains(queries) + }), + "client should query the initial palette: {:?}", + read_output(&output) + ); + + // Complete the startup query with a light palette. No appearance notification + // is sent: this models a terminal whose colors are changed directly by OSC. + let mut light = + String::from("\x1b]10;rgb:0000/0000/0000\x1b\\\x1b]11;rgb:ffff/ffff/ffff\x1b\\"); + for index in 0..=u8::MAX { + light.push_str(&format!("\x1b]4;{index};rgb:ffff/ffff/ffff\x1b\\")); + } + writer.write_all(light.as_bytes()).unwrap(); + writer.flush().unwrap(); + // Let startup settle and the resize watcher install its signal handler. + thread::sleep(Duration::from_millis(250)); + + for _ in 0..2 { + let watermark = output_len(&output); + assert_eq!( + unsafe { libc::kill(client.child.process_id().unwrap() as i32, libc::SIGWINCH) }, + 0 + ); + assert!( + wait_until(Duration::from_secs(5), Duration::from_millis(25), || { + let captured = read_output(&output); + let refreshed = &captured[watermark..]; + refreshed.contains(queries) && refreshed.contains("\x1b]4;15;?\x1b\\") + }), + "SIGWINCH should query default colors and the ANSI palette without changing PTY size" + ); + writer + .write_all(b"\x1b]10;rgb:eeee/eeee/eeee\x1b\\\x1b]11;rgb:1111/2222/3333\x1b\\") + .unwrap(); + writer.flush().unwrap(); + } + + drop(writer); + drop(client); + cleanup_spawned_herdr(server, base); +} + /// Current captured byte length, used as a watermark so a test can search only /// the output emitted *after* a trigger. The teardown markers also appear in /// normal attach-phase output, so matching the whole buffer is meaningless. -- cgit v1.2.3