aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-28 14:56:51 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-28 14:56:53 +0300
commit81f355fadac7d0d45b077dfc28f9f679add6bbb6 (patch)
tree8c734b443c28ee4de8c4058f5a0c25cafbd057bd
parent1491b7dd9c992ef0ad2b763f3e450befaf25c47f (diff)
perf: replace agent spinners with static status marks
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--src/app/mod.rs9
-rw-r--r--src/app/runtime.rs46
-rw-r--r--src/app/state.rs3
-rw-r--r--src/server/headless.rs299
-rw-r--r--src/server/render_stream.rs39
-rw-r--r--src/ui.rs13
-rw-r--r--src/ui/mobile.rs13
-rw-r--r--src/ui/navigator.rs11
-rw-r--r--src/ui/sidebar.rs10
-rw-r--r--src/ui/status.rs31
-rw-r--r--src/workspace/aggregate.rs12
-rw-r--r--tests/cross_area.rs15
-rw-r--r--website/index.html6
14 files changed, 67 insertions, 441 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 21d11b85..7acaf007 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Changed
+- Agent status indicators now use the same static workspace marks across the sidebar, navigator, and mobile views, eliminating continuous spinner rendering while agents work.
- Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0.
### Fixed
diff --git a/src/app/mod.rs b/src/app/mod.rs
index b2aa4ff2..c8c8113a 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -33,9 +33,6 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16);
-pub(crate) const ANIMATION_INTERVAL: Duration = Duration::from_millis(16);
-pub(crate) const HEADLESS_ANIMATION_INTERVAL: Duration = Duration::from_millis(128);
-pub(crate) const HEADLESS_ANIMATION_TICK_STEP: u32 = 8;
pub(crate) const SELECTION_AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(30);
const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
@@ -125,7 +122,6 @@ pub struct App {
pub(crate) last_pane_click: Option<PaneClickState>,
pub(crate) pending_url_click_sources: HashSet<InputSourceId>,
pub(crate) next_resize_poll: Instant,
- pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) next_agent_manifest_update_check: Option<Instant>,
pub(crate) update_version_check_enabled: bool,
@@ -664,7 +660,6 @@ impl App {
local_sound_playback: true,
toast_config: config.ui.toast.clone(),
keybinds: config.keybinds(),
- spinner_tick: 0,
palette: theme_palette,
theme_name,
theme_runtime,
@@ -753,7 +748,6 @@ impl App {
last_pane_click: None,
pending_url_click_sources: HashSet::new(),
next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
- next_animation_tick: None,
next_auto_update_check: version_check_enabled
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
next_agent_manifest_update_check: manifest_check_enabled
@@ -1046,7 +1040,6 @@ impl App {
}
let now = Instant::now();
- self.sync_animation_timer(now);
self.sync_host_mouse_capture(&mut host_mouse_capture_active)?;
self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?;
@@ -4672,7 +4665,6 @@ mod tests {
app.next_resize_poll = now - Duration::from_millis(1);
app.config_diagnostic_deadline = None;
app.toast_deadline = None;
- app.next_animation_tick = None;
app.next_auto_update_check = None;
app.session_save_deadline = None;
app.state.workspaces.clear();
@@ -4764,7 +4756,6 @@ mod tests {
let now = Instant::now();
app.next_resize_poll = now + Duration::from_millis(300);
app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5));
- app.next_animation_tick = Some(now + Duration::from_millis(100));
app.session_save_deadline = Some(now + Duration::from_millis(200));
assert_eq!(
app.next_loop_deadline(now, false),
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index bd7830b9..ef0a452d 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -1,11 +1,13 @@
-use std::time::{Duration, Instant};
+use std::time::Instant;
+
+#[cfg(test)]
+use std::time::Duration;
use crossterm::terminal;
use super::{
- background_update_check_enabled, pressed_key_identity, App, ANIMATION_INTERVAL,
- AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL,
- SELECTION_AUTOSCROLL_INTERVAL,
+ background_update_check_enabled, pressed_key_identity, App, AUTO_UPDATE_CHECK_INTERVAL,
+ MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
};
fn retain_custom_command_after_wait(
pid: u32,
@@ -224,8 +226,6 @@ impl App {
let mut changed = false;
let mut resized = false;
- self.sync_animation_timer(now);
-
if now >= self.next_resize_poll {
resized = self.handle_resize_poll();
changed |= resized;
@@ -287,15 +287,6 @@ impl App {
}
if self
- .next_animation_tick
- .is_some_and(|deadline| now >= deadline)
- {
- self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1);
- self.next_animation_tick = Some(now + ANIMATION_INTERVAL);
- changed = true;
- }
-
- if self
.selection_autoscroll_deadline
.is_some_and(|deadline| now >= deadline)
{
@@ -336,7 +327,6 @@ impl App {
self.sync_pending_agent_resume_deadline(now);
changed |= self.start_pending_agent_resumes(self.pending_agent_resume_due(now));
}
- self.sync_animation_timer(now);
changed
}
@@ -393,29 +383,6 @@ impl App {
self.sync_agent_metadata_deadline();
}
- pub(crate) fn sync_animation_timer(&mut self, now: Instant) {
- self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL);
- }
-
- pub(crate) fn sync_headless_animation_timer(&mut self, now: Instant) {
- self.sync_animation_timer_with_interval(now, crate::app::HEADLESS_ANIMATION_INTERVAL);
- }
-
- fn sync_animation_timer_with_interval(&mut self, now: Instant, interval: Duration) {
- if self.agent_panel_has_animation() {
- self.next_animation_tick.get_or_insert(now + interval);
- } else {
- self.next_animation_tick = None;
- }
- }
-
- fn agent_panel_has_animation(&self) -> bool {
- self.state
- .workspaces
- .iter()
- .any(|ws| ws.has_working_pane(&self.state.terminals))
- }
-
pub(crate) fn tick_selection_autoscroll(&mut self, now: Instant) {
let Some(autoscroll) = self.state.selection_autoscroll.clone() else {
// Self-heal: state cleared but deadline leaked
@@ -568,7 +535,6 @@ impl App {
self.state.next_pending_agent_notification_deadline(),
self.state.next_managed_agent_deadline(),
self.copy_feedback_deadline,
- self.next_animation_tick,
include_git_refresh
.then(|| self.git_refresh_deadline())
.flatten(),
diff --git a/src/app/state.rs b/src/app/state.rs
index 17b3ae4c..67318f02 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1551,8 +1551,6 @@ pub struct AppState {
pub local_sound_playback: bool,
pub toast_config: ToastConfig,
pub keybinds: Keybinds,
- /// Frame counter for spinner animations (wraps around).
- pub spinner_tick: u32,
/// UI color palette — all sidebar/UI colors centralized for theming.
pub palette: Palette,
/// Currently applied theme name (for settings UI).
@@ -1920,7 +1918,6 @@ impl AppState {
local_sound_playback: false,
toast_config: ToastConfig::default(),
keybinds: Keybinds::default(),
- spinner_tick: 0,
palette: Palette::catppuccin(),
theme_name: "catppuccin".to_string(),
theme_runtime: ThemeRuntimeConfig {
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c1644dcd..7444f10d 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -145,14 +145,6 @@ impl RenderImpact {
}
}
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-enum ScheduledRenderImpact {
- #[default]
- None,
- Animation,
- Full,
-}
-
fn record_render_impact(source: &'static str, impact: RenderImpact) {
let event = match (source, impact) {
("api_requests", RenderImpact::Graphics) => "graphics_render_cause.api_requests",
@@ -484,7 +476,7 @@ impl HeadlessServer {
/// - Drains API requests (from the JSON socket)
/// - Accepts new client connections
/// - Reads client messages and routes input
- /// - Handles scheduled tasks (resize poll, animation, session save, etc.)
+ /// - Handles scheduled tasks (session save, metadata expiry, etc.)
/// - Renders virtually and streams frames to clients
pub async fn run(&mut self) -> io::Result<()> {
crate::logging::startup("server");
@@ -501,7 +493,6 @@ impl HeadlessServer {
let mut needs_render = true;
let mut needs_full_render = true;
let mut needs_graphics_render = false;
- let mut needs_animation_render = false;
loop {
crate::render_prof::event("loop.tick");
@@ -598,19 +589,11 @@ impl HeadlessServer {
// 6. Handle scheduled tasks.
let now = Instant::now();
- match self.handle_scheduled_tasks_headless(now, needs_render) {
- ScheduledRenderImpact::None => {}
- ScheduledRenderImpact::Animation => {
- needs_render = true;
- needs_animation_render = true;
- crate::render_prof::event("animation_render_cause.scheduled_tasks");
- }
- ScheduledRenderImpact::Full => {
- needs_render = true;
- needs_full_render = true;
- needs_graphics_render = false;
- crate::render_prof::event("full_render_cause.scheduled_tasks");
- }
+ if self.handle_scheduled_tasks_headless(now, needs_render) {
+ needs_render = true;
+ needs_full_render = true;
+ needs_graphics_render = false;
+ crate::render_prof::event("full_render_cause.scheduled_tasks");
}
if self.handle_deferred_requests_headless() {
@@ -632,8 +615,6 @@ impl HeadlessServer {
self.stream_host_mouse_capture_mode();
self.stream_host_keyboard_enhancement_flags();
- self.app.sync_headless_animation_timer(now);
-
// 7. Render virtually and stream frames.
if needs_render && self.app.can_render_now(now) {
crate::render_prof::event("render.attempt");
@@ -647,16 +628,7 @@ impl HeadlessServer {
crate::render_prof::event("retained_gate.not_pty_dirty");
}
let mut deferred_graphics = false;
- let rendered_retained = if needs_animation_render
- && !needs_full_render
- && !needs_graphics_render
- && !pty_dirty
- {
- self.render_retained_animation_update_and_stream()
- } else if needs_graphics_render
- && !needs_full_render
- && !needs_animation_render
- && !pty_dirty
+ let rendered_retained = if needs_graphics_render && !needs_full_render && !pty_dirty
{
match self.render_retained_graphics_update_and_stream() {
RetainedGraphicsOutcome::Sent => true,
@@ -667,10 +639,7 @@ impl HeadlessServer {
RetainedGraphicsOutcome::Fallback => false,
}
} else {
- pty_dirty
- && !needs_full_render
- && !needs_animation_render
- && self.render_retained_pty_update_and_stream()
+ pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream()
};
if deferred_graphics {
needs_render = false;
@@ -684,7 +653,6 @@ impl HeadlessServer {
needs_render = false;
needs_full_render = false;
needs_graphics_render = false;
- needs_animation_render = false;
continue;
}
@@ -3428,69 +3396,6 @@ impl HeadlessServer {
}
}
- fn render_retained_animation_update_and_stream(&mut self) -> bool {
- crate::render_prof::event("retained_animation.attempt");
- if !self.retained_pty_update_allowed_by_app_state()
- || self.app.state.config_diagnostic.is_some()
- {
- crate::render_prof::event("retained_animation_fallback.unsafe_app_state");
- return false;
- }
-
- let mut targets = Vec::new();
- for (client_id, (cols, rows), _, _, mode) in
- render_targets(&self.clients, self.foreground_client_id)
- {
- if !matches!(mode, ClientConnectionMode::App) {
- continue;
- }
- let Some(client) = self.clients.get(&client_id) else {
- return false;
- };
- if client.deferred_render() != DeferredRender::None
- || client.graphics_surface_reset_pending
- {
- crate::render_prof::event("retained_animation_fallback.client_state");
- return false;
- }
- let Some(previous) = client.render_state.last_frame().cloned() else {
- crate::render_prof::event("retained_animation_fallback.no_last_frame");
- return false;
- };
- if previous.width != cols || previous.height != rows {
- crate::render_prof::event("retained_animation_fallback.frame_size_mismatch");
- return false;
- }
- targets.push((client_id, previous));
- }
-
- let mut frames = Vec::with_capacity(targets.len());
- for (client_id, previous) in targets {
- let Some(frame) = crate::server::render_stream::render_working_animation_from_frame(
- &mut self.app.state,
- &self.app.terminal_runtimes,
- previous,
- ) else {
- crate::render_prof::event("retained_animation_fallback.render_failed");
- return false;
- };
- frames.push((client_id, frame));
- }
-
- let mut broken_clients = Vec::new();
- let mut sent_all = true;
- for (client_id, frame) in frames {
- sent_all &= self.send_retained_frame_to_client(client_id, frame, &mut broken_clients);
- }
- for client_id in broken_clients {
- self.remove_client_and_resize_if_needed(client_id);
- }
- if sent_all {
- crate::render_prof::event("retained_animation.success");
- }
- sent_all
- }
-
fn render_retained_pty_update_and_stream(&mut self) -> bool {
crate::render_prof::event("retained.attempt");
let retained_started = crate::render_prof::timer();
@@ -3987,15 +3892,8 @@ impl HeadlessServer {
///
/// Similar to `App::handle_scheduled_tasks` but without resize polling
/// (the server doesn't have a terminal to resize).
- fn handle_scheduled_tasks_headless(
- &mut self,
- now: Instant,
- geometry_dirty: bool,
- ) -> ScheduledRenderImpact {
+ fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool {
let mut changed = false;
- let mut animation_changed = false;
-
- self.app.sync_headless_animation_timer(now);
// No resize polling needed — server has no terminal.
// Client resize messages drive size changes instead.
@@ -4051,20 +3949,6 @@ impl HeadlessServer {
if self
.app
- .next_animation_tick
- .is_some_and(|deadline| now >= deadline)
- {
- self.app.state.spinner_tick = self
- .app
- .state
- .spinner_tick
- .wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP);
- self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL);
- animation_changed = true;
- }
-
- if self
- .app
.selection_autoscroll_deadline
.is_some_and(|deadline| now >= deadline)
{
@@ -4119,14 +4003,7 @@ impl HeadlessServer {
.app
.start_pending_agent_resumes(self.app.pending_agent_resume_due(now));
}
- self.app.sync_headless_animation_timer(now);
- if changed {
- ScheduledRenderImpact::Full
- } else if animation_changed {
- ScheduledRenderImpact::Animation
- } else {
- ScheduledRenderImpact::None
- }
+ changed
}
/// Initiates graceful shutdown.
@@ -4950,22 +4827,6 @@ mod tests {
(server, client_rx, pane_id)
}
- fn set_first_test_pane_working(server: &mut HeadlessServer) {
- server.app.state.ensure_test_terminals();
- let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
- let terminal_id = server.app.state.workspaces[0].tabs[0].panes[&pane_id]
- .attached_terminal_id
- .clone();
- let terminal = server
- .app
- .state
- .terminals
- .get_mut(&terminal_id)
- .expect("test terminal");
- terminal.detected_agent = Some(crate::detect::Agent::Pi);
- terminal.state = crate::detect::AgentState::Working;
- }
-
fn assert_frame_data_eq(actual: &FrameData, expected: &FrameData) {
assert_eq!(
(actual.width, actual.height),
@@ -4992,88 +4853,6 @@ mod tests {
}
}
- #[tokio::test]
- async fn retained_animation_matches_full_render_for_mixed_client_sizes() {
- fn test_server_with_mobile_client() -> (
- HeadlessServer,
- std::sync::mpsc::Receiver<Vec<u8>>,
- std::sync::mpsc::Receiver<Vec<u8>>,
- ) {
- let (mut server, desktop_rx, _) = retained_test_server(
- b"\x1b]8;;https://example.com\x1b\\\x1b[4:3mlinked\x1b[0m\x1b]8;;\x1b\\",
- );
- set_first_test_pane_working(&mut server);
- let (mobile_tx, _mobile_control_rx, mobile_rx) = test_client_writer();
- server.clients.insert(
- 2,
- ClientConnection::new(
- (44, 20),
- crate::kitty_graphics::HostCellSize::default(),
- crate::terminal_theme::TerminalTheme::default(),
- None,
- 2,
- RenderEncoding::SemanticFrame,
- Some(mobile_tx),
- ),
- );
- (server, desktop_rx, mobile_rx)
- }
-
- let (mut retained_server, retained_desktop_rx, retained_mobile_rx) =
- test_server_with_mobile_client();
- let (mut full_server, full_desktop_rx, full_mobile_rx) = test_server_with_mobile_client();
-
- retained_server.render_and_stream();
- let _ = retained_desktop_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("retained desktop baseline");
- let _ = retained_mobile_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("retained mobile baseline");
- full_server.render_and_stream();
- let _ = full_desktop_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("full desktop baseline");
- let _ = full_mobile_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("full mobile baseline");
-
- retained_server.app.state.spinner_tick = app::HEADLESS_ANIMATION_TICK_STEP;
- full_server.app.state.spinner_tick = app::HEADLESS_ANIMATION_TICK_STEP;
-
- assert!(retained_server.render_retained_animation_update_and_stream());
- full_server.render_and_stream();
-
- let retained_desktop = read_server_frame(
- retained_desktop_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("retained desktop animation"),
- );
- let retained_mobile = read_server_frame(
- retained_mobile_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("retained mobile animation"),
- );
- let full_desktop = read_server_frame(
- full_desktop_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("full desktop animation"),
- );
- let full_mobile = read_server_frame(
- full_mobile_rx
- .recv_timeout(Duration::from_millis(100))
- .expect("full mobile animation"),
- );
-
- assert_frame_data_eq(&retained_desktop, &full_desktop);
- assert_frame_data_eq(&retained_mobile, &full_mobile);
- assert_eq!(
- retained_server.app.state.view.layout,
- crate::app::state::ViewLayout::Desktop
- );
- assert!(!retained_desktop.hyperlinks.is_empty());
- }
-
#[test]
fn foreground_client_applies_client_keybindings() {
let mut server = test_headless_server();
@@ -6161,10 +5940,7 @@ next_tab = ""
Some("short lived")
);
- assert_eq!(
- server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false),
- ScheduledRenderImpact::Full
- );
+ assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false));
assert_eq!(server.app.agent_metadata_deadline, None);
assert_eq!(
@@ -6195,38 +5971,12 @@ next_tab = ""
}
#[test]
- fn headless_scheduled_animation_is_distinct_but_other_changes_win() {
- let mut server = test_headless_server();
- server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("working")];
- set_first_test_pane_working(&mut server);
- let now = Instant::now();
- server.app.next_animation_tick = Some(now);
-
- assert_eq!(
- server.handle_scheduled_tasks_headless(now, false),
- ScheduledRenderImpact::Animation
- );
-
- server.app.next_animation_tick = Some(now);
- server.app.state.config_diagnostic = Some("expired".into());
- server.app.config_diagnostic_deadline = Some(now);
- assert_eq!(
- server.handle_scheduled_tasks_headless(now, false),
- ScheduledRenderImpact::Full
- );
- assert!(server.app.state.config_diagnostic.is_none());
- }
-
- #[test]
fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() {
let mut server = test_headless_server();
let now = Instant::now();
server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1));
- assert_eq!(
- server.handle_scheduled_tasks_headless(now, false),
- ScheduledRenderImpact::None
- );
+ assert!(!server.handle_scheduled_tasks_headless(now, false));
assert_eq!(server.app.next_agent_manifest_update_check, None);
}
@@ -6282,10 +6032,7 @@ next_tab = ""
});
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
- assert_eq!(
- server.handle_scheduled_tasks_headless(Instant::now(), true),
- ScheduledRenderImpact::None
- );
+ assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true));
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
assert!(server
.app
@@ -6339,10 +6086,7 @@ next_tab = ""
});
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
- assert_eq!(
- server.handle_scheduled_tasks_headless(Instant::now(), false),
- ScheduledRenderImpact::None
- );
+ assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false));
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
assert!(server
.app
@@ -9698,10 +9442,7 @@ next_tab = ""
}
);
let deadline = server.app.toast_deadline.expect("api toast deadline");
- assert_eq!(
- server.handle_scheduled_tasks_headless(deadline, false),
- ScheduledRenderImpact::Full
- );
+ assert!(server.handle_scheduled_tasks_headless(deadline, false));
assert!(server.app.state.toast.is_none());
assert!(server.app.toast_deadline.is_none());
}
@@ -9828,10 +9569,7 @@ next_tab = ""
.state
.next_pending_agent_notification_deadline()
.expect("pending notification deadline");
- assert_eq!(
- server.handle_scheduled_tasks_headless(deadline, false),
- ScheduledRenderImpact::Full
- );
+ assert!(server.handle_scheduled_tasks_headless(deadline, false));
let first = read_server_message(
client_control_rx
@@ -9919,10 +9657,7 @@ next_tab = ""
.state
.next_pending_agent_notification_deadline()
.expect("pending notification deadline");
- assert_eq!(
- server.handle_scheduled_tasks_headless(deadline, false),
- ScheduledRenderImpact::Full
- );
+ assert!(server.handle_scheduled_tasks_headless(deadline, false));
let first = read_server_message(
client_control_rx
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index 41a5781d..f1f6c428 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -345,45 +345,6 @@ pub(crate) fn render_virtual_with_runtime_registry(
(buffer, cursor)
}
-pub(crate) fn render_working_animation_from_frame(
- app_state: &mut AppState,
- terminal_runtimes: &TerminalRuntimeRegistry,
- mut frame: FrameData,
-) -> Option<FrameData> {
- let area = Rect::new(0, 0, frame.width, frame.height);
- crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area);
-
- let backend = CursorTrackingBackend::new(area.width, area.height);
- let mut terminal = ratatui::Terminal::new(backend).ok()?;
- terminal
- .draw(|frame| {
- crate::ui::render_working_animation(app_state, terminal_runtimes, frame);
- })
- .ok()?;
-
- let updated_area = if app_state.view.layout == crate::app::state::ViewLayout::Mobile {
- app_state.view.mobile_header_rect
- } else {
- app_state.view.sidebar_rect
- };
- if updated_area.x.saturating_add(updated_area.width) > frame.width
- || updated_area.y.saturating_add(updated_area.height) > frame.height
- {
- return None;
- }
-
- let rendered = terminal.backend().buffer();
- frame.graphics.clear();
- for y in updated_area.y..updated_area.y.saturating_add(updated_area.height) {
- for x in updated_area.x..updated_area.x.saturating_add(updated_area.width) {
- let index = usize::from(y) * usize::from(frame.width) + usize::from(x);
- let cell = rendered.cell((x, y))?;
- frame.cells[index] = crate::protocol::CellData::from_ratatui_cell(cell);
- }
- }
- Some(frame)
-}
-
fn popup_terminal_cursor(
app_state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
diff --git a/src/ui.rs b/src/ui.rs
index 59145dd2..bf67d9fd 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -105,15 +105,6 @@ use crate::terminal::TerminalRuntimeRegistry;
const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
-// Braille spinner frames — smooth rotation
-const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
-
-/// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame.
-/// We want ~8 updates/sec so divide by 8.
-pub(super) fn spinner_frame(tick: u32) -> &'static str {
- SPINNERS[(tick as usize / 8) % SPINNERS.len()]
-}
-
/// Compute view geometry and reconcile pane sizes.
/// Called before render to separate mutation from drawing.
#[cfg_attr(not(test), allow(dead_code))]
@@ -401,7 +392,7 @@ pub fn render_with_runtime_registry(
let tab_bar_area = app.view.tab_bar_rect;
let terminal_area = app.view.terminal_area;
- render_working_animation(app, terminal_runtimes, frame);
+ render_navigation_chrome(app, terminal_runtimes, frame);
if app.view.layout != ViewLayout::Mobile {
render_tab_bar(app, frame, tab_bar_area);
}
@@ -452,7 +443,7 @@ pub fn render_with_runtime_registry(
}
}
-pub(crate) fn render_working_animation(
+fn render_navigation_chrome(
app: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
frame: &mut Frame,
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index 10b58af0..299a152b 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -11,7 +11,7 @@ use super::sidebar::{
next_entry_is_indented_workspace, workspace_list_entries_expanded, AgentPanelEntry,
WorkspaceListEntry,
};
-use super::status::{agent_icon, state_dot};
+use super::status::state_dot;
use super::text::{display_width_u16, truncate_end};
use crate::app::state::{Palette, ToastKind, ToastNotification};
use crate::app::AppState;
@@ -326,14 +326,7 @@ fn render_header_status(
};
let (state, seen) = ws.aggregate_state(&app.terminals);
- let (dot, dot_style) = if matches!(state, AgentState::Working) {
- (
- super::spinner_frame(app.spinner_tick),
- Style::default().fg(p.yellow),
- )
- } else {
- state_dot(state, seen, p)
- };
+ let (dot, dot_style) = state_dot(state, seen, 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)
@@ -539,7 +532,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) = agent_icon(entry.state, entry.seen, app.spinner_tick, p);
+ let (icon, icon_style) = state_dot(entry.state, entry.seen, p);
let title = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(icon, icon_style.bg(bg)),
diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs
index 28eb8530..9269394b 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::{agent_icon, state_label_color},
+ status::{state_dot, state_label_color},
text::{display_width_u16, middle_elide, truncate_end},
widgets::{panel_contrast_fg, render_panel_shell},
};
@@ -66,7 +66,6 @@ fn render_search(app: &AppState, frame: &mut Frame, area: Rect) {
&mut spans,
crate::detect::AgentState::Blocked,
true,
- app.spinner_tick,
"blocked",
app,
),
@@ -74,7 +73,6 @@ fn render_search(app: &AppState, frame: &mut Frame, area: Rect) {
&mut spans,
crate::detect::AgentState::Working,
true,
- app.spinner_tick,
"working",
app,
),
@@ -82,7 +80,6 @@ fn render_search(app: &AppState, frame: &mut Frame, area: Rect) {
&mut spans,
crate::detect::AgentState::Idle,
true,
- app.spinner_tick,
"idle",
app,
),
@@ -90,7 +87,6 @@ fn render_search(app: &AppState, frame: &mut Frame, area: Rect) {
&mut spans,
crate::detect::AgentState::Idle,
false,
- app.spinner_tick,
"done",
app,
),
@@ -114,11 +110,10 @@ fn push_state_chip(
spans: &mut Vec<Span<'static>>,
state: crate::detect::AgentState,
seen: bool,
- tick: u32,
label: &'static str,
app: &AppState,
) {
- let (icon, icon_style) = agent_icon(state, seen, tick, &app.palette);
+ let (icon, icon_style) = state_dot(state, seen, &app.palette);
spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD)));
spans.push(Span::raw(" "));
spans.push(Span::styled(
@@ -206,7 +201,7 @@ fn render_row(
} else {
Style::default().fg(p.subtext0).bg(p.panel_bg)
};
- let (status_icon, status_style) = agent_icon(row.status, row.seen, app.spinner_tick, p);
+ let (status_icon, status_style) = state_dot(row.status, row.seen, p);
let status_style = if selected {
base_style.add_modifier(Modifier::BOLD)
} else if context_only {
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 09078c17..c7ba659a 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::{agent_icon, state_dot, state_label, state_label_color};
+use super::status::{state_dot, 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};
@@ -850,7 +850,7 @@ 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) = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
+ let (icon, icon_style) = state_dot(detail.state, detail.seen, p);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{position:<2}"), position_style),
@@ -1496,7 +1496,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 = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
+ let state_icon = state_dot(detail.state, detail.seen, p);
for (row_index, resolved) in rows.iter().take(height as usize).enumerate() {
let mut spans = vec![Span::raw(if row_index == 0 { " " } else { " " })];
@@ -2161,7 +2161,7 @@ rows = [[{ token = "git_status", fg = "#123456" }]]
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(detail_area.x, tenth_row)].symbol(), "1");
assert_eq!(buffer[(detail_area.x + 1, tenth_row)].symbol(), "0");
- assert_eq!(buffer[(detail_area.x + 2, tenth_row)].symbol(), "○");
+ assert_eq!(buffer[(detail_area.x + 2, tenth_row)].symbol(), "·");
}
#[test]
@@ -2205,7 +2205,7 @@ 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)
diff --git a/src/ui/status.rs b/src/ui/status.rs
index ede38b85..a7399285 100644
--- a/src/ui/status.rs
+++ b/src/ui/status.rs
@@ -203,21 +203,6 @@ pub(super) fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static
}
}
-pub(super) fn agent_icon(
- state: AgentState,
- seen: bool,
- tick: u32,
- p: &Palette,
-) -> (&'static str, Style) {
- match (state, seen) {
- (AgentState::Blocked, _) => ("◉", Style::default().fg(p.red)),
- (AgentState::Working, _) => (super::spinner_frame(tick), 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_label(state: AgentState, seen: bool) -> &'static str {
match (state, seen) {
(AgentState::Blocked, _) => "blocked",
@@ -260,6 +245,22 @@ mod tests {
}
#[test]
+ fn state_dots_use_aligned_static_workspace_marks() {
+ 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),
+ ] {
+ let (actual_symbol, style) = state_dot(state, seen, &palette);
+ assert_eq!(actual_symbol, symbol);
+ assert_eq!(style.fg, Some(color));
+ }
+ }
+
+ #[test]
fn toast_rect_uses_configured_corner() {
let area = Rect::new(10, 20, 100, 40);
let toast = toast();
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
index 79b5a021..29621d4d 100644
--- a/src/workspace/aggregate.rs
+++ b/src/workspace/aggregate.rs
@@ -26,14 +26,6 @@ pub struct PaneDetail {
}
impl Tab {
- pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> bool {
- self.panes.values().any(|pane| {
- terminals
- .get(&pane.attached_terminal_id)
- .is_some_and(|terminal| terminal.state == AgentState::Working)
- })
- }
-
fn pane_details(
&self,
terminals: &HashMap<TerminalId, TerminalState>,
@@ -107,10 +99,6 @@ impl Workspace {
.unwrap_or((AgentState::Unknown, true))
}
- pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> bool {
- self.tabs.iter().any(|tab| tab.has_working_pane(terminals))
- }
-
pub fn pane_details(&self, terminals: &HashMap<TerminalId, TerminalState>) -> Vec<PaneDetail> {
let multi_tab = self.tabs.len() > 1;
self.tabs
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 0ee9aa2b..6f1ab82b 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -548,6 +548,15 @@ fn decode_frame_payload(payload: &[u8]) -> io::Result<FrameWire> {
})
}
+fn frame_contains_colored_symbol(frame: &FrameWire, symbol: &str, rgb: (u8, u8, u8)) -> bool {
+ let (r, g, b) = rgb;
+ let fg = 0x02_00_00_00 | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b);
+ frame
+ .cells
+ .iter()
+ .any(|cell| cell.symbol == symbol && cell.fg == fg)
+}
+
fn frame_contains_text(frame: &FrameWire, needle: &str) -> bool {
if frame.cells.is_empty() {
return false;
@@ -836,9 +845,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
client_handshake(&mut client_b, CURRENT_PROTOCOL, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
- ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
- .iter()
- .any(|symbol| frame_contains_text(frame, symbol))
+ frame_contains_colored_symbol(frame, "●", (249, 226, 175))
})
.expect("frame decoding should succeed");
assert!(
@@ -857,7 +864,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
let saw_blocked_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
- frame_contains_text(frame, "◉")
+ frame_contains_colored_symbol(frame, "●", (243, 139, 168))
})
.expect("frame decoding should succeed");
assert!(
diff --git a/website/index.html b/website/index.html
index 021a37e6..0d9faf91 100644
--- a/website/index.html
+++ b/website/index.html
@@ -223,9 +223,9 @@
</div>
<div class="mock-sidebar-half mock-sidebar-agents">
<div class="mock-agent-title"><span>agents</span><span>grouped</span></div>
- <div class="mock-agent working active" data-agent="herdr" data-kind="claude" data-ws-link="herdr" data-tab-link="main"><span class="braille-spinner"></span><div><strong>herdr</strong><small>working · claude</small></div></div>
- <div class="mock-agent" data-agent="explore" data-kind="opencode" data-ws-link="herdr" data-tab-link="opencode"><span>✓</span><div><strong>explore</strong><small>idle · opencode</small></div></div>
- <div class="mock-agent blocked" data-agent="llm-proxy" data-kind="claude" data-ws-link="llm-proxy" data-tab-link="main"><span>◉</span><div><strong>llm-proxy</strong><small>blocked · claude</small></div></div>
+ <div class="mock-agent working active" data-agent="herdr" data-kind="claude" data-ws-link="herdr" data-tab-link="main"><span>●</span><div><strong>herdr</strong><small>working · claude</small></div></div>
+ <div class="mock-agent" data-agent="explore" data-kind="opencode" data-ws-link="herdr" data-tab-link="opencode"><span>○</span><div><strong>explore</strong><small>idle · opencode</small></div></div>
+ <div class="mock-agent blocked" data-agent="llm-proxy" data-kind="claude" data-ws-link="llm-proxy" data-tab-link="main"><span>●</span><div><strong>llm-proxy</strong><small>blocked · claude</small></div></div>
<div class="mock-agent done" data-agent="qmp" data-kind="codex" data-ws-link="qmp" data-tab-link="main"><span>●</span><div><strong>qmp</strong><small>done · codex</small></div></div>
</div>
</aside>