aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx2
-rw-r--r--src/client/input.rs11
-rw-r--r--src/client/mod.rs12
-rw-r--r--src/raw_input.rs35
-rw-r--r--tests/client_mode.rs67
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 <client-pid>` 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<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
host_color_query_sent: bool,
+ host_theme_query_pending: Arc<AtomicU32>,
host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
@@ -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<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
host_color_query_sent: bool,
+ host_theme_query_pending: Arc<AtomicU32>,
host_cell_size_query_sent: bool,
host_mouse_capture_active: Arc<AtomicBool>,
host_sgr_pixels_active: Arc<AtomicBool>,
@@ -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.