aboutsummaryrefslogtreecommitdiff
path: root/src/ui/mobile.rs
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/mobile.rs
parent4473e391962c68d59bd980fa98a44f3397d9931d (diff)
feat(ui): add distinct status indicators (#2282)
refs #2260
Diffstat (limited to 'src/ui/mobile.rs')
-rw-r--r--src/ui/mobile.rs148
1 files changed, 130 insertions, 18 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)]
);
}