aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-04 17:26:30 +0300
committerGitHub <noreply@github.com>2026-08-04 17:26:30 +0300
commitcc9fa47540d8525f24fe46a10f759ca40090a6e0 (patch)
treeef6979a7d5168952dc2de8d7a68e42b3ad69c45b /src/ui
parent4473e391962c68d59bd980fa98a44f3397d9931d (diff)
feat(ui): add distinct status indicators (#2282)
refs #2260
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/mobile.rs148
-rw-r--r--src/ui/navigator.rs6
-rw-r--r--src/ui/settings.rs18
-rw-r--r--src/ui/sidebar.rs26
-rw-r--r--src/ui/status.rs66
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));
+ }
}
}