aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent4473e391962c68d59bd980fa98a44f3397d9931d (diff)
feat(ui): add distinct status indicators (#2282)
refs #2260
Diffstat (limited to 'src')
-rw-r--r--src/app/config_io.rs13
-rw-r--r--src/app/input/mod.rs3
-rw-r--r--src/app/input/settings.rs83
-rw-r--r--src/app/mod.rs30
-rw-r--r--src/app/state.rs5
-rw-r--r--src/config.rs5
-rw-r--r--src/config/model.rs37
-rw-r--r--src/main.rs4
-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
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));
+ }
}
}