feat: make focus-gain redraw configurable

refs #282
This commit is contained in:
Ogulcan Celik 2026-05-25 18:39:44 +03:00
parent ac9dac0263
commit 58bcd76bfd
11 changed files with 182 additions and 29 deletions

View File

@ -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

View File

@ -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.

View File

@ -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();
@ -1500,6 +1505,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();
@ -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,
@ -2073,6 +2091,20 @@ mod tests {
assert!(!app.state.workspaces[0].tabs[background_tab].panes[&background_pane].seen);
}
#[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();

View File

@ -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

View File

@ -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,

View File

@ -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");
}
}
}
@ -1631,6 +1647,29 @@ mod tests {
assert_eq!(sound_from_notify_message("toast"), None);
}
#[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();

View File

@ -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,
@ -669,6 +672,19 @@ mouse_capture = false
assert!(!config.ui.mouse_capture);
}
#[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();

View File

@ -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

View File

@ -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.

View File

@ -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]

View File

@ -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();
@ -4286,6 +4288,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();
@ -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]