diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 188d1237..28a26223 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -287,7 +287,7 @@ mouse_scroll_lines = 3 confirm_close = true prompt_new_tab_name = true show_agent_labels_on_pane_borders = false -agent_panel_scope = "all" +agent_panel_sort = "spaces" accent = "cyan" ``` @@ -295,7 +295,7 @@ accent = "cyan" `mobile_width_threshold` controls the terminal width at or below which Herdr uses the mobile single-column layout. The default is 64 columns; increase it for foldables, tablets, or wide phone terminals. -`agent_panel_scope` can be `all` or `current`. Use `current` if you only want the agent panel to show agents in the active workspace. +The agent panel shows all agents across all spaces. `agent_panel_sort` can be `spaces` or `priority`; `workspaces` is accepted as an alias for `spaces`. `spaces` keeps agents grouped by space order. `priority` sorts by attention priority: blocked, done, working, idle, then unknown. Within the same status, agents that most recently changed state appear first. `confirm_close` controls whether closing a workspace asks for confirmation. `prompt_new_tab_name` controls whether new tabs ask for a label first. diff --git a/src/app/actions.rs b/src/app/actions.rs index 9df07af5..1e0f0f5e 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -951,12 +951,6 @@ impl AppState { let workspace_id = self.workspaces[idx].id.clone(); crate::logging::workspace_focused(&workspace_id); self.mark_session_dirty(); - if matches!( - self.agent_panel_scope, - crate::app::state::AgentPanelScope::CurrentWorkspace - ) { - self.agent_panel_scroll = 0; - } self.ensure_workspace_visible(idx); if let Some(ws) = self.workspaces.get_mut(idx) { let active_tab = ws.active_tab; @@ -994,14 +988,6 @@ impl AppState { crate::logging::workspace_focused(&workspace_id); } self.mark_session_dirty(); - if workspace_changed - && matches!( - self.agent_panel_scope, - crate::app::state::AgentPanelScope::CurrentWorkspace - ) - { - self.agent_panel_scroll = 0; - } self.ensure_workspace_visible(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); @@ -2587,6 +2573,12 @@ impl AppState { self.mark_session_dirty(); } let change = mutation.effective_state_change?; + if change.previous_state != change.state { + self.next_agent_state_change_seq += 1; + if let Some(terminal) = self.terminals.get_mut(&terminal_id) { + terminal.last_agent_state_change_seq = Some(self.next_agent_state_change_seq); + } + } let seen = self.apply_pane_state_change(ws_idx, pane_id, &change)?; let update = PaneStateUpdate { pane_id, @@ -3542,6 +3534,16 @@ mod tests { } fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) { + set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle); + } + + fn set_agent_state( + state: &mut AppState, + ws_idx: usize, + tab_idx: usize, + pane_id: PaneId, + agent_state: AgentState, + ) { state.ensure_test_terminals(); let terminal_id = state.workspaces[ws_idx].tabs[tab_idx] .panes @@ -3550,12 +3552,28 @@ mod tests { .attached_terminal_id .clone(); if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Pi), agent_state); } } + fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) { + state + .update_terminal_state(pane_id, |terminal| { + Some(terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + agent_state, + matches!(agent_state, AgentState::Blocked), + false, + false, + false, + std::time::Instant::now(), + )) + }) + .expect("agent state transition should update pane state"); + } + #[test] - fn next_agent_cycles_agent_panel_entries_in_all_scope() { + fn next_agent_cycles_agent_panel_entries() { let mut first = Workspace::test_new("one"); let first_root = first.tabs[0].root_pane; let first_second = first.test_split(Direction::Horizontal); @@ -3569,7 +3587,6 @@ mod tests { state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; - state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces; mark_agent(&mut state, 0, 0, first_root); mark_agent(&mut state, 0, 0, first_second); mark_agent(&mut state, 1, 0, second_root); @@ -3602,7 +3619,6 @@ mod tests { state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; - state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces; mark_agent(&mut state, 0, 0, first_root); mark_agent(&mut state, 0, 0, first_second); mark_agent(&mut state, 1, 0, second_root); @@ -3618,7 +3634,6 @@ mod tests { fn focus_agent_entry_succeeds_for_already_focused_agent() { let mut state = app_with_workspaces(&["one"]); let root = state.workspaces[0].tabs[0].root_pane; - state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces; mark_agent(&mut state, 0, 0, root); assert!(state.focus_agent_entry(0)); @@ -3628,11 +3643,11 @@ mod tests { } #[test] - fn next_agent_cycles_only_current_scope_entries() { + fn next_agent_cycles_priority_sorted_agent_panel_entries() { let mut first = Workspace::test_new("one"); let first_root = first.tabs[0].root_pane; let first_second = first.test_split(Direction::Horizontal); - first.tabs[0].layout.focus_pane(first_second); + first.tabs[0].layout.focus_pane(first_root); let second = Workspace::test_new("two"); let second_root = second.tabs[0].root_pane; @@ -3642,15 +3657,40 @@ mod tests { state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; - state.agent_panel_scope = crate::app::state::AgentPanelScope::CurrentWorkspace; - mark_agent(&mut state, 0, 0, first_root); - mark_agent(&mut state, 0, 0, first_second); - mark_agent(&mut state, 1, 0, second_root); + state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; + set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle); + set_agent_state(&mut state, 0, 0, first_second, AgentState::Working); + set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked); state.next_agent(); - assert_eq!(state.active, Some(0)); - assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); + state.assert_invariants_for_test(); + } + + #[test] + fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() { + let mut workspace = Workspace::test_new("one"); + let first = workspace.tabs[0].root_pane; + let second = workspace.test_split(Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(first); + + let mut state = AppState::test_new(); + state.workspaces = vec![workspace]; + state.ensure_test_terminals(); + state.active = Some(0); + state.selected = 0; + state.mode = Mode::Terminal; + state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; + + transition_agent_state(&mut state, first, AgentState::Idle); + transition_agent_state(&mut state, second, AgentState::Working); + assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); + + transition_agent_state(&mut state, second, AgentState::Idle); + + assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second); state.assert_invariants_for_test(); } @@ -3668,7 +3708,6 @@ mod tests { state.active = Some(0); state.selected = 0; state.mode = Mode::Terminal; - state.agent_panel_scope = crate::app::state::AgentPanelScope::CurrentWorkspace; for tab_idx in 0..state.workspaces[0].tabs.len() { let pane_id = state.workspaces[0].tabs[tab_idx].root_pane; mark_agent(&mut state, 0, tab_idx, pane_id); diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 804a26f6..7a196008 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -107,20 +107,20 @@ impl App { } } - pub(super) fn save_agent_panel_scope(&mut self, scope: crate::app::state::AgentPanelScope) { - let value = match scope { - crate::app::state::AgentPanelScope::CurrentWorkspace => { - crate::config::AgentPanelScopeConfig::Current.as_str() + pub(super) fn save_agent_panel_sort(&mut self, sort: crate::app::state::AgentPanelSort) { + let value = match sort { + crate::app::state::AgentPanelSort::Spaces => { + crate::config::AgentPanelSortConfig::Spaces.as_str() } - crate::app::state::AgentPanelScope::AllWorkspaces => { - crate::config::AgentPanelScopeConfig::All.as_str() + crate::app::state::AgentPanelSort::Priority => { + crate::config::AgentPanelSortConfig::Priority.as_str() } }; - if self.update_config_file("agent panel scope", |content| { + if self.update_config_file("agent panel sort", |content| { crate::config::upsert_section_value( content, "ui", - "agent_panel_scope", + "agent_panel_sort", &format!("\"{value}\""), ) }) { diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index cb4b0d46..5760c436 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -256,7 +256,7 @@ impl App { let handled_pane_double_click = self.handle_pane_double_click(mouse); - let previous_agent_panel_scope = self.state.agent_panel_scope; + let previous_agent_panel_sort = self.state.agent_panel_sort; let previous_settings_section = self.state.settings.section; if !handled_pane_double_click { if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { @@ -286,8 +286,8 @@ impl App { { self.refresh_integration_recommendations(); } - if self.state.agent_panel_scope != previous_agent_panel_scope { - self.save_agent_panel_scope(self.state.agent_panel_scope); + if self.state.agent_panel_sort != previous_agent_panel_sort { + self.save_agent_panel_sort(self.state.agent_panel_sort); } if let Some(content) = self.state.request_clipboard_write.take() { @@ -590,7 +590,6 @@ fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot { &terminal_runtimes, state.active, state.selected, - state.agent_panel_scope, state.sidebar_width, state.sidebar_section_split, state.collapsed_space_keys.clone(), diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 2e2a94aa..41bb5267 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -5,7 +5,7 @@ use tracing::warn; use crate::{ app::state::{ - AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, + AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout, WorkspacePressState, }, @@ -540,10 +540,10 @@ impl AppState { return None; } - if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) { - self.agent_panel_scope = match self.agent_panel_scope { - AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces, - AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace, + if self.on_agent_panel_sort_toggle(mouse.column, mouse.row) { + self.agent_panel_sort = match self.agent_panel_sort { + AgentPanelSort::Spaces => AgentPanelSort::Priority, + AgentPanelSort::Priority => AgentPanelSort::Spaces, }; self.agent_panel_scroll = 0; self.mark_session_dirty(); diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 3ab5f8f5..fd631cd4 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -432,7 +432,7 @@ impl AppState { best.map(|(insert_idx, _)| insert_idx) } - pub(super) fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool { + pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool { if self.sidebar_collapsed { return false; } @@ -441,7 +441,7 @@ impl AppState { self.view.sidebar_rect, self.sidebar_section_split, ); - let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope); + let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_sort); rect.width > 0 && col >= rect.x && col < rect.x + rect.width @@ -496,7 +496,7 @@ mod tests { use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; use crate::{ - app::state::{AgentPanelScope, DragTarget, Mode}, + app::state::{AgentPanelSort, DragTarget, Mode}, detect::Agent, workspace::Workspace, }; @@ -711,7 +711,7 @@ mod tests { } #[test] - fn clicking_agent_panel_toggle_switches_scope() { + fn clicking_agent_panel_toggle_switches_sort() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); @@ -723,23 +723,15 @@ mod tests { app.state.view.sidebar_rect, app.state.sidebar_section_split, ); - let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope); + let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_sort); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), toggle.x, toggle.y, )); - assert_eq!( - app.state.agent_panel_scope, - AgentPanelScope::CurrentWorkspace - ); + assert_eq!(app.state.agent_panel_sort, AgentPanelSort::Priority); assert_eq!(app.state.agent_panel_scroll, 0); - let snapshot = capture_snapshot(&app.state); - assert_eq!( - snapshot.agent_panel_scope, - AgentPanelScope::CurrentWorkspace - ); } #[test] @@ -772,7 +764,6 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; - app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces; let (_, detail_area) = crate::ui::expanded_sidebar_sections( app.state.view.sidebar_rect, diff --git a/src/app/mod.rs b/src/app/mod.rs index 1b90f157..ff1581cf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -204,12 +204,12 @@ fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginR .collect() } -fn agent_panel_scope_from_config( - scope: crate::config::AgentPanelScopeConfig, -) -> state::AgentPanelScope { - match scope { - crate::config::AgentPanelScopeConfig::Current => state::AgentPanelScope::CurrentWorkspace, - crate::config::AgentPanelScopeConfig::All => state::AgentPanelScope::AllWorkspaces, +fn agent_panel_sort_from_config( + sort: crate::config::AgentPanelSortConfig, +) -> state::AgentPanelSort { + match sort { + crate::config::AgentPanelSortConfig::Spaces => state::AgentPanelSort::Spaces, + crate::config::AgentPanelSortConfig::Priority => state::AgentPanelSort::Priority, } } @@ -289,7 +289,6 @@ impl App { workspaces, active, selected, - _restored_agent_panel_scope, sidebar_width, sidebar_width_source, sidebar_section_split, @@ -299,7 +298,6 @@ impl App { Vec::new(), None, 0, - state::AgentPanelScope::CurrentWorkspace, config.ui.sidebar_width, state::SidebarWidthSource::ConfigDefault, 0.5_f32, @@ -332,7 +330,6 @@ impl App { Vec::new(), None, 0, - snap.agent_panel_scope, snap.sidebar_width.unwrap_or(config.ui.sidebar_width), if snap.sidebar_width.is_some() { state::SidebarWidthSource::Persisted @@ -350,7 +347,6 @@ impl App { ws, active, selected, - snap.agent_panel_scope, snap.sidebar_width.unwrap_or(config.ui.sidebar_width), if snap.sidebar_width.is_some() { state::SidebarWidthSource::Persisted @@ -366,7 +362,6 @@ impl App { Vec::new(), None, 0, - state::AgentPanelScope::CurrentWorkspace, config.ui.sidebar_width, state::SidebarWidthSource::ConfigDefault, 0.5_f32, @@ -374,7 +369,7 @@ impl App { ) }; - let agent_panel_scope = agent_panel_scope_from_config(config.ui.agent_panel_scope); + let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); // Validate sidebar bounds before they reach any `u16::clamp(min, max)` // call: `clamp` panics when `min > max`. On bad config, fall back to @@ -519,7 +514,8 @@ impl App { sidebar_width_auto: false, sidebar_collapsed: false, sidebar_section_split, - agent_panel_scope, + agent_panel_sort, + next_agent_state_change_seq: 0, mouse_capture: config.ui.mouse_capture, right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), right_click_passthrough: None, @@ -693,7 +689,6 @@ impl App { app.state.selected = snapshot .selected .min(app.state.workspaces.len().saturating_sub(1)); - app.state.agent_panel_scope = snapshot.agent_panel_scope; if let Some(width) = snapshot.sidebar_width { app.state.sidebar_width = width; app.state.sidebar_width_source = state::SidebarWidthSource::Persisted; @@ -1246,8 +1241,8 @@ impl App { self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name; self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; - self.state.agent_panel_scope = - agent_panel_scope_from_config(config.ui.agent_panel_scope); + self.state.agent_panel_sort = + agent_panel_sort_from_config(config.ui.agent_panel_sort); 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 { @@ -2065,17 +2060,14 @@ mod tests { } #[test] - fn startup_uses_configured_agent_panel_scope() { + fn startup_uses_configured_agent_panel_sort() { let mut config = Config::default(); - config.ui.agent_panel_scope = crate::config::AgentPanelScopeConfig::Current; + config.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); - assert_eq!( - app.state.agent_panel_scope, - state::AgentPanelScope::CurrentWorkspace - ); + assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); } #[test] @@ -2199,7 +2191,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( &path, - "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); @@ -2219,10 +2211,7 @@ mod tests { app.state.toast_config.delivery, crate::config::ToastDelivery::Herdr ); - assert_eq!( - app.state.agent_panel_scope, - state::AgentPanelScope::CurrentWorkspace - ); + assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); assert!(!app.state.redraw_on_focus_gained); assert_eq!( app.state.right_click_passthrough_modifiers, @@ -2561,27 +2550,21 @@ mod tests { } #[test] - fn save_agent_panel_scope_persists_then_applies_live_config() { + fn save_agent_panel_sort_persists_then_applies_live_config() { let _guard = config_env_lock().lock().unwrap(); - let path = temp_config_path("save-agent-panel-scope"); + let path = temp_config_path("save-agent-panel-sort"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "onboarding = false\n").unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - assert_eq!( - app.state.agent_panel_scope, - state::AgentPanelScope::AllWorkspaces - ); + assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Spaces); - app.save_agent_panel_scope(state::AgentPanelScope::CurrentWorkspace); + app.save_agent_panel_sort(state::AgentPanelSort::Priority); - assert_eq!( - app.state.agent_panel_scope, - state::AgentPanelScope::CurrentWorkspace - ); + assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("agent_panel_scope = \"current\"")); + assert!(content.contains("agent_panel_sort = \"priority\"")); assert!(app.state.config_diagnostic.is_none()); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index cb2e5712..815701b7 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -336,18 +336,10 @@ impl App { } fn agent_panel_has_animation(&self) -> bool { - match self.state.agent_panel_scope { - crate::app::state::AgentPanelScope::CurrentWorkspace => self - .state - .active - .and_then(|idx| self.state.workspaces.get(idx)) - .is_some_and(|ws| ws.has_working_pane(&self.state.terminals)), - crate::app::state::AgentPanelScope::AllWorkspaces => self - .state - .workspaces - .iter() - .any(|ws| ws.has_working_pane(&self.state.terminals)), - } + self.state + .workspaces + .iter() + .any(|ws| ws.has_working_pane(&self.state.terminals)) } pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) { diff --git a/src/app/session.rs b/src/app/session.rs index f9b38e79..5e174507 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -31,7 +31,6 @@ impl App { &self.terminal_runtimes, self.state.active, self.state.selected, - self.state.agent_panel_scope, self.state.sidebar_width, self.state.sidebar_section_split, self.state.collapsed_space_keys.clone(), diff --git a/src/app/state.rs b/src/app/state.rs index 089834d8..585bb3e4 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -832,11 +832,11 @@ pub(crate) enum CopyModeSelection { Linewise { anchor_row: u32 }, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum AgentPanelScope { - CurrentWorkspace, +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AgentPanelSort { #[default] - AllWorkspaces, + Spaces, + Priority, } // --------------------------------------------------------------------------- @@ -1340,7 +1340,8 @@ pub struct AppState { pub sidebar_collapsed: bool, /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, - pub agent_panel_scope: AgentPanelScope, + pub agent_panel_sort: AgentPanelSort, + pub next_agent_state_change_seq: u64, /// 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, @@ -1689,7 +1690,8 @@ impl AppState { sidebar_width_auto: false, sidebar_collapsed: false, sidebar_section_split: 0.5, - agent_panel_scope: AgentPanelScope::AllWorkspaces, + agent_panel_sort: AgentPanelSort::Spaces, + next_agent_state_change_seq: 0, mouse_capture: true, right_click_passthrough_modifiers: None, right_click_passthrough: None, diff --git a/src/config.rs b/src/config.rs index 758ff331..bf5fceb4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,7 +18,7 @@ pub use self::{ IndexedKeybind, Keybinds, LiveKeybindConfig, }, model::{ - validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport, + validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, ConfigReloadStatus, KeysConfig, NewTerminalCwdConfig, ShellModeConfig, ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, diff --git a/src/config/model.rs b/src/config/model.rs index 95915590..c8fa7f71 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -84,17 +84,18 @@ pub enum ToastClipboardPosition { #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "lowercase")] -pub enum AgentPanelScopeConfig { - Current, +pub enum AgentPanelSortConfig { #[default] - All, + #[serde(alias = "workspaces")] + Spaces, + Priority, } -impl AgentPanelScopeConfig { +impl AgentPanelSortConfig { pub fn as_str(self) -> &'static str { match self { - Self::Current => "current", - Self::All => "all", + Self::Spaces => "spaces", + Self::Priority => "priority", } } } @@ -440,8 +441,8 @@ pub struct UiConfig { pub prompt_new_tab_name: bool, /// Show agent labels in split pane borders when no manual pane label is set. Default: false. pub show_agent_labels_on_pane_borders: bool, - /// Agent sidebar scope. Saved values are "current" or "all". Default: "all". - pub agent_panel_scope: AgentPanelScopeConfig, + /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces". + pub agent_panel_sort: AgentPanelSortConfig, /// Accent color for highlights, borders, and navigation UI. /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). pub accent: String, @@ -626,7 +627,7 @@ impl Default for UiConfig { confirm_close: true, prompt_new_tab_name: true, show_agent_labels_on_pane_borders: false, - agent_panel_scope: AgentPanelScopeConfig::All, + agent_panel_sort: AgentPanelSortConfig::Spaces, accent: "cyan".into(), toast: ToastConfig::default(), sound: SoundConfig::default(), @@ -797,13 +798,32 @@ resume_agents_on_restore = false } #[test] - fn agent_panel_scope_config_parses() { + fn agent_panel_sort_config_parses_alias_and_defaults() { + assert_eq!( + Config::default().ui.agent_panel_sort, + AgentPanelSortConfig::Spaces + ); + let toml = r#" [ui] -agent_panel_scope = "all" +agent_panel_sort = "priority" "#; let config: Config = toml::from_str(toml).unwrap(); - assert_eq!(config.ui.agent_panel_scope, AgentPanelScopeConfig::All); + assert_eq!(config.ui.agent_panel_sort, AgentPanelSortConfig::Priority); + + let toml = r#" +[ui] +agent_panel_sort = "workspaces" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.agent_panel_sort, AgentPanelSortConfig::Spaces); + + let toml = r#" +[ui] +agent_panel_scope = "current" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.agent_panel_sort, AgentPanelSortConfig::Spaces); } #[test] diff --git a/src/main.rs b/src/main.rs index d78dd2ad..11bd2550 100644 --- a/src/main.rs +++ b/src/main.rs @@ -250,8 +250,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Show detected/reported agent labels in split pane borders when no manual pane name is set. # show_agent_labels_on_pane_borders = false -# Agent panel scope: "current" or "all". Toggling it in the sidebar saves this setting. -# agent_panel_scope = "all" +# Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue). +# "workspaces" is accepted as an alias for "spaces". +# agent_panel_sort = "spaces" # Accent color for highlights, borders, and navigation UI. # Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b) diff --git a/src/persist/io.rs b/src/persist/io.rs index 12f3c9c5..3344f5ec 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -175,7 +175,6 @@ pub fn load_history() -> Option { #[cfg(test)] mod tests { use super::*; - use crate::app::state::AgentPanelScope; use crate::persist::snapshot::{ PaneHistorySnapshot, TabHistorySnapshot, WorkspaceHistorySnapshot, }; @@ -205,7 +204,6 @@ mod tests { workspaces: vec![], active: None, selected: 0, - agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 7c046698..162c13ed 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -1188,7 +1188,6 @@ mod tests { }], active: Some(0), selected: 0, - agent_panel_scope: Default::default(), sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), @@ -1278,7 +1277,6 @@ mod tests { }], active: Some(0), selected: 0, - agent_panel_scope: Default::default(), sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), @@ -1384,7 +1382,6 @@ mod tests { }], active: Some(0), selected: 0, - agent_panel_scope: Default::default(), sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), @@ -1496,7 +1493,6 @@ mod tests { }], active: Some(0), selected: 0, - agent_panel_scope: Default::default(), sidebar_width: None, sidebar_section_split: None, collapsed_space_keys: Default::default(), @@ -1687,7 +1683,6 @@ mod tests { }], active: Some(0), selected: 0, - agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: Default::default(), diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 765fa2b6..506a4d45 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -21,8 +21,6 @@ pub struct SessionSnapshot { pub active: Option, pub selected: usize, #[serde(default)] - pub agent_panel_scope: crate::app::state::AgentPanelScope, - #[serde(default)] pub sidebar_width: Option, #[serde(default)] pub sidebar_section_split: Option, @@ -179,8 +177,6 @@ struct RawSessionSnapshot { #[serde(default)] selected: usize, #[serde(default)] - agent_panel_scope: crate::app::state::AgentPanelScope, - #[serde(default)] sidebar_width: Option, #[serde(default)] sidebar_section_split: Option, @@ -198,7 +194,6 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result .collect::, _>>()?, active: raw.active, selected: raw.selected, - agent_panel_scope: raw.agent_panel_scope, sidebar_width: raw.sidebar_width, sidebar_section_split: raw.sidebar_section_split, collapsed_space_keys: raw.collapsed_space_keys, @@ -261,7 +256,6 @@ pub fn capture( terminal_runtimes: &TerminalRuntimeRegistry, active: Option, selected: usize, - agent_panel_scope: crate::app::state::AgentPanelScope, sidebar_width: u16, sidebar_section_split: f32, collapsed_space_keys: std::collections::HashSet, @@ -274,7 +268,6 @@ pub fn capture( .collect(), active, selected, - agent_panel_scope, sidebar_width: Some(sidebar_width), sidebar_section_split: Some(sidebar_section_split), collapsed_space_keys, @@ -489,7 +482,7 @@ mod tests { use ratatui::layout::{Direction, Rect}; use super::*; - use crate::app::{state::AgentPanelScope, AppState, Mode}; + use crate::app::{AppState, Mode}; use crate::layout::NavDirection; use crate::workspace::Workspace; @@ -543,7 +536,6 @@ mod tests { terminal_runtimes, state.active, state.selected, - state.agent_panel_scope, state.sidebar_width, state.sidebar_section_split, state.collapsed_space_keys.clone(), @@ -571,7 +563,6 @@ mod tests { workspaces: vec![], active: None, selected: 0, - agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), @@ -657,7 +648,6 @@ mod tests { }], active: Some(0), selected: 0, - agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), @@ -683,10 +673,6 @@ mod tests { restored.workspaces[0].tabs[0].panes[&1].label.as_deref(), Some("website") ); - assert_eq!( - restored.agent_panel_scope, - AgentPanelScope::CurrentWorkspace - ); assert_eq!(restored.sidebar_width, Some(26)); assert_eq!(restored.sidebar_section_split, Some(0.5)); } @@ -699,7 +685,6 @@ mod tests { assert_eq!(snap.workspaces.len(), 2); assert_eq!(snap.active, Some(0)); assert_eq!(snap.selected, 0); - assert_eq!(snap.agent_panel_scope, AgentPanelScope::AllWorkspaces); assert_eq!(snap.sidebar_width, None); assert_eq!(snap.sidebar_section_split, None); assert_eq!(snap.workspaces[0].tabs.len(), 2); @@ -715,14 +700,13 @@ mod tests { assert_eq!(snap.version, 3); assert_eq!(snap.workspaces.len(), 2); - assert_eq!(snap.agent_panel_scope, AgentPanelScope::CurrentWorkspace); assert_eq!(snap.sidebar_section_split, Some(0.4)); assert_eq!(snap.workspaces[0].active_tab, 1); assert_eq!(snap.workspaces[1].tabs[0].panes.len(), 2); } #[test] - fn old_snapshot_defaults_agent_panel_scope() { + fn old_snapshot_defaults_sidebar_fields() { let json = serde_json::json!({ "version": SNAPSHOT_VERSION, "workspaces": [], @@ -733,7 +717,6 @@ mod tests { let restored = parse_snapshot(&json).unwrap(); - assert_eq!(restored.agent_panel_scope, AgentPanelScope::AllWorkspaces); assert_eq!(restored.sidebar_width, None); assert_eq!(restored.sidebar_section_split, None); } @@ -847,13 +830,11 @@ mod tests { let mut state = state_with_workspaces(&["one"]); state.sidebar_width = 31; state.sidebar_section_split = 0.4; - state.agent_panel_scope = AgentPanelScope::AllWorkspaces; state.collapsed_space_keys.insert("repo-key".into()); let snapshot = capture_from_state(&state); assert_eq!(snapshot.sidebar_width, Some(31)); assert_eq!(snapshot.sidebar_section_split, Some(0.4)); - assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces); assert!(snapshot.collapsed_space_keys.contains("repo-key")); } @@ -1222,7 +1203,6 @@ mod tests { }], active: Some(0), selected: 0, - agent_panel_scope: AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), sidebar_section_split: Some(0.5), collapsed_space_keys: std::collections::HashSet::new(), diff --git a/src/server/headless.rs b/src/server/headless.rs index 750e9250..67a876f9 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -861,7 +861,6 @@ impl HeadlessServer { &self.app.terminal_runtimes, self.app.state.active, self.app.state.selected, - self.app.state.agent_panel_scope, self.app.state.sidebar_width, self.app.state.sidebar_section_split, self.app.state.collapsed_space_keys.clone(), diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 07e84354..b1c0640c 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -73,6 +73,7 @@ pub struct TerminalState { suppressed_full_lifecycle_hook_reports: HashMap, metadata_report_sequences: HashMap, pub state: AgentState, + pub last_agent_state_change_seq: Option, pub revision: u64, pub launch_argv: Option>, pub respawn_shell_on_exit: bool, @@ -97,6 +98,7 @@ impl TerminalState { suppressed_full_lifecycle_hook_reports: HashMap::new(), metadata_report_sequences: HashMap::new(), state: AgentState::Unknown, + last_agent_state_change_seq: None, revision: 0, launch_argv: None, respawn_shell_on_exit: false, @@ -904,6 +906,7 @@ impl TerminalState { self.agent_metadata.clear(); self.suppressed_full_lifecycle_hook_reports.clear(); self.state = AgentState::Unknown; + self.last_agent_state_change_seq = None; self.launch_argv = None; self.respawn_shell_on_exit = false; self.pending_agent_resume_plan = None; diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index d074b68f..80cee6b5 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -959,6 +959,7 @@ mod tests { agent_label: agent_label.map(str::to_string), state: AgentState::Idle, seen: true, + last_agent_state_change_seq: None, custom_status: None, state_labels: std::collections::HashMap::new(), } diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 0447b119..15c0a37e 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -8,7 +8,7 @@ use ratatui::{ use super::scrollbar::{render_scrollbar, should_show_scrollbar}; use super::status::{agent_icon, state_dot, state_label, state_label_color}; -use crate::app::state::{AgentPanelScope, Palette}; +use crate::app::state::{AgentPanelSort, Palette}; use crate::app::{AppState, Mode}; use crate::detect::AgentState; use crate::terminal::TerminalRuntimeRegistry; @@ -25,6 +25,7 @@ pub(crate) struct AgentPanelEntry { pub agent_label: Option, pub state: AgentState, pub seen: bool, + pub last_agent_state_change_seq: Option, pub custom_status: Option, pub state_labels: std::collections::HashMap, } @@ -68,39 +69,19 @@ pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect Rect::new(content.x, content.y + ws_h, content.width, 1) } -fn agent_panel_current_workspace_idx(app: &AppState) -> Option { - if matches!( - app.mode, - Mode::Navigate - | Mode::RenameWorkspace - | Mode::RenamePane - | Mode::Resize - | Mode::ConfirmClose - | Mode::ContextMenu - | Mode::Settings - | Mode::GlobalMenu - | Mode::KeybindHelp - | Mode::ProductAnnouncement - ) { - Some(app.selected) - } else { - app.active +fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str { + match sort { + AgentPanelSort::Spaces => "grouped", + AgentPanelSort::Priority => "priority", } } -fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str { - match scope { - AgentPanelScope::CurrentWorkspace => "current", - AgentPanelScope::AllWorkspaces => "all", - } -} - -pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect { +pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect { if area.width == 0 || area.height < 2 { return Rect::default(); } - let label = agent_panel_toggle_label(scope); + let label = agent_panel_sort_label(sort); let width = label.chars().count() as u16; Rect::new( area.x + area.width.saturating_sub(width), @@ -134,54 +115,41 @@ fn agent_panel_entries_with_runtimes( } }; - match app.agent_panel_scope { - AgentPanelScope::CurrentWorkspace => { - let Some(ws_idx) = agent_panel_current_workspace_idx(app) else { - return Vec::new(); - }; - let Some(ws) = app.workspaces.get(ws_idx) else { - return Vec::new(); - }; + let mut entries: Vec<_> = app + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + let multi_tab = ws.tabs.len() > 1; + let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes); ws.pane_details(&app.terminals) .into_iter() - .map(|detail| AgentPanelEntry { + .map(move |detail| AgentPanelEntry { ws_idx, tab_idx: detail.tab_idx, pane_id: detail.pane_id, - primary_label: detail.label, - primary_tab_label: None, - agent_label: None, + primary_label: workspace_label.clone(), + primary_tab_label: multi_tab.then_some(detail.tab_label), + agent_label: Some(detail.agent_label), state: detail.state, seen: detail.seen, + last_agent_state_change_seq: detail.last_agent_state_change_seq, custom_status: detail.custom_status, state_labels: detail.state_labels, }) - .collect() - } - AgentPanelScope::AllWorkspaces => app - .workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - let multi_tab = ws.tabs.len() > 1; - let workspace_label = ws.display_name_from(&app.terminals, terminal_runtimes); - ws.pane_details(&app.terminals) - .into_iter() - .map(move |detail| AgentPanelEntry { - ws_idx, - tab_idx: detail.tab_idx, - pane_id: detail.pane_id, - primary_label: workspace_label.clone(), - primary_tab_label: multi_tab.then_some(detail.tab_label), - agent_label: Some(detail.agent_label), - state: detail.state, - seen: detail.seen, - custom_status: detail.custom_status, - state_labels: detail.state_labels, - }) - }) - .collect(), + }) + .collect(); + + if matches!(app.agent_panel_sort, AgentPanelSort::Priority) { + entries.sort_by_key(|entry| { + ( + std::cmp::Reverse(workspace_attention_priority(entry.state, entry.seen)), + std::cmp::Reverse(entry.last_agent_state_change_seq), + ) + }); } + + entries } pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str { @@ -1067,11 +1035,11 @@ fn render_agent_detail( )])), Rect::new(area.x, area.y + 1, area.width, 1), ); - let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope); + let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_sort); if toggle_rect != Rect::default() { frame.render_widget( Paragraph::new(Span::styled( - agent_panel_toggle_label(app.agent_panel_scope), + agent_panel_sort_label(app.agent_panel_sort), Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), )) .alignment(Alignment::Right), @@ -1271,7 +1239,6 @@ mod tests { .detected_agent = Some(Agent::Claude); app.active = Some(0); app.selected = 0; - app.agent_panel_scope = AgentPanelScope::AllWorkspaces; let entries = agent_panel_entries(&app); assert_eq!(entries[0].primary_label, "one"); @@ -1282,6 +1249,49 @@ mod tests { assert_eq!(entries[1].agent_label.as_deref(), Some("claude")); } + #[test] + fn priority_agent_panel_sort_uses_attention_then_space_order() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![ + Workspace::test_new("one"), + Workspace::test_new("two"), + Workspace::test_new("three"), + Workspace::test_new("four"), + ]; + app.ensure_test_terminals(); + app.active = Some(0); + app.selected = 0; + app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; + + let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, state| { + let pane = app.workspaces[ws_idx].tabs[0].root_pane; + let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane] + .attached_terminal_id + .clone(); + let terminal = app.terminals.get_mut(&terminal_id).unwrap(); + terminal.detected_agent = Some(Agent::Claude); + terminal.state = state; + }; + set_state(&mut app, 0, AgentState::Working); + set_state(&mut app, 1, AgentState::Idle); + set_state(&mut app, 2, AgentState::Working); + set_state(&mut app, 3, AgentState::Blocked); + + let done_pane = app.workspaces[1].tabs[0].root_pane; + app.workspaces[1].tabs[0] + .panes + .get_mut(&done_pane) + .unwrap() + .seen = false; + + let labels: Vec = agent_panel_entries(&app) + .into_iter() + .map(|entry| entry.primary_label) + .collect(); + + assert_eq!(labels, ["four", "two", "one", "three"]); + } + #[cfg(unix)] #[tokio::test] async fn all_workspaces_agent_panel_entries_use_live_root_runtime_cwd_for_workspace_label() { @@ -1315,7 +1325,6 @@ mod tests { terminal.detected_agent = Some(Agent::Pi); app.active = Some(0); app.selected = 0; - app.agent_panel_scope = AgentPanelScope::AllWorkspaces; let (events, _) = tokio::sync::mpsc::channel(4); let runtime = crate::terminal::TerminalRuntime::spawn( @@ -1372,7 +1381,6 @@ mod tests { .set_agent_name("planner".into()); app.active = Some(0); app.selected = 0; - app.agent_panel_scope = AgentPanelScope::AllWorkspaces; let entries = agent_panel_entries(&app); assert_eq!(entries[0].primary_label, "bridge"); @@ -1390,6 +1398,7 @@ mod tests { agent_label: Some("claude".into()), state: AgentState::Idle, seen: true, + last_agent_state_change_seq: None, custom_status: None, state_labels: std::collections::HashMap::new(), }; diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index d1fcf984..365decbf 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -17,6 +17,7 @@ pub struct PaneDetail { pub agent: Option, pub state: AgentState, pub seen: bool, + pub last_agent_state_change_seq: Option, pub custom_status: Option, pub state_labels: HashMap, } @@ -60,6 +61,7 @@ impl Tab { agent: terminal.effective_known_agent(), state: terminal.state, seen: pane.seen, + last_agent_state_change_seq: terminal.last_agent_state_change_seq, custom_status: presentation.custom_status, state_labels: presentation.state_labels, })