perf: replace agent spinners with static status marks
This commit is contained in:
parent
1491b7dd9c
commit
81f355fada
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
13
src/ui.rs
13
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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue