perf: replace agent spinners with static status marks

This commit is contained in:
Ogulcan Celik 2026-07-28 14:56:51 +03:00
parent 1491b7dd9c
commit 81f355fada
14 changed files with 67 additions and 441 deletions

View File

@ -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

View File

@ -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),

View File

@ -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(),

View File

@ -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 {

View File

@ -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<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!(
@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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)),

View File

@ -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 {

View File

@ -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)

View File

@ -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);

View File

@ -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

View File

@ -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!(

View File

@ -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>