From 81f355fadac7d0d45b077dfc28f9f679add6bbb6 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 28 Jul 2026 14:56:51 +0300 Subject: [PATCH] perf: replace agent spinners with static status marks --- docs/next/CHANGELOG.md | 1 + src/app/mod.rs | 9 -- src/app/runtime.rs | 46 +----- src/app/state.rs | 3 - src/server/headless.rs | 299 ++---------------------------------- src/server/render_stream.rs | 39 ----- src/ui.rs | 13 +- src/ui/mobile.rs | 13 +- src/ui/navigator.rs | 11 +- src/ui/sidebar.rs | 10 +- src/ui/status.rs | 31 ++-- src/workspace/aggregate.rs | 12 -- tests/cross_area.rs | 15 +- website/index.html | 6 +- 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, pub(crate) pending_url_click_sources: HashSet, pub(crate) next_resize_poll: Instant, - pub(crate) next_animation_tick: Option, pub(crate) next_auto_update_check: Option, pub(crate) next_agent_manifest_update_check: Option, 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; @@ -286,15 +286,6 @@ impl App { changed = true; } - 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. @@ -4049,20 +3947,6 @@ impl HeadlessServer { changed = true; } - 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 @@ -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>, - std::sync::mpsc::Receiver>, - ) { - 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!( @@ -6194,39 +5970,13 @@ 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 { - 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>, 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", @@ -259,6 +244,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); 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) -> 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, @@ -107,10 +99,6 @@ impl Workspace { .unwrap_or((AgentState::Unknown, true)) } - pub fn has_working_pane(&self, terminals: &HashMap) -> bool { - self.tabs.iter().any(|tab| tab.has_working_pane(terminals)) - } - pub fn pane_details(&self, terminals: &HashMap) -> Vec { 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 { }) } +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 @@
agentsgrouped
-
herdrworking · claude
-
exploreidle · opencode
-
llm-proxyblocked · claude
+
herdrworking · claude
+
exploreidle · opencode
+
llm-proxyblocked · claude
qmpdone · codex