aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-07-25 23:20:08 +0300
committerGitHub <noreply@github.com>2026-07-25 23:20:08 +0300
commitb01fc37e66aac45c7fa9eccc826d793d68608a0a (patch)
treeadd6d6755c0817289f2dfdb9c16924943fa57485
parentda40b1aa347d07e60057182f81e5bcd1d12e9fb1 (diff)
perf: avoid full pane renders for spinner animation (#1868)
-rw-r--r--src/protocol/wire.rs24
-rw-r--r--src/server/headless.rs280
-rw-r--r--src/server/render_stream.rs39
-rw-r--r--src/ui.rs27
4 files changed, 335 insertions, 35 deletions
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index d724a5ae..0d4b14bb 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -444,6 +444,19 @@ pub struct CellData {
pub hyperlink: Option<u32>,
}
+impl CellData {
+ pub(crate) fn from_ratatui_cell(cell: &ratatui::buffer::Cell) -> Self {
+ Self {
+ symbol: cell.symbol().to_owned(),
+ fg: color_to_u32(cell.fg),
+ bg: color_to_u32(cell.bg),
+ modifier: modifier_to_u16(cell.modifier),
+ skip: cell.skip,
+ hyperlink: None,
+ }
+ }
+}
+
/// Cursor shape encoded as a DECSCUSR parameter.
///
/// 0 = terminal default, 1 = blinking block, 2 = steady block,
@@ -527,14 +540,9 @@ impl FrameData {
index
}))
});
- cells.push(CellData {
- symbol: cell.symbol().to_owned(),
- fg: color_to_u32(cell.fg),
- bg: color_to_u32(cell.bg),
- modifier: modifier_to_u16(cell.modifier),
- skip: cell.skip,
- hyperlink,
- });
+ let mut cell = CellData::from_ratatui_cell(cell);
+ cell.hyperlink = hyperlink;
+ cells.push(cell);
}
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 99702512..94721c38 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -145,6 +145,14 @@ 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",
@@ -493,6 +501,7 @@ 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");
@@ -589,11 +598,19 @@ impl HeadlessServer {
// 6. Handle scheduled tasks.
let now = Instant::now();
- 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");
+ 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_deferred_requests_headless() {
@@ -630,7 +647,16 @@ impl HeadlessServer {
crate::render_prof::event("retained_gate.not_pty_dirty");
}
let mut deferred_graphics = false;
- let rendered_retained = if needs_graphics_render && !needs_full_render && !pty_dirty
+ 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
{
match self.render_retained_graphics_update_and_stream() {
RetainedGraphicsOutcome::Sent => true,
@@ -641,7 +667,10 @@ impl HeadlessServer {
RetainedGraphicsOutcome::Fallback => false,
}
} else {
- pty_dirty && !needs_full_render && self.render_retained_pty_update_and_stream()
+ pty_dirty
+ && !needs_full_render
+ && !needs_animation_render
+ && self.render_retained_pty_update_and_stream()
};
if deferred_graphics {
needs_render = false;
@@ -655,6 +684,7 @@ impl HeadlessServer {
needs_render = false;
needs_full_render = false;
needs_graphics_render = false;
+ needs_animation_render = false;
continue;
}
@@ -3396,6 +3426,69 @@ 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();
@@ -3892,8 +3985,13 @@ 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) -> bool {
+ fn handle_scheduled_tasks_headless(
+ &mut self,
+ now: Instant,
+ geometry_dirty: bool,
+ ) -> ScheduledRenderImpact {
let mut changed = false;
+ let mut animation_changed = false;
self.app.sync_headless_animation_timer(now);
@@ -3960,7 +4058,7 @@ impl HeadlessServer {
.spinner_tick
.wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP);
self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL);
- changed = true;
+ animation_changed = true;
}
if self
@@ -4020,7 +4118,13 @@ impl HeadlessServer {
.start_pending_agent_resumes(self.app.pending_agent_resume_due(now));
}
self.app.sync_headless_animation_timer(now);
- changed
+ if changed {
+ ScheduledRenderImpact::Full
+ } else if animation_changed {
+ ScheduledRenderImpact::Animation
+ } else {
+ ScheduledRenderImpact::None
+ }
}
/// Initiates graceful shutdown.
@@ -4825,6 +4929,22 @@ 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),
@@ -4851,6 +4971,88 @@ 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();
@@ -5938,7 +6140,10 @@ next_tab = ""
Some("short lived")
);
- assert!(server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false));
+ assert_eq!(
+ server.handle_scheduled_tasks_headless(deadline + Duration::from_millis(1), false),
+ ScheduledRenderImpact::Full
+ );
assert_eq!(server.app.agent_metadata_deadline, None);
assert_eq!(
@@ -5969,12 +6174,38 @@ 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!(!server.handle_scheduled_tasks_headless(now, false));
+ assert_eq!(
+ server.handle_scheduled_tasks_headless(now, false),
+ ScheduledRenderImpact::None
+ );
assert_eq!(server.app.next_agent_manifest_update_check, None);
}
@@ -6030,7 +6261,10 @@ next_tab = ""
});
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
- assert!(!server.handle_scheduled_tasks_headless(Instant::now(), true));
+ assert_eq!(
+ server.handle_scheduled_tasks_headless(Instant::now(), true),
+ ScheduledRenderImpact::None
+ );
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
assert!(server
.app
@@ -6084,7 +6318,10 @@ next_tab = ""
});
server.app.pending_agent_resume_deadline = Some(Instant::now() - Duration::from_millis(1));
- assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false));
+ assert_eq!(
+ server.handle_scheduled_tasks_headless(Instant::now(), false),
+ ScheduledRenderImpact::None
+ );
assert!(server.app.terminal_runtimes.get(&terminal_id).is_none());
assert!(server
.app
@@ -9311,7 +9548,10 @@ next_tab = ""
}
);
let deadline = server.app.toast_deadline.expect("api toast deadline");
- assert!(server.handle_scheduled_tasks_headless(deadline, false));
+ assert_eq!(
+ server.handle_scheduled_tasks_headless(deadline, false),
+ ScheduledRenderImpact::Full
+ );
assert!(server.app.state.toast.is_none());
assert!(server.app.toast_deadline.is_none());
}
@@ -9438,7 +9678,10 @@ next_tab = ""
.state
.next_pending_agent_notification_deadline()
.expect("pending notification deadline");
- assert!(server.handle_scheduled_tasks_headless(deadline, false));
+ assert_eq!(
+ server.handle_scheduled_tasks_headless(deadline, false),
+ ScheduledRenderImpact::Full
+ );
let first = read_server_message(
client_control_rx
@@ -9526,7 +9769,10 @@ next_tab = ""
.state
.next_pending_agent_notification_deadline()
.expect("pending notification deadline");
- assert!(server.handle_scheduled_tasks_headless(deadline, false));
+ assert_eq!(
+ server.handle_scheduled_tasks_headless(deadline, false),
+ ScheduledRenderImpact::Full
+ );
let first = read_server_message(
client_control_rx
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index f1f6c428..41a5781d 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -345,6 +345,45 @@ pub(crate) fn render_virtual_with_runtime_registry(
(buffer, cursor)
}
+pub(crate) fn render_working_animation_from_frame(
+ app_state: &mut AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ mut frame: FrameData,
+) -> Option<FrameData> {
+ let area = Rect::new(0, 0, frame.width, frame.height);
+ crate::ui::compute_view_without_resizing_panes(app_state, terminal_runtimes, area);
+
+ let backend = CursorTrackingBackend::new(area.width, area.height);
+ let mut terminal = ratatui::Terminal::new(backend).ok()?;
+ terminal
+ .draw(|frame| {
+ crate::ui::render_working_animation(app_state, terminal_runtimes, frame);
+ })
+ .ok()?;
+
+ let updated_area = if app_state.view.layout == crate::app::state::ViewLayout::Mobile {
+ app_state.view.mobile_header_rect
+ } else {
+ app_state.view.sidebar_rect
+ };
+ if updated_area.x.saturating_add(updated_area.width) > frame.width
+ || updated_area.y.saturating_add(updated_area.height) > frame.height
+ {
+ return None;
+ }
+
+ let rendered = terminal.backend().buffer();
+ frame.graphics.clear();
+ for y in updated_area.y..updated_area.y.saturating_add(updated_area.height) {
+ for x in updated_area.x..updated_area.x.saturating_add(updated_area.width) {
+ let index = usize::from(y) * usize::from(frame.width) + usize::from(x);
+ let cell = rendered.cell((x, y))?;
+ frame.cells[index] = crate::protocol::CellData::from_ratatui_cell(cell);
+ }
+ }
+ Some(frame)
+}
+
fn popup_terminal_cursor(
app_state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
diff --git a/src/ui.rs b/src/ui.rs
index 6d5c02f1..ed1f6a7d 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -394,19 +394,10 @@ pub fn render_with_runtime_registry(
terminal_runtimes: &TerminalRuntimeRegistry,
frame: &mut Frame,
) {
- let sidebar_area = app.view.sidebar_rect;
let tab_bar_area = app.view.tab_bar_rect;
let terminal_area = app.view.terminal_area;
- if app.view.layout == ViewLayout::Mobile {
- render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect);
- } else if sidebar_area.width > 0 {
- if app.sidebar_collapsed {
- render_sidebar_collapsed(app, frame, sidebar_area);
- } else {
- render_sidebar(app, terminal_runtimes, frame, sidebar_area);
- }
- }
+ render_working_animation(app, terminal_runtimes, frame);
if app.view.layout != ViewLayout::Mobile {
render_tab_bar(app, frame, tab_bar_area);
}
@@ -457,6 +448,22 @@ pub fn render_with_runtime_registry(
}
}
+pub(crate) fn render_working_animation(
+ app: &AppState,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ frame: &mut Frame,
+) {
+ if app.view.layout == ViewLayout::Mobile {
+ render_mobile_header(app, terminal_runtimes, frame, app.view.mobile_header_rect);
+ } else if app.view.sidebar_rect.width > 0 {
+ if app.sidebar_collapsed {
+ render_sidebar_collapsed(app, frame, app.view.sidebar_rect);
+ } else {
+ render_sidebar(app, terminal_runtimes, frame, app.view.sidebar_rect);
+ }
+ }
+}
+
fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) {
let has_config_diagnostic = app.config_diagnostic.is_some();
if let Some(message) = &app.config_diagnostic {