diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-04 17:26:30 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-04 17:26:30 +0300 |
| commit | cc9fa47540d8525f24fe46a10f759ca40090a6e0 (patch) | |
| tree | ef6979a7d5168952dc2de8d7a68e42b3ad69c45b /src | |
| parent | 4473e391962c68d59bd980fa98a44f3397d9931d (diff) | |
feat(ui): add distinct status indicators (#2282)
refs #2260
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/config_io.rs | 13 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 3 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 83 | ||||
| -rw-r--r-- | src/app/mod.rs | 30 | ||||
| -rw-r--r-- | src/app/state.rs | 5 | ||||
| -rw-r--r-- | src/config.rs | 5 | ||||
| -rw-r--r-- | src/config/model.rs | 37 | ||||
| -rw-r--r-- | src/main.rs | 4 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 148 | ||||
| -rw-r--r-- | src/ui/navigator.rs | 6 | ||||
| -rw-r--r-- | src/ui/settings.rs | 18 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 26 | ||||
| -rw-r--r-- | src/ui/status.rs | 66 |
13 files changed, 388 insertions, 56 deletions
diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 59077b6f..9f7445b9 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -55,6 +55,19 @@ impl App { } } + pub(super) fn save_status_indicators(&mut self, style: crate::config::StatusIndicatorStyle) { + if self.update_config_file("status indicators", |content| { + crate::config::upsert_section_value( + content, + "ui", + "status_indicators", + &format!("\"{}\"", style.as_str()), + ) + }) { + self.apply_config_from_disk(false); + } + } + pub(super) fn save_sound(&mut self, enabled: bool) { if self.update_config_file("sound setting", |content| { crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 7eca40b6..f77821c9 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -399,6 +399,9 @@ impl App { } MouseAction::Settings(action) => match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveStatusIndicators(style) => { + self.save_status_indicators(style) + } SettingsAction::SaveSound(enabled) => self.save_sound(enabled), SettingsAction::SaveToastDelivery(delivery) => { self.save_toast_delivery(delivery) diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 326d841b..fb1a7d75 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -6,7 +6,7 @@ use crate::{ state::{AppState, SettingsSection, THEME_NAMES}, App, Mode, }, - config::ToastDelivery, + config::{StatusIndicatorStyle, ToastDelivery}, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -14,6 +14,7 @@ use crate::{ #[allow(clippy::enum_variant_names)] pub(super) enum SettingsAction { SaveTheme(String), + SaveStatusIndicators(StatusIndicatorStyle), SaveSound(bool), SaveToastDelivery(ToastDelivery), SaveAgentBorderLabels(bool), @@ -26,6 +27,7 @@ impl App { if let Some(action) = update_settings_state(&mut self.state, key) { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveStatusIndicators(style) => self.save_status_indicators(style), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), SettingsAction::SaveAgentBorderLabels(enabled) => { @@ -56,6 +58,21 @@ fn current_theme_index(theme_name: &str) -> usize { .unwrap_or(0) } +fn status_indicator_index(style: StatusIndicatorStyle) -> usize { + match style { + StatusIndicatorStyle::Dots => 0, + StatusIndicatorStyle::Symbols => 1, + } +} + +fn status_indicator_for_index(idx: usize) -> StatusIndicatorStyle { + if idx == 0 { + StatusIndicatorStyle::Dots + } else { + StatusIndicatorStyle::Symbols + } +} + fn toast_delivery_index(delivery: ToastDelivery) -> usize { match delivery { ToastDelivery::Off => 0, @@ -145,8 +162,8 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti } } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { - state.settings.section = SettingsSection::Sound; - state.settings.list.selected = usize::from(!state.sound_enabled()); + state.settings.section = SettingsSection::Indicators; + state.settings.list.selected = status_indicator_index(state.status_indicators); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { state.settings.section = SettingsSection::Integrations; @@ -158,6 +175,30 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti _ => {} }, }, + SettingsSection::Indicators => match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { + state.settings.list.selected = 1 - state.settings.list.selected.min(1); + } + KeyCode::Enter | KeyCode::Char(' ') => { + let style = status_indicator_for_index(state.settings.list.selected); + return Some(SettingsAction::SaveStatusIndicators(style)); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Theme; + state.settings.list.selected = current_theme_index(&state.theme_name); + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::Sound; + state.settings.list.selected = usize::from(!state.sound_enabled()); + } + _ => { + if let Some(super::modal::ModalAction::Close) = + super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) + { + cancel_settings(state); + } + } + }, SettingsSection::Sound => match key.code { KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { state.settings.list.selected = 1 - state.settings.list.selected.min(1); @@ -171,8 +212,8 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti state.settings.list.selected = toast_delivery_index(state.toast_delivery()); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { - state.settings.section = SettingsSection::Theme; - state.settings.list.selected = current_theme_index(&state.theme_name); + state.settings.section = SettingsSection::Indicators; + state.settings.list.selected = status_indicator_index(state.status_indicators); } _ => { if let Some(super::modal::ModalAction::Close) = @@ -263,6 +304,7 @@ pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) { state.settings.section = section; state.settings.list.selected = match section { SettingsSection::Theme => current_theme_index(&state.theme_name), + SettingsSection::Indicators => status_indicator_index(state.status_indicators), SettingsSection::Sound => usize::from(!state.sound_enabled()), SettingsSection::Toast => toast_delivery_index(state.toast_delivery()), SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()), @@ -336,7 +378,7 @@ impl AppState { let idx = scroll + (row - area.y) as usize; (idx < THEME_NAMES.len()).then_some(idx) } - SettingsSection::Sound => { + SettingsSection::Indicators | SettingsSection::Sound => { let list_y = area.y + 3; if row >= list_y && row < list_y + 2 { Some((row - list_y) as usize) @@ -371,6 +413,9 @@ impl AppState { self.settings.section = section; self.settings.list.select(match section { SettingsSection::Theme => current_theme_index(&self.theme_name), + SettingsSection::Indicators => { + status_indicator_index(self.status_indicators) + } SettingsSection::Sound => usize::from(!self.sound_enabled()), SettingsSection::Toast => toast_delivery_index(self.toast_delivery()), SettingsSection::PaneLabels => { @@ -387,6 +432,9 @@ impl AppState { preview_selected_theme(self); None } + SettingsSection::Indicators => Some(SettingsAction::SaveStatusIndicators( + status_indicator_for_index(idx), + )), SettingsSection::Sound => { let enabled = idx == 0; Some(SettingsAction::SaveSound(enabled)) @@ -454,7 +502,7 @@ mod tests { ); assert_eq!( state.settings.section, - crate::app::state::SettingsSection::Sound + crate::app::state::SettingsSection::Indicators ); update_settings_state( @@ -469,6 +517,27 @@ mod tests { } #[test] + fn settings_indicator_choice_returns_save_action() { + let mut state = state_with_workspaces(&["test"]); + open_settings_at(&mut state, SettingsSection::Indicators); + state.settings.list.selected = 1; + + let action = update_settings_state( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + + assert_eq!( + action, + Some(SettingsAction::SaveStatusIndicators( + StatusIndicatorStyle::Symbols + )) + ); + assert_eq!(state.status_indicators, StatusIndicatorStyle::Dots); + assert_eq!(state.mode, Mode::Settings); + } + + #[test] fn settings_sound_toggle_returns_save_action() { let mut state = state_with_workspaces(&["test"]); open_settings(&mut state); diff --git a/src/app/mod.rs b/src/app/mod.rs index d049c0f2..82ac1a60 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -616,6 +616,7 @@ impl App { sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, sidebar_section_split, agent_panel_sort, + status_indicators: config.ui.status_indicators, agent_view_override: None, sidebar_agents: config.ui.sidebar.agents.clone(), sidebar_spaces: config.ui.sidebar.spaces.clone(), @@ -1446,6 +1447,7 @@ impl App { self.state.tab_bar_position = config.ui.tab_bar_position; self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); + self.state.status_indicators = config.ui.status_indicators; self.state.sidebar_agents = config.ui.sidebar.agents.clone(); self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); self.state.agent_panel_scroll = 0; @@ -3443,6 +3445,34 @@ mod tests { } #[test] + fn save_status_indicators_persists_then_applies_live_config() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("save-status-indicators"); + 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.status_indicators, + crate::config::StatusIndicatorStyle::Dots + ); + + app.save_status_indicators(crate::config::StatusIndicatorStyle::Symbols); + + assert_eq!( + app.state.status_indicators, + crate::config::StatusIndicatorStyle::Symbols + ); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("status_indicators = \"symbols\"")); + assert!(app.state.config_diagnostic.is_none()); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] 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-sort"); diff --git a/src/app/state.rs b/src/app/state.rs index e6c23c4c..7a8837ee 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -991,6 +991,7 @@ pub enum AgentPanelSort { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsSection { Theme, + Indicators, Sound, Toast, PaneLabels, @@ -1000,6 +1001,7 @@ pub enum SettingsSection { impl SettingsSection { pub const ALL: &[Self] = &[ Self::Theme, + Self::Indicators, Self::Sound, Self::Toast, Self::PaneLabels, @@ -1009,6 +1011,7 @@ impl SettingsSection { pub fn label(self) -> &'static str { match self { Self::Theme => "theme", + Self::Indicators => "indicators", Self::Sound => "sound", Self::Toast => "toasts", Self::PaneLabels => "pane labels", @@ -1476,6 +1479,7 @@ pub struct AppState { /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, + pub status_indicators: crate::config::StatusIndicatorStyle, /// Transient session-wide projection override for the built-in Agents view. pub agent_view_override: Option<crate::api::schema::AgentViewSetParams>, pub sidebar_agents: crate::config::AgentsSidebarConfig, @@ -1847,6 +1851,7 @@ impl AppState { sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, + status_indicators: crate::config::StatusIndicatorStyle::Dots, agent_view_override: None, sidebar_agents: crate::config::AgentsSidebarConfig::default(), sidebar_spaces: crate::config::SpacesSidebarConfig::default(), diff --git a/src/config.rs b/src/config.rs index f3fc170a..83562c48 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,8 +21,9 @@ pub use self::{ model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig, - SidebarCollapsedModeConfig, TabBarPositionConfig, ToastClipboardPosition, ToastConfig, - ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, + SidebarCollapsedModeConfig, StatusIndicatorStyle, TabBarPositionConfig, + ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, + UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, }, sidebar::{ AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle, diff --git a/src/config/model.rs b/src/config/model.rs index e56ac087..97b8c7fa 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -106,6 +106,23 @@ impl AgentPanelSortConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum StatusIndicatorStyle { + #[default] + Dots, + Symbols, +} + +impl StatusIndicatorStyle { + pub fn as_str(self) -> &'static str { + match self { + Self::Dots => "dots", + Self::Symbols => "symbols", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum HostCursorModeConfig { @@ -826,6 +843,8 @@ pub struct UiConfig { pub tab_bar_position: TabBarPositionConfig, /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces". pub agent_panel_sort: AgentPanelSortConfig, + /// Agent status indicator style. Saved values are "dots" or "symbols". Default: "dots". + pub status_indicators: StatusIndicatorStyle, /// Expanded sidebar row composition. pub sidebar: SidebarConfig, /// Accent color for highlights, borders, and navigation UI. @@ -1029,6 +1048,7 @@ impl Default for UiConfig { hide_tab_bar_when_single_tab: false, tab_bar_position: TabBarPositionConfig::Top, agent_panel_sort: AgentPanelSortConfig::Spaces, + status_indicators: StatusIndicatorStyle::Dots, sidebar: SidebarConfig::default(), accent: "cyan".into(), toast: ToastConfig::default(), @@ -1253,6 +1273,23 @@ agent_panel_scope = "current" } #[test] + fn status_indicator_style_defaults_to_dots_and_parses_symbols() { + assert_eq!( + Config::default().ui.status_indicators, + StatusIndicatorStyle::Dots + ); + + let config: Config = toml::from_str( + r#" +[ui] +status_indicators = "symbols" +"#, + ) + .unwrap(); + assert_eq!(config.ui.status_indicators, StatusIndicatorStyle::Symbols); + } + + #[test] fn pane_appearance_defaults_and_parse() { let default_config = Config::default(); assert!(default_config.ui.pane_borders); diff --git a/src/main.rs b/src/main.rs index 27c1d92f..da905311 100644 --- a/src/main.rs +++ b/src/main.rs @@ -326,6 +326,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # "workspaces" is accepted as an alias for "spaces". # agent_panel_sort = "spaces" +# Agent status indicators: "dots" preserves the compact color marks; "symbols" uses +# distinct static glyphs for blocked, working, done, idle, and unknown states. +# status_indicators = "dots" + # Expanded agent rows. Built-ins are state_icon, state_text, workspace, tab, pane, agent, # terminal_title, and terminal_title_stripped. # Custom values reported through pane metadata use a $name token. diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 2e6c97ad..bdec6551 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -11,10 +11,11 @@ use super::sidebar::{ next_entry_is_indented_workspace, workspace_list_entries_expanded, AgentPanelEntry, WorkspaceListEntry, }; -use super::status::state_dot; +use super::status::{state_icon, state_icon_symbol}; use super::text::{display_width_u16, truncate_end}; use crate::app::state::{Palette, ToastKind, ToastNotification}; use crate::app::AppState; +use crate::config::StatusIndicatorStyle; use crate::detect::AgentState; use crate::layout::PaneId; use crate::terminal::TerminalRuntimeRegistry; @@ -326,7 +327,7 @@ fn render_header_status( }; let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_dot(state, seen, p); + let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); let tab_label = mobile_tab_status(ws); let row1 = Rect::new(area.x, area.y, area.width, 1); let tab_w = display_width_u16(&tab_label) @@ -406,9 +407,10 @@ fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { // "tap me" without the user reading the summary row. if global_agent_counts(app).blocked > 0 { let bx = area.x + area.width.saturating_sub(1); + let (symbol, style) = state_icon(AgentState::Blocked, true, app.status_indicators, p); frame.buffer_mut()[(bx, area.y)] - .set_symbol("●") - .set_style(Style::default().fg(p.red).bg(p.surface0)); + .set_symbol(symbol) + .set_style(style.bg(p.surface0)); } } @@ -532,7 +534,7 @@ fn render_mobile_switcher_content( entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id }); let bg = mobile_item_bg(false, active, p); - let (icon, icon_style) = state_dot(entry.state, entry.seen, p); + let (icon, icon_style) = state_icon(entry.state, entry.seen, app.status_indicators, p); let title = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), Span::styled(icon, icon_style.bg(bg)), @@ -595,7 +597,7 @@ fn render_mobile_switcher_content( let selected = *ws_idx == app.selected; let bg = mobile_item_bg(selected, active, p); let (state, seen) = ws.aggregate_state(&app.terminals); - let (dot, dot_style) = state_dot(state, seen, p); + let (dot, dot_style) = state_icon(state, seen, app.status_indicators, p); let mut title_spans = vec![Span::styled(" ", Style::default().bg(bg))]; // Worktrees of the same space render as branches off their parent, so a @@ -1010,7 +1012,10 @@ enum SummaryTone { /// Ordered, non-zero breakdown for the header roll-up: attention states lead /// (blocked → done → working → idle). Pure so it can be unit-tested. -fn agent_summary_segments(counts: GlobalAgentCounts) -> Vec<(String, SummaryTone)> { +fn agent_summary_segments( + counts: GlobalAgentCounts, + indicator_style: StatusIndicatorStyle, +) -> Vec<(String, SummaryTone)> { if counts.total() == 0 { return vec![("no agents".to_string(), SummaryTone::Muted)]; } @@ -1020,22 +1025,77 @@ fn agent_summary_segments(counts: GlobalAgentCounts) -> Vec<(String, SummaryTone let mut segments = Vec::new(); if counts.blocked > 0 { segments.push(( - format!("◉ {} blocked", counts.blocked), + agent_summary_text( + indicator_style, + AgentState::Blocked, + true, + Some("◉"), + counts.blocked, + "blocked", + ), SummaryTone::Blocked, )); } if counts.done > 0 { - segments.push((format!("● {} done", counts.done), SummaryTone::Done)); + segments.push(( + agent_summary_text( + indicator_style, + AgentState::Idle, + false, + Some("●"), + counts.done, + "done", + ), + SummaryTone::Done, + )); } if counts.working > 0 { - segments.push((format!("{} working", counts.working), SummaryTone::Working)); + segments.push(( + agent_summary_text( + indicator_style, + AgentState::Working, + true, + None, + counts.working, + "working", + ), + SummaryTone::Working, + )); } if counts.idle > 0 { - segments.push((format!("{} idle", counts.idle), SummaryTone::Idle)); + segments.push(( + agent_summary_text( + indicator_style, + AgentState::Idle, + true, + None, + counts.idle, + "idle", + ), + SummaryTone::Idle, + )); } segments } +fn agent_summary_text( + indicator_style: StatusIndicatorStyle, + state: AgentState, + seen: bool, + dot_style_symbol: Option<&str>, + count: usize, + label: &str, +) -> String { + let symbol = match indicator_style { + StatusIndicatorStyle::Dots => dot_style_symbol, + StatusIndicatorStyle::Symbols => Some(state_icon_symbol(state, seen, indicator_style)), + }; + match symbol { + Some(symbol) => format!("{symbol} {count} {label}"), + None => format!("{count} {label}"), + } +} + /// Greedily keep the most-urgent segments that fit `max_width` (counting the /// leading space and " · " separators) and report whether any were dropped. /// Segments are ordered by urgency, so the dropped tail is always the least @@ -1060,7 +1120,7 @@ fn fit_summary_segments( } fn agent_summary_line(app: &AppState, p: &Palette, max_width: u16) -> Line<'static> { - let segments = agent_summary_segments(global_agent_counts(app)); + let segments = agent_summary_segments(global_agent_counts(app), app.status_indicators); let (shown, truncated) = fit_summary_segments(segments, max_width as usize); let mut spans = vec![Span::styled(" ", Style::default().bg(p.panel_bg))]; @@ -1211,7 +1271,7 @@ mod tests { working: 2, idle: 1, }; - let segments = agent_summary_segments(counts); + let segments = agent_summary_segments(counts, StatusIndicatorStyle::Dots); let labels: Vec<&str> = segments.iter().map(|(text, _)| text.as_str()).collect(); assert_eq!( labels, @@ -1221,13 +1281,59 @@ mod tests { } #[test] + fn distinct_agent_summary_uses_configured_symbols_for_every_state() { + let counts = GlobalAgentCounts { + blocked: 2, + done: 1, + working: 2, + idle: 1, + }; + let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Symbols) + .into_iter() + .map(|(text, _)| text) + .collect(); + assert_eq!( + labels, + ["× 2 blocked", "✓ 1 done", "◐ 2 working", "○ 1 idle"] + ); + } + + #[test] + fn distinct_status_style_updates_mobile_blocked_badge() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![crate::workspace::Workspace::test_new("blocked")]; + app.ensure_test_terminals(); + app.status_indicators = StatusIndicatorStyle::Symbols; + let pane_id = app.workspaces[0].tabs[0].root_pane; + let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal_state = app.terminals.get_mut(&terminal_id).unwrap(); + terminal_state.detected_agent = Some(crate::detect::Agent::Claude); + terminal_state.state = AgentState::Blocked; + + let area = Rect::new(0, 0, 12, 2); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height)) + .unwrap(); + terminal + .draw(|frame| render_switch_button(&app, frame, area)) + .unwrap(); + + assert_eq!( + terminal.backend().buffer()[(area.width - 1, 0)].symbol(), + "×" + ); + } + + #[test] fn agent_summary_hides_empty_categories() { let counts = GlobalAgentCounts { done: 1, working: 2, ..Default::default() }; - let labels: Vec<String> = agent_summary_segments(counts) + let labels: Vec<String> = agent_summary_segments(counts, StatusIndicatorStyle::Dots) .into_iter() .map(|(text, _)| text) .collect(); @@ -1244,7 +1350,7 @@ mod tests { ..Default::default() }; assert_eq!( - agent_summary_segments(counts), + agent_summary_segments(counts, StatusIndicatorStyle::Dots), vec![("all idle".to_string(), SummaryTone::Muted)] ); } @@ -1257,7 +1363,10 @@ mod tests { working: 2, idle: 1, }; - let (shown, truncated) = fit_summary_segments(agent_summary_segments(counts), 24); + let (shown, truncated) = fit_summary_segments( + agent_summary_segments(counts, StatusIndicatorStyle::Dots), + 24, + ); let labels: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect(); assert_eq!(labels, vec!["◉ 2 blocked", "● 1 done"]); assert!(truncated); @@ -1271,7 +1380,10 @@ mod tests { working: 2, idle: 1, }; - let (shown, truncated) = fit_summary_segments(agent_summary_segments(counts), 60); + let (shown, truncated) = fit_summary_segments( + agent_summary_segments(counts, StatusIndicatorStyle::Dots), + 60, + ); assert_eq!(shown.len(), 4); assert!(!truncated); } @@ -1279,7 +1391,7 @@ mod tests { #[test] fn agent_summary_reports_no_agents_when_empty() { assert_eq!( - agent_summary_segments(GlobalAgentCounts::default()), + agent_summary_segments(GlobalAgentCounts::default(), StatusIndicatorStyle::Dots,), vec![("no agents".to_string(), SummaryTone::Muted)] ); } diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs index 9269394b..cb2f235e 100644 --- a/src/ui/navigator.rs +++ b/src/ui/navigator.rs @@ -8,7 +8,7 @@ use ratatui::{ use super::{ scrollbar::{render_scrollbar, should_show_scrollbar}, - status::{state_dot, state_label_color}, + status::{state_icon, state_label_color}, text::{display_width_u16, middle_elide, truncate_end}, widgets::{panel_contrast_fg, render_panel_shell}, }; @@ -113,7 +113,7 @@ fn push_state_chip( label: &'static str, app: &AppState, ) { - let (icon, icon_style) = state_dot(state, seen, &app.palette); + let (icon, icon_style) = state_icon(state, seen, app.status_indicators, &app.palette); spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD))); spans.push(Span::raw(" ")); spans.push(Span::styled( @@ -201,7 +201,7 @@ fn render_row( } else { Style::default().fg(p.subtext0).bg(p.panel_bg) }; - let (status_icon, status_style) = state_dot(row.status, row.seen, p); + let (status_icon, status_style) = state_icon(row.status, row.seen, app.status_indicators, p); let status_style = if selected { base_style.add_modifier(Modifier::BOLD) } else if context_only { diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5052c91c..3cea6da3 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -12,7 +12,7 @@ use super::widgets::{ }; use crate::{ app::{state::Palette, AppState}, - config::ToastDelivery, + config::{StatusIndicatorStyle, ToastDelivery}, }; pub(crate) const SETTINGS_POPUP_WIDTH: u16 = 76; @@ -106,6 +106,22 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R SettingsSection::Theme => { render_settings_theme(app, frame, content_area); } + SettingsSection::Indicators => { + render_modal_choice_list( + frame, + content_area, + "agent status indicators", + "choose color dots or distinct symbols for each state", + &[ + ("color dots ● ● ● ○ ·", StatusIndicatorStyle::Dots), + ("distinct symbols × ◐ ✓ ○ ·", StatusIndicatorStyle::Symbols), + ], + app.status_indicators, + app.settings.list.selected, + p, + 1, + ); + } SettingsSection::Sound => { render_settings_toggle( frame, diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 23e9afd4..50796e81 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -10,7 +10,7 @@ use ratatui::{ use self::tokens::{ResolvedToken, ResolvedTokenKind, SpaceTokenContext}; use super::scrollbar::{render_scrollbar, should_show_scrollbar}; -use super::status::{state_dot, state_label, state_label_color}; +use super::status::{state_icon, state_label, state_label_color}; use super::text::{display_width, display_width_u16, truncate_end}; use crate::app::state::{AgentPanelSort, Palette}; use crate::app::{AppState, Mode}; @@ -781,7 +781,7 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: break; } let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - let (icon, icon_style) = state_dot(agg_state, agg_seen, p); + let (icon, icon_style) = state_icon(agg_state, agg_seen, app.status_indicators, p); let is_selected = visible_idx == app.selected && is_navigating; let is_active = Some(visible_idx) == app.active; let row_style = if is_selected { @@ -842,7 +842,8 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: } let position = detail_idx + 1; let position_style = Style::default().fg(p.overlay0); - let (icon, icon_style) = state_dot(detail.state, detail.seen, p); + let (icon, icon_style) = + state_icon(detail.state, detail.seen, app.status_indicators, p); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(format!("{position:<2}"), position_style), @@ -1275,7 +1276,7 @@ fn render_workspace_list( .filter(|(_, collapsed)| *collapsed) .map(|(key, _)| space_aggregate_state(app, key)) .unwrap_or((agg_state, agg_seen)); - let state_icon = state_dot(display_state, display_seen, p); + let state_icon = state_icon(display_state, display_seen, app.status_indicators, p); let state_text_style = Style::default() .fg(state_label_color(display_state, display_seen, p)) .add_modifier(Modifier::DIM); @@ -1488,7 +1489,7 @@ fn render_agent_detail( Style::default().fg(label_color).add_modifier(Modifier::DIM) }; let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); - let state_icon = state_dot(detail.state, detail.seen, p); + let state_icon = state_icon(detail.state, detail.seen, app.status_indicators, p); for (row_index, resolved) in rows.iter().take(height as usize).enumerate() { let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })]; @@ -2209,6 +2210,7 @@ rows = [[{ token = "git_status", fg = "#123456" }]] app.workspaces = vec![first, second]; app.ensure_test_terminals(); app.agent_panel_sort = crate::app::state::AgentPanelSort::Priority; + app.status_indicators = crate::config::StatusIndicatorStyle::Symbols; let set_state = |app: &mut crate::app::state::AppState, ws_idx: usize, pane_id, state| { let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] @@ -2218,9 +2220,14 @@ rows = [[{ token = "git_status", fg = "#123456" }]] terminal.detected_agent = Some(Agent::Claude); terminal.state = state; }; - set_state(&mut app, 0, first_pane, AgentState::Working); + set_state(&mut app, 0, first_pane, AgentState::Idle); set_state(&mut app, 1, second_pane, AgentState::Working); set_state(&mut app, 1, urgent_pane, AgentState::Blocked); + app.workspaces[0].tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .seen = false; assert_eq!(app.workspaces[1].public_pane_number(urgent_pane), Some(2)); assert_eq!(agent_panel_entries(&app)[0].pane_id, urgent_pane); @@ -2238,11 +2245,16 @@ rows = [[{ token = "git_status", fg = "#123456" }]] assert_eq!(buffer[(detail_area.x, detail_area.y)].symbol(), "1"); assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); assert_eq!(buffer[(detail_area.x, detail_area.y + 2)].symbol(), "3"); - assert_eq!(buffer[(detail_area.x + 2, detail_area.y)].symbol(), "●"); + assert_eq!(buffer[(detail_area.x + 2, detail_area.y)].symbol(), "×"); assert_eq!( buffer[(detail_area.x + 2, detail_area.y)].style().fg, Some(app.palette.red) ); + assert_eq!(buffer[(detail_area.x + 2, detail_area.y + 1)].symbol(), "✓"); + assert_eq!( + buffer[(detail_area.x + 2, detail_area.y + 1)].style().fg, + Some(app.palette.teal) + ); } #[cfg(unix)] diff --git a/src/ui/status.rs b/src/ui/status.rs index a7399285..62646482 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -10,7 +10,7 @@ use super::text::display_width_u16; use super::widgets::panel_contrast_fg; use crate::{ app::state::{CopyFeedback, Palette, ToastKind, ToastNotification}, - config::{ToastClipboardPosition, ToastHerdrPosition}, + config::{StatusIndicatorStyle, ToastClipboardPosition, ToastHerdrPosition}, detect::AgentState, }; @@ -193,16 +193,37 @@ pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: & } } -pub(super) fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) { - match (state, seen) { - (AgentState::Blocked, _) => ("●", Style::default().fg(p.red)), - (AgentState::Working, _) => ("●", Style::default().fg(p.yellow)), - (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)), - (AgentState::Idle, true) => ("○", Style::default().fg(p.green)), - (AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)), +pub(super) fn state_icon_symbol( + state: AgentState, + seen: bool, + indicator_style: StatusIndicatorStyle, +) -> &'static str { + match (indicator_style, state, seen) { + (StatusIndicatorStyle::Dots, AgentState::Blocked, _) => "●", + (StatusIndicatorStyle::Dots, AgentState::Working, _) => "●", + (StatusIndicatorStyle::Dots, AgentState::Idle, false) => "●", + (StatusIndicatorStyle::Dots, AgentState::Idle, true) => "○", + (StatusIndicatorStyle::Dots, AgentState::Unknown, _) => "·", + (StatusIndicatorStyle::Symbols, AgentState::Blocked, _) => "×", + (StatusIndicatorStyle::Symbols, AgentState::Working, _) => "◐", + (StatusIndicatorStyle::Symbols, AgentState::Idle, false) => "✓", + (StatusIndicatorStyle::Symbols, AgentState::Idle, true) => "○", + (StatusIndicatorStyle::Symbols, AgentState::Unknown, _) => "·", } } +pub(super) fn state_icon( + state: AgentState, + seen: bool, + indicator_style: StatusIndicatorStyle, + p: &Palette, +) -> (&'static str, Style) { + ( + state_icon_symbol(state, seen, indicator_style), + Style::default().fg(state_label_color(state, seen, p)), + ) +} + pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str { match (state, seen) { (AgentState::Blocked, _) => "blocked", @@ -245,18 +266,27 @@ mod tests { } #[test] - fn state_dots_use_aligned_static_workspace_marks() { + fn state_icons_support_dot_and_distinct_symbol_styles() { let palette = Palette::catppuccin(); - for (state, seen, symbol, color) in [ - (AgentState::Blocked, true, "●", palette.red), - (AgentState::Working, true, "●", palette.yellow), - (AgentState::Idle, false, "●", palette.teal), - (AgentState::Idle, true, "○", palette.green), - (AgentState::Unknown, true, "·", palette.overlay0), + for (indicator_style, expected_symbols) in [ + (StatusIndicatorStyle::Dots, ["●", "●", "●", "○", "·"]), + (StatusIndicatorStyle::Symbols, ["×", "◐", "✓", "○", "·"]), ] { - let (actual_symbol, style) = state_dot(state, seen, &palette); - assert_eq!(actual_symbol, symbol); - assert_eq!(style.fg, Some(color)); + for ((state, seen, color), expected_symbol) in [ + (AgentState::Blocked, true, palette.red), + (AgentState::Working, true, palette.yellow), + (AgentState::Idle, false, palette.teal), + (AgentState::Idle, true, palette.green), + (AgentState::Unknown, true, palette.overlay0), + ] + .into_iter() + .zip(expected_symbols) + { + let (actual_symbol, style) = state_icon(state, seen, indicator_style, &palette); + assert_eq!(actual_symbol, expected_symbol); + assert_eq!(display_width_u16(actual_symbol), 1); + assert_eq!(style.fg, Some(color)); + } } } |
