feat: sort agent panel by priority

refs #318
This commit is contained in:
Ogulcan Celik 2026-06-17 16:08:43 +03:00
parent d998753efe
commit 54490254f7
21 changed files with 247 additions and 234 deletions

View File

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

View File

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

View File

@ -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}\""),
)
}) {

View File

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

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -175,7 +175,6 @@ pub fn load_history() -> Option<SessionHistorySnapshot> {
#[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(),

View File

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

View File

@ -21,8 +21,6 @@ pub struct SessionSnapshot {
pub active: Option<usize>,
pub selected: usize,
#[serde(default)]
pub agent_panel_scope: crate::app::state::AgentPanelScope,
#[serde(default)]
pub sidebar_width: Option<u16>,
#[serde(default)]
pub sidebar_section_split: Option<f32>,
@ -179,8 +177,6 @@ struct RawSessionSnapshot {
#[serde(default)]
selected: usize,
#[serde(default)]
agent_panel_scope: crate::app::state::AgentPanelScope,
#[serde(default)]
sidebar_width: Option<u16>,
#[serde(default)]
sidebar_section_split: Option<f32>,
@ -198,7 +194,6 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String>
.collect::<Result<Vec<_>, _>>()?,
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<usize>,
selected: usize,
agent_panel_scope: crate::app::state::AgentPanelScope,
sidebar_width: u16,
sidebar_section_split: f32,
collapsed_space_keys: std::collections::HashSet<String>,
@ -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(),

View File

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

View File

@ -73,6 +73,7 @@ pub struct TerminalState {
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
metadata_report_sequences: HashMap<String, u64>,
pub state: AgentState,
pub last_agent_state_change_seq: Option<u64>,
pub revision: u64,
pub launch_argv: Option<Vec<String>>,
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;

View File

@ -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(),
}

View File

@ -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<String>,
pub state: AgentState,
pub seen: bool,
pub last_agent_state_change_seq: Option<u64>,
pub custom_status: Option<String>,
pub state_labels: std::collections::HashMap<String, String>,
}
@ -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<usize> {
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<String> = 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(),
};

View File

@ -17,6 +17,7 @@ pub struct PaneDetail {
pub agent: Option<Agent>,
pub state: AgentState,
pub seen: bool,
pub last_agent_state_change_seq: Option<u64>,
pub custom_status: Option<String>,
pub state_labels: HashMap<String, String>,
}
@ -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,
})