aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-25 18:39:44 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-25 18:42:33 +0300
commit58bcd76bfdb76ecbb8cfee868dabe60b43f16cc6 (patch)
treedad4b122401b87c9bc9b8e5b46b854c9534fc983
parentac9dac0263ba2336710e8fc350600ff8674ebf99 (diff)
feat: make focus-gain redraw configurable
refs #282
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx3
-rw-r--r--src/app/mod.rs38
-rw-r--r--src/app/runtime.rs4
-rw-r--r--src/app/state.rs6
-rw-r--r--src/client/mod.rs49
-rw-r--r--src/config/model.rs16
-rw-r--r--src/main.rs5
-rw-r--r--src/protocol/wire.rs2
-rw-r--r--src/raw_input.rs17
-rw-r--r--src/server/headless.rs70
11 files changed, 182 insertions, 29 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index a58d4f61..a6b08e93 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- Added a session navigator at `prefix+g` with a searchable workspace/tab/pane tree, agent state filters, mouse switching, and keyboard navigation. (#157)
- Added scrollback support to direct agent terminal attaches. Mouse wheel and plain PageUp/PageDown now scroll the attached terminal viewport, while terminal apps that request mouse or alternate-scroll input still receive those events. The client/server protocol is now version 11.
+- Added `ui.redraw_on_focus_gained` to keep the existing full redraw on outer-terminal focus gain by default while allowing users to opt out of the visible refresh. (#282)
## [0.6.2] - 2026-05-23
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 470e2709..1e30fbf3 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -222,6 +222,7 @@ sidebar_width = 32
sidebar_min_width = 18
sidebar_max_width = 36
mouse_capture = true
+redraw_on_focus_gained = true
mouse_scroll_lines = 3
confirm_close = true
prompt_new_tab_name = true
@@ -238,6 +239,8 @@ accent = "cyan"
Set `mouse_capture = false` if you want your terminal to handle normal clicks, such as command-clicking URLs.
+Set `redraw_on_focus_gained = false` to avoid the visible full-screen refresh when switching back to Herdr. The default is `true` because a full redraw recovers from rare stale or dirty host terminal surfaces.
+
Set `mouse_scroll_lines` to change how many pane scrollback lines each mouse wheel notch scrolls. The default is 3. Pane apps that request mouse reporting still receive wheel events directly.
Set `show_agent_labels_on_pane_borders = true` if you want detected agent labels in split pane borders when no manual pane label is set.
diff --git a/src/app/mod.rs b/src/app/mod.rs
index bcbd40cf..0b4c792c 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -386,7 +386,7 @@ impl App {
request_submit_worktree_open: false,
request_submit_worktree_remove: false,
request_reload_config: false,
- request_client_sound_config_reload: false,
+ request_client_config_reload: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
@@ -458,6 +458,7 @@ impl App {
sidebar_section_split,
agent_panel_scope,
mouse_capture: config.ui.mouse_capture,
+ redraw_on_focus_gained: config.ui.redraw_on_focus_gained,
mouse_scroll_lines: config.ui.mouse_scroll_lines(),
confirm_close: config.ui.confirm_close,
prompt_new_tab_name: config.ui.prompt_new_tab_name,
@@ -1055,6 +1056,10 @@ impl App {
.sidebar_width
.clamp(self.state.sidebar_min_width, self.state.sidebar_max_width);
self.state.mouse_capture = config.ui.mouse_capture;
+ if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained {
+ self.state.request_client_config_reload = true;
+ }
+ self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained;
self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines();
self.state.confirm_close = config.ui.confirm_close;
self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
@@ -1065,7 +1070,7 @@ impl App {
self.state.agent_panel_scroll = 0;
self.state.accent = crate::config::parse_color(&config.ui.accent);
if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
- self.state.request_client_sound_config_reload = true;
+ self.state.request_client_config_reload = true;
}
self.state.sound = config.ui.sound.clone();
self.state.toast_config = config.ui.toast.clone();
@@ -1501,6 +1506,17 @@ mod tests {
}
#[test]
+ fn startup_uses_redraw_on_focus_gained_config() {
+ let mut config = Config::default();
+ config.ui.redraw_on_focus_gained = false;
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+
+ let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default());
+
+ assert!(!app.state.redraw_on_focus_gained);
+ }
+
+ #[test]
fn startup_restores_preview_update_available_from_saved_notes() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("startup-preview-update-available");
@@ -1610,7 +1626,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
+ "[terminal]\ndefault_shell = \"nu\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1634,6 +1650,8 @@ mod tests {
app.state.agent_panel_scope,
state::AgentPanelScope::CurrentWorkspace
);
+ assert!(!app.state.redraw_on_focus_gained);
+ assert!(app.state.request_client_config_reload);
assert_eq!(app.state.default_shell, "nu");
assert_eq!(
app.state.new_terminal_cwd,
@@ -2074,6 +2092,20 @@ mod tests {
}
#[tokio::test]
+ async fn outer_focus_gained_does_not_require_full_redraw_when_disabled() {
+ let mut app = test_app();
+ app.state.redraw_on_focus_gained = false;
+
+ let handled = app
+ .handle_raw_input_event(crate::raw_input::RawInputEvent::OuterFocusGained)
+ .await;
+
+ assert!(handled);
+ assert_eq!(app.state.outer_terminal_focus, Some(true));
+ assert!(!app.full_redraw_pending);
+ }
+
+ #[tokio::test]
async fn repeat_key_events_are_ignored_outside_terminal_mode() {
let mut app = test_app();
app.state.mode = Mode::ReleaseNotes;
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 284fb882..85bdb8dd 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -106,7 +106,9 @@ impl App {
true
}
crate::raw_input::RawInputEvent::OuterFocusGained => {
- self.request_full_redraw();
+ if self.state.redraw_on_focus_gained {
+ self.request_full_redraw();
+ }
self.state.outer_terminal_focus = Some(true);
self.state.mark_active_tab_seen();
true
diff --git a/src/app/state.rs b/src/app/state.rs
index 422664ff..b22dc6a3 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1057,7 +1057,7 @@ pub struct AppState {
pub request_reload_config: bool,
/// Set when the headless server should ask attached clients to reload
/// their client-local sound config from disk.
- pub request_client_sound_config_reload: bool,
+ pub request_client_config_reload: bool,
/// Set when UI interaction requested a clipboard write that must be
/// handled by the outer App/event loop instead of directly from AppState.
pub request_clipboard_write: Option<Vec<u8>>,
@@ -1115,6 +1115,7 @@ pub struct AppState {
/// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
/// captures mouse while the focused pane app requests mouse reporting.
pub mouse_capture: bool,
+ pub redraw_on_focus_gained: bool,
pub mouse_scroll_lines: usize,
pub confirm_close: bool,
pub prompt_new_tab_name: bool,
@@ -1351,7 +1352,7 @@ impl AppState {
request_submit_worktree_open: false,
request_submit_worktree_remove: false,
request_reload_config: false,
- request_client_sound_config_reload: false,
+ request_client_config_reload: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
@@ -1414,6 +1415,7 @@ impl AppState {
sidebar_section_split: 0.5,
agent_panel_scope: AgentPanelScope::AllWorkspaces,
mouse_capture: true,
+ redraw_on_focus_gained: true,
mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES,
confirm_close: true,
prompt_new_tab_name: true,
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 5dd2652f..3e146f47 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -59,6 +59,8 @@ struct ClientState {
attach_escape: Option<AttachEscapeState>,
/// Rows scrolled for one direct-attach wheel notch.
mouse_scroll_lines: usize,
+ /// Whether outer focus gain should force a full host-terminal redraw.
+ redraw_on_focus_gained: bool,
}
#[derive(Debug, Default)]
@@ -514,6 +516,7 @@ fn run_client_with_mode(
let loaded_config = crate::config::Config::load();
let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines();
+ let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained;
let sound_config = loaded_config.config.ui.sound;
let direct_attach_requested = attach_request.is_some();
let kitty_graphics_enabled =
@@ -608,6 +611,7 @@ fn run_client_with_mode(
should_quit,
sound_config,
mouse_scroll_lines,
+ redraw_on_focus_gained,
kitty_graphics_enabled,
false,
negotiated_encoding,
@@ -655,6 +659,7 @@ async fn run_client_loop(
should_quit: Arc<AtomicBool>,
sound_config: crate::config::SoundConfig,
mouse_scroll_lines: usize,
+ redraw_on_focus_gained: bool,
kitty_graphics_enabled: bool,
mouse_capture_active: bool,
negotiated_encoding: RenderEncoding,
@@ -668,6 +673,7 @@ async fn run_client_loop(
kitty_graphics_enabled,
attach_escape,
mouse_scroll_lines,
+ redraw_on_focus_gained,
};
debug!(?negotiated_encoding, "client render encoding active");
@@ -763,7 +769,10 @@ async fn run_client_loop(
}
} else {
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
- if crate::raw_input::events_require_host_surface_redraw(&events) {
+ if crate::raw_input::events_require_host_surface_redraw(
+ &events,
+ state.redraw_on_focus_gained,
+ ) {
state.request_full_redraw();
}
data
@@ -854,7 +863,10 @@ async fn run_client_loop(
let _ = io::stdout().flush();
}
ServerMessage::ReloadSoundConfig => {
- reload_local_sound_config(&mut state.sound_config);
+ reload_local_client_config(
+ &mut state.sound_config,
+ &mut state.redraw_on_focus_gained,
+ );
}
ServerMessage::MouseCapture { enabled } => {
let desired = enabled;
@@ -955,17 +967,21 @@ fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<(
// Notifications
// ---------------------------------------------------------------------------
-fn reload_local_sound_config(sound_config: &mut crate::config::SoundConfig) {
+fn reload_local_client_config(
+ sound_config: &mut crate::config::SoundConfig,
+ redraw_on_focus_gained: &mut bool,
+) {
match crate::config::load_live_config() {
Ok(loaded) => {
for diagnostic in loaded.config.ui.sound.diagnostics() {
warn!(diagnostic = %diagnostic, "local sound config diagnostic");
}
*sound_config = loaded.config.ui.sound;
- debug!("reloaded local sound config");
+ *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained;
+ debug!("reloaded local client config");
}
Err(diagnostics) => {
- warn!(diagnostics = ?diagnostics, "failed to reload local sound config; keeping current sound config");
+ warn!(diagnostics = ?diagnostics, "failed to reload local client config; keeping current client config");
}
}
}
@@ -1632,6 +1648,29 @@ mod tests {
}
#[test]
+ fn reload_local_client_config_refreshes_redraw_on_focus_gained() {
+ let _guard = crate::config::test_config_env_lock().lock().unwrap();
+ let path = std::env::temp_dir().join(format!(
+ "herdr-client-config-reload-{}-{}.toml",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ std::fs::write(&path, "[ui]\nredraw_on_focus_gained = false\n").unwrap();
+ let path_string = path.to_string_lossy().to_string();
+ let _env = EnvVarGuard::set(crate::config::CONFIG_PATH_ENV_VAR, &path_string);
+ let mut sound_config = crate::config::SoundConfig::default();
+ let mut redraw_on_focus_gained = true;
+
+ reload_local_client_config(&mut sound_config, &mut redraw_on_focus_gained);
+
+ assert!(!redraw_on_focus_gained);
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
fn toast_notify_from_server_is_emitted_even_when_attach_config_was_off() {
let sound_config = crate::config::SoundConfig::default();
let mut emitted = None;
diff --git a/src/config/model.rs b/src/config/model.rs
index 35b08582..7434c924 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -259,6 +259,8 @@ pub struct UiConfig {
pub sidebar_max_width: u16,
/// Capture mouse input for Herdr's mouse UI. Default: true.
pub mouse_capture: bool,
+ /// Force a full host-terminal redraw when the outer terminal regains focus. Default: true.
+ pub redraw_on_focus_gained: bool,
/// Lines to scroll per mouse wheel notch. Default: 3.
pub mouse_scroll_lines: Option<NonZeroUsize>,
/// Ask for confirmation before closing a workspace. Default: true.
@@ -416,6 +418,7 @@ impl Default for UiConfig {
sidebar_min_width: 18,
sidebar_max_width: 36,
mouse_capture: true,
+ redraw_on_focus_gained: true,
mouse_scroll_lines: None,
confirm_close: true,
prompt_new_tab_name: true,
@@ -670,6 +673,19 @@ mouse_capture = false
}
#[test]
+ fn redraw_on_focus_gained_default_on_and_parse() {
+ let default_config = Config::default();
+ assert!(default_config.ui.redraw_on_focus_gained);
+
+ let toml = r#"
+[ui]
+redraw_on_focus_gained = false
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert!(!config.ui.redraw_on_focus_gained);
+ }
+
+ #[test]
fn mouse_scroll_lines_defaults_to_three_and_parses() {
let default_config = Config::default();
assert_eq!(
diff --git a/src/main.rs b/src/main.rs
index 725aed45..9a33555e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -182,6 +182,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Pane apps like lazygit and btop can still receive mouse when they request it.
# mouse_capture = true
+# Force a full redraw when the outer terminal regains focus.
+# Set false to reduce visible flashing when switching back to Herdr.
+# Trade-off: rare host terminal surface corruption may persist until the next full redraw.
+# redraw_on_focus_gained = true
+
# Pane scrollback lines to scroll per mouse wheel notch.
# mouse_scroll_lines = 3
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index bd79c900..e6c5624c 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -370,7 +370,7 @@ pub enum ServerMessage {
data: String,
},
- /// Client-local sound config changed on disk; refresh it without reconnecting.
+ /// Client-local runtime config changed on disk; refresh it without reconnecting.
ReloadSoundConfig,
/// Whether the client should currently capture host mouse input.
diff --git a/src/raw_input.rs b/src/raw_input.rs
index d75c6c49..fb9a1a3d 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -128,10 +128,14 @@ pub enum RawInputEvent {
Unsupported,
}
-pub(crate) fn events_require_host_surface_redraw(events: &[RawInputEvent]) -> bool {
- events
- .iter()
- .any(|event| matches!(event, RawInputEvent::OuterFocusGained))
+pub(crate) fn events_require_host_surface_redraw(
+ events: &[RawInputEvent],
+ redraw_on_focus_gained: bool,
+) -> bool {
+ redraw_on_focus_gained
+ && events
+ .iter()
+ .any(|event| matches!(event, RawInputEvent::OuterFocusGained))
}
pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
@@ -667,10 +671,11 @@ mod tests {
#[test]
fn outer_focus_gained_requests_host_surface_redraw() {
let events = parse_raw_input_bytes_sync(b"\x1b[I");
- assert!(events_require_host_surface_redraw(&events));
+ assert!(events_require_host_surface_redraw(&events, true));
+ assert!(!events_require_host_surface_redraw(&events, false));
let events = parse_raw_input_bytes_sync(b"\x1b[O");
- assert!(!events_require_host_surface_redraw(&events));
+ assert!(!events_require_host_surface_redraw(&events, true));
}
#[test]
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 4dafb6d7..bde4d362 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -406,7 +406,7 @@ impl HeadlessServer {
needs_render = true;
}
- self.drain_client_sound_config_reload_request();
+ self.drain_client_config_reload_request();
self.stream_host_mouse_capture_mode();
self.app.sync_headless_animation_timer(now);
@@ -1423,11 +1423,11 @@ impl HeadlessServer {
(had_event, changed)
}
- fn drain_client_sound_config_reload_request(&mut self) {
- if !self.app.state.request_client_sound_config_reload {
+ fn drain_client_config_reload_request(&mut self) {
+ if !self.app.state.request_client_config_reload {
return;
}
- self.app.state.request_client_sound_config_reload = false;
+ self.app.state.request_client_config_reload = false;
self.send_to_all_clients(ServerMessage::ReloadSoundConfig);
}
@@ -1726,8 +1726,10 @@ impl HeadlessServer {
return true;
}
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
- let host_surface_redraw =
- crate::raw_input::events_require_host_surface_redraw(&events);
+ let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
+ &events,
+ self.app.state.redraw_on_focus_gained,
+ );
if let Some(client) = self.clients.get_mut(&client_id) {
if host_surface_redraw {
client.request_full_redraw();
@@ -4287,6 +4289,52 @@ next_tab = ""
}
#[test]
+ fn outer_focus_gained_does_not_force_terminal_ansi_full_redraw_when_disabled() {
+ let mut server = test_headless_server();
+ server.app.state.redraw_on_focus_gained = false;
+ let (client_tx, _client_control_rx, client_rx) = test_client_writer();
+
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 1,
+ RenderEncoding::TerminalAnsi,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(1);
+
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial terminal frame");
+
+ server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\x1b[I".to_vec(),
+ });
+ server.render_and_stream();
+
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+ assert_eq!(server.clients[&1].outer_terminal_focus, Some(true));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(true));
+ assert_eq!(
+ server
+ .clients
+ .get(&1)
+ .unwrap()
+ .render_state
+ .terminal_seq()
+ .unwrap(),
+ 1
+ );
+ }
+
+ #[test]
fn full_render_queue_does_not_advance_terminal_ansi_baseline() {
let mut server = test_headless_server();
let (client_tx, _client_control_rx, client_rx) = test_client_writer();
@@ -4420,7 +4468,7 @@ next_tab = ""
}
#[test]
- fn client_sound_reload_request_refreshes_attached_clients() {
+ fn client_config_reload_request_refreshes_attached_clients() {
let mut server = test_headless_server();
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
@@ -4436,19 +4484,19 @@ next_tab = ""
Some(client_tx),
),
);
- server.app.state.request_client_sound_config_reload = true;
+ server.app.state.request_client_config_reload = true;
- server.drain_client_sound_config_reload_request();
+ server.drain_client_config_reload_request();
match read_server_message(
client_control_rx
.recv_timeout(Duration::from_millis(100))
- .expect("client sound reload message"),
+ .expect("client config reload message"),
) {
ServerMessage::ReloadSoundConfig => {}
other => panic!("expected ReloadSoundConfig, got {other:?}"),
}
- assert!(!server.app.state.request_client_sound_config_reload);
+ assert!(!server.app.state.request_client_config_reload);
}
#[test]