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/ui | |
| parent | 4473e391962c68d59bd980fa98a44f3397d9931d (diff) | |
feat(ui): add distinct status indicators (#2282)
refs #2260
Diffstat (limited to 'src/ui')
| -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 |
5 files changed, 217 insertions, 47 deletions
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)); + } } } |
