Skip to content

Commit cc9fa47

Browse files
authored
feat(ui): add distinct status indicators (#2282)
refs #2260
1 parent 4473e39 commit cc9fa47

16 files changed

Lines changed: 408 additions & 56 deletions

File tree

‎docs/next/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
### Added
6+
- Settings and `ui.status_indicators = "symbols"` can now use distinct static shapes for blocked, working, done, idle, and unknown agent states. (#2260)
67
- The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits.
78

89
### Fixed

‎docs/next/website/src/content/docs/configuration.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,15 @@ The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference](
258258

259259
Set `tab_bar_position = "bottom"` under `[ui]` to place the desktop tab row below the terminal panes. Prefix, Navigate, Copy, and Resize mode bars temporarily replace the bottom tab row while active. The default is `"top"`.
260260

261+
Agent status uses compact colored dots by default. To distinguish blocked, working, done, idle, and unknown states by shape as well as color, choose **distinct symbols** in Settings or configure:
262+
263+
```toml
264+
[ui]
265+
status_indicators = "symbols"
266+
```
267+
268+
The symbols are static, so this option does not enable spinner animation.
269+
261270
### Sidebar row layouts
262271

263272
The expanded desktop sidebar renders each inner array in `rows` as one line. These are the complete default layouts:

‎docs/next/website/src/data/config-reference.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,16 @@
696696
"priority"
697697
]
698698
},
699+
{
700+
"key": "ui.status_indicators",
701+
"type": "enum",
702+
"default": "\"dots\"",
703+
"description": "Choose compact color dots or distinct static symbols for agent states.",
704+
"values": [
705+
"dots",
706+
"symbols"
707+
]
708+
},
699709
{
700710
"key": "ui.sidebar.agents.row_gap",
701711
"type": "integer",

‎src/app/config_io.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ impl App {
5555
}
5656
}
5757

58+
pub(super) fn save_status_indicators(&mut self, style: crate::config::StatusIndicatorStyle) {
59+
if self.update_config_file("status indicators", |content| {
60+
crate::config::upsert_section_value(
61+
content,
62+
"ui",
63+
"status_indicators",
64+
&format!("\"{}\"", style.as_str()),
65+
)
66+
}) {
67+
self.apply_config_from_disk(false);
68+
}
69+
}
70+
5871
pub(super) fn save_sound(&mut self, enabled: bool) {
5972
if self.update_config_file("sound setting", |content| {
6073
crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled)

‎src/app/input/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,9 @@ impl App {
399399
}
400400
MouseAction::Settings(action) => match action {
401401
SettingsAction::SaveTheme(name) => self.save_theme(&name),
402+
SettingsAction::SaveStatusIndicators(style) => {
403+
self.save_status_indicators(style)
404+
}
402405
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
403406
SettingsAction::SaveToastDelivery(delivery) => {
404407
self.save_toast_delivery(delivery)

‎src/app/input/settings.rs‎

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ use crate::{
66
state::{AppState, SettingsSection, THEME_NAMES},
77
App, Mode,
88
},
9-
config::ToastDelivery,
9+
config::{StatusIndicatorStyle, ToastDelivery},
1010
};
1111

1212
#[derive(Debug, Clone, PartialEq, Eq)]
1313
// The shared `Save` verb is semantic: these actions persist settings.
1414
#[allow(clippy::enum_variant_names)]
1515
pub(super) enum SettingsAction {
1616
SaveTheme(String),
17+
SaveStatusIndicators(StatusIndicatorStyle),
1718
SaveSound(bool),
1819
SaveToastDelivery(ToastDelivery),
1920
SaveAgentBorderLabels(bool),
@@ -26,6 +27,7 @@ impl App {
2627
if let Some(action) = update_settings_state(&mut self.state, key) {
2728
match action {
2829
SettingsAction::SaveTheme(name) => self.save_theme(&name),
30+
SettingsAction::SaveStatusIndicators(style) => self.save_status_indicators(style),
2931
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
3032
SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery),
3133
SettingsAction::SaveAgentBorderLabels(enabled) => {
@@ -56,6 +58,21 @@ fn current_theme_index(theme_name: &str) -> usize {
5658
.unwrap_or(0)
5759
}
5860

61+
fn status_indicator_index(style: StatusIndicatorStyle) -> usize {
62+
match style {
63+
StatusIndicatorStyle::Dots => 0,
64+
StatusIndicatorStyle::Symbols => 1,
65+
}
66+
}
67+
68+
fn status_indicator_for_index(idx: usize) -> StatusIndicatorStyle {
69+
if idx == 0 {
70+
StatusIndicatorStyle::Dots
71+
} else {
72+
StatusIndicatorStyle::Symbols
73+
}
74+
}
75+
5976
fn toast_delivery_index(delivery: ToastDelivery) -> usize {
6077
match delivery {
6178
ToastDelivery::Off => 0,
@@ -145,8 +162,8 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
145162
}
146163
}
147164
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
148-
state.settings.section = SettingsSection::Sound;
149-
state.settings.list.selected = usize::from(!state.sound_enabled());
165+
state.settings.section = SettingsSection::Indicators;
166+
state.settings.list.selected = status_indicator_index(state.status_indicators);
150167
}
151168
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
152169
state.settings.section = SettingsSection::Integrations;
@@ -158,6 +175,30 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
158175
_ => {}
159176
},
160177
},
178+
SettingsSection::Indicators => match key.code {
179+
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
180+
state.settings.list.selected = 1 - state.settings.list.selected.min(1);
181+
}
182+
KeyCode::Enter | KeyCode::Char(' ') => {
183+
let style = status_indicator_for_index(state.settings.list.selected);
184+
return Some(SettingsAction::SaveStatusIndicators(style));
185+
}
186+
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
187+
state.settings.section = SettingsSection::Theme;
188+
state.settings.list.selected = current_theme_index(&state.theme_name);
189+
}
190+
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
191+
state.settings.section = SettingsSection::Sound;
192+
state.settings.list.selected = usize::from(!state.sound_enabled());
193+
}
194+
_ => {
195+
if let Some(super::modal::ModalAction::Close) =
196+
super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS)
197+
{
198+
cancel_settings(state);
199+
}
200+
}
201+
},
161202
SettingsSection::Sound => match key.code {
162203
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
163204
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
171212
state.settings.list.selected = toast_delivery_index(state.toast_delivery());
172213
}
173214
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
174-
state.settings.section = SettingsSection::Theme;
175-
state.settings.list.selected = current_theme_index(&state.theme_name);
215+
state.settings.section = SettingsSection::Indicators;
216+
state.settings.list.selected = status_indicator_index(state.status_indicators);
176217
}
177218
_ => {
178219
if let Some(super::modal::ModalAction::Close) =
@@ -263,6 +304,7 @@ pub(crate) fn open_settings_at(state: &mut AppState, section: SettingsSection) {
263304
state.settings.section = section;
264305
state.settings.list.selected = match section {
265306
SettingsSection::Theme => current_theme_index(&state.theme_name),
307+
SettingsSection::Indicators => status_indicator_index(state.status_indicators),
266308
SettingsSection::Sound => usize::from(!state.sound_enabled()),
267309
SettingsSection::Toast => toast_delivery_index(state.toast_delivery()),
268310
SettingsSection::PaneLabels => usize::from(!state.agent_border_labels_enabled()),
@@ -336,7 +378,7 @@ impl AppState {
336378
let idx = scroll + (row - area.y) as usize;
337379
(idx < THEME_NAMES.len()).then_some(idx)
338380
}
339-
SettingsSection::Sound => {
381+
SettingsSection::Indicators | SettingsSection::Sound => {
340382
let list_y = area.y + 3;
341383
if row >= list_y && row < list_y + 2 {
342384
Some((row - list_y) as usize)
@@ -371,6 +413,9 @@ impl AppState {
371413
self.settings.section = section;
372414
self.settings.list.select(match section {
373415
SettingsSection::Theme => current_theme_index(&self.theme_name),
416+
SettingsSection::Indicators => {
417+
status_indicator_index(self.status_indicators)
418+
}
374419
SettingsSection::Sound => usize::from(!self.sound_enabled()),
375420
SettingsSection::Toast => toast_delivery_index(self.toast_delivery()),
376421
SettingsSection::PaneLabels => {
@@ -387,6 +432,9 @@ impl AppState {
387432
preview_selected_theme(self);
388433
None
389434
}
435+
SettingsSection::Indicators => Some(SettingsAction::SaveStatusIndicators(
436+
status_indicator_for_index(idx),
437+
)),
390438
SettingsSection::Sound => {
391439
let enabled = idx == 0;
392440
Some(SettingsAction::SaveSound(enabled))
@@ -454,7 +502,7 @@ mod tests {
454502
);
455503
assert_eq!(
456504
state.settings.section,
457-
crate::app::state::SettingsSection::Sound
505+
crate::app::state::SettingsSection::Indicators
458506
);
459507

460508
update_settings_state(
@@ -468,6 +516,27 @@ mod tests {
468516
assert_eq!(state.palette.panel_bg, original_palette.panel_bg);
469517
}
470518

519+
#[test]
520+
fn settings_indicator_choice_returns_save_action() {
521+
let mut state = state_with_workspaces(&["test"]);
522+
open_settings_at(&mut state, SettingsSection::Indicators);
523+
state.settings.list.selected = 1;
524+
525+
let action = update_settings_state(
526+
&mut state,
527+
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
528+
);
529+
530+
assert_eq!(
531+
action,
532+
Some(SettingsAction::SaveStatusIndicators(
533+
StatusIndicatorStyle::Symbols
534+
))
535+
);
536+
assert_eq!(state.status_indicators, StatusIndicatorStyle::Dots);
537+
assert_eq!(state.mode, Mode::Settings);
538+
}
539+
471540
#[test]
472541
fn settings_sound_toggle_returns_save_action() {
473542
let mut state = state_with_workspaces(&["test"]);

‎src/app/mod.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,7 @@ impl App {
616616
sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode,
617617
sidebar_section_split,
618618
agent_panel_sort,
619+
status_indicators: config.ui.status_indicators,
619620
agent_view_override: None,
620621
sidebar_agents: config.ui.sidebar.agents.clone(),
621622
sidebar_spaces: config.ui.sidebar.spaces.clone(),
@@ -1446,6 +1447,7 @@ impl App {
14461447
self.state.tab_bar_position = config.ui.tab_bar_position;
14471448
self.state.agent_panel_sort =
14481449
agent_panel_sort_from_config(config.ui.agent_panel_sort);
1450+
self.state.status_indicators = config.ui.status_indicators;
14491451
self.state.sidebar_agents = config.ui.sidebar.agents.clone();
14501452
self.state.sidebar_spaces = config.ui.sidebar.spaces.clone();
14511453
self.state.agent_panel_scroll = 0;
@@ -3442,6 +3444,34 @@ mod tests {
34423444
let _ = std::fs::remove_dir_all(path.parent().unwrap());
34433445
}
34443446

3447+
#[test]
3448+
fn save_status_indicators_persists_then_applies_live_config() {
3449+
let _guard = config_env_lock().lock().unwrap();
3450+
let path = temp_config_path("save-status-indicators");
3451+
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3452+
std::fs::write(&path, "onboarding = false\n").unwrap();
3453+
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
3454+
3455+
let mut app = test_app();
3456+
assert_eq!(
3457+
app.state.status_indicators,
3458+
crate::config::StatusIndicatorStyle::Dots
3459+
);
3460+
3461+
app.save_status_indicators(crate::config::StatusIndicatorStyle::Symbols);
3462+
3463+
assert_eq!(
3464+
app.state.status_indicators,
3465+
crate::config::StatusIndicatorStyle::Symbols
3466+
);
3467+
let content = std::fs::read_to_string(&path).unwrap();
3468+
assert!(content.contains("status_indicators = \"symbols\""));
3469+
assert!(app.state.config_diagnostic.is_none());
3470+
3471+
std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
3472+
let _ = std::fs::remove_dir_all(path.parent().unwrap());
3473+
}
3474+
34453475
#[test]
34463476
fn save_agent_panel_sort_persists_then_applies_live_config() {
34473477
let _guard = config_env_lock().lock().unwrap();

‎src/app/state.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,7 @@ pub enum AgentPanelSort {
991991
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
992992
pub enum SettingsSection {
993993
Theme,
994+
Indicators,
994995
Sound,
995996
Toast,
996997
PaneLabels,
@@ -1000,6 +1001,7 @@ pub enum SettingsSection {
10001001
impl SettingsSection {
10011002
pub const ALL: &[Self] = &[
10021003
Self::Theme,
1004+
Self::Indicators,
10031005
Self::Sound,
10041006
Self::Toast,
10051007
Self::PaneLabels,
@@ -1009,6 +1011,7 @@ impl SettingsSection {
10091011
pub fn label(self) -> &'static str {
10101012
match self {
10111013
Self::Theme => "theme",
1014+
Self::Indicators => "indicators",
10121015
Self::Sound => "sound",
10131016
Self::Toast => "toasts",
10141017
Self::PaneLabels => "pane labels",
@@ -1476,6 +1479,7 @@ pub struct AppState {
14761479
/// Ratio of sidebar height allocated to the workspaces section.
14771480
pub sidebar_section_split: f32,
14781481
pub agent_panel_sort: AgentPanelSort,
1482+
pub status_indicators: crate::config::StatusIndicatorStyle,
14791483
/// Transient session-wide projection override for the built-in Agents view.
14801484
pub agent_view_override: Option<crate::api::schema::AgentViewSetParams>,
14811485
pub sidebar_agents: crate::config::AgentsSidebarConfig,
@@ -1847,6 +1851,7 @@ impl AppState {
18471851
sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact,
18481852
sidebar_section_split: 0.5,
18491853
agent_panel_sort: AgentPanelSort::Spaces,
1854+
status_indicators: crate::config::StatusIndicatorStyle::Dots,
18501855
agent_view_override: None,
18511856
sidebar_agents: crate::config::AgentsSidebarConfig::default(),
18521857
sidebar_spaces: crate::config::SpacesSidebarConfig::default(),

‎src/config.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ pub use self::{
2121
model::{
2222
validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport,
2323
ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig,
24-
SidebarCollapsedModeConfig, TabBarPositionConfig, ToastClipboardPosition, ToastConfig,
25-
ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
24+
SidebarCollapsedModeConfig, StatusIndicatorStyle, TabBarPositionConfig,
25+
ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition,
26+
UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
2627
},
2728
sidebar::{
2829
AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle,

0 commit comments

Comments
 (0)