From 9a2a7af5402f2bc67ab24c8b4c14c6dd20a43bb2 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Thu, 3 Sep 2026 14:24:33 +0300 Subject: perf: compress idle terminal scrollback (#3556) --- src/server/headless/client_views.rs | 113 ++++++++++++++++++++++++- src/server/headless/notifications.rs | 2 + src/server/headless/tests/mod.rs | 160 ++++++++++++++++++++++++++++++++++- 3 files changed, 269 insertions(+), 6 deletions(-) (limited to 'src/server') diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 3a0f895c..2d49a50a 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -281,6 +281,33 @@ impl HeadlessServer { ) } + fn public_request_may_change_geometry(method: &api::schema::Method) -> bool { + use api::schema::Method; + + matches!( + method, + Method::CommandInvoke(_) + | Method::LayoutSetSplitRatio(_) + | Method::PaneClose(_) + | Method::PaneEditScrollback(_) + | Method::PaneFocus(_) + | Method::PaneFocusDirection(_) + | Method::PaneResize(_) + | Method::PaneSplit(_) + | Method::PaneSwap(_) + | Method::PaneZoom(_) + | Method::TabClose(_) + | Method::TabCreate(_) + | Method::TabFocus(_) + | Method::WorkspaceClose(_) + | Method::WorkspaceCreate(_) + | Method::WorkspaceFocus(_) + | Method::WorktreeCreate(_) + | Method::WorktreeOpen(_) + | Method::WorktreeRemove(_) + ) + } + pub(super) fn deferred_endpoint_navigation_tab_id(response: &[u8]) -> Option { let response = serde_json::from_slice::(response).ok()?; if response.pointer("/result/type")?.as_str()? != "worktree_created" { @@ -521,6 +548,18 @@ impl HeadlessServer { client_id: u64, target: crate::ui::TabSurfaceTarget, start_pending_agent_resumes: bool, + ) -> bool { + if !self.resize_shell_tab_geometry_to_target(client_id, target) { + return false; + } + self.finish_shell_tab_geometry_change(start_pending_agent_resumes); + true + } + + fn resize_shell_tab_geometry_to_target( + &mut self, + client_id: u64, + target: crate::ui::TabSurfaceTarget, ) -> bool { let Some(client) = self.clients.get(&client_id) else { return false; @@ -562,7 +601,6 @@ impl HeadlessServer { { let _ = resize_popup_runtime(&self.app, Rect::new(0, 0, cols, rows), cell_size); } - self.finish_shell_tab_geometry_change(start_pending_agent_resumes); true } @@ -581,6 +619,66 @@ impl HeadlessServer { self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes) } + pub(super) fn reapply_controlled_shell_tab_geometry( + &mut self, + start_pending_agent_resumes: bool, + ) -> bool { + let mut viewed_tabs = HashMap::>::new(); + for (&client_id, client) in &self.clients { + if !client.is_shell_client() || client.writer.is_none() { + continue; + } + let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { + continue; + }; + viewed_tabs.entry(tab_id).or_default().push(client_id); + } + for viewers in viewed_tabs.values_mut() { + viewers.sort_unstable(); + } + for (tab_id, viewers) in viewed_tabs { + let controller_is_viewing = self + .tab_geometry_controllers + .get(&tab_id) + .is_some_and(|controller| viewers.contains(controller)); + if !controller_is_viewing { + self.tab_geometry_controllers.insert(tab_id, viewers[0]); + } + } + + if self.resize_tabs_for_only_shell_client(start_pending_agent_resumes) { + return true; + } + + let mut controlled_tabs = self + .tab_geometry_controllers + .iter() + .filter_map(|(tab_id, &client_id)| { + self.app + .parse_tab_id(tab_id) + .map(|(workspace_index, tab_index)| { + ( + tab_id.clone(), + client_id, + crate::ui::TabSurfaceTarget { + workspace_index, + tab_index, + }, + ) + }) + }) + .collect::>(); + controlled_tabs.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + let mut reapplied = false; + for (_, client_id, target) in controlled_tabs { + reapplied |= self.resize_shell_tab_geometry_to_target(client_id, target); + } + if reapplied { + self.finish_shell_tab_geometry_change(start_pending_agent_resumes); + } + reapplied + } + pub(super) fn claim_shell_tab_geometry( &mut self, client_id: u64, @@ -683,6 +781,7 @@ impl HeadlessServer { ) -> bool { let target_before = self.default_shell_target(); let popup_before = self.app.state.popup_pane.is_some(); + let method_claims_geometry = Self::public_request_may_change_geometry(&msg.request.method); let explicit_public_focus_target = match &msg.request.method { api::schema::Method::WorkspaceFocus(params) => self .app @@ -742,7 +841,9 @@ impl HeadlessServer { if reconcile || target_changed || self.app.state.popup_pane.is_some() != popup_before { self.reconcile_client_shell_locations(); } - changed + let geometry_changed = + method_claims_geometry && self.reapply_controlled_shell_tab_geometry(false); + changed | geometry_changed } pub(super) fn handle_client_shell_api_request( @@ -782,8 +883,12 @@ impl HeadlessServer { ); } let geometry_changed = method_claims_geometry - && (self.claim_shell_tab_geometry(client_id, false) - || self.resize_shell_tab_if_controller(client_id, false)); + && if reconcile { + self.reapply_controlled_shell_tab_geometry(false) + } else { + self.claim_shell_tab_geometry(client_id, false) + || self.resize_shell_tab_if_controller(client_id, false) + }; changed | navigation_changed | geometry_changed } } diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index 6764dd2d..dd661809 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -652,6 +652,7 @@ impl HeadlessServer { } self.reconcile_client_shell_locations(); self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); + self.reapply_controlled_shell_tab_geometry(false); for (pane_id, terminal_id) in shutdown_terminals { if self.app.find_pane(pane_id).is_none() { self.shutdown_terminal_stream_clients( @@ -698,6 +699,7 @@ impl HeadlessServer { } self.reconcile_client_shell_locations(); self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); + self.reapply_controlled_shell_tab_geometry(false); if self.app.find_pane(pane_id_val).is_none() { if let Some(terminal_id) = terminal_id { diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 29a4cfbf..77a16fc7 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1577,6 +1577,108 @@ async fn repeated_layout_action_reapplies_controller_geometry() { shutdown_test_runtimes(&mut server); } +#[tokio::test] +async fn public_close_reapplies_controller_geometry() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("public-close-geometry"); + let first_pane = workspace.tabs[0].root_pane; + let second_pane = workspace.test_split(ratatui::layout::Direction::Vertical); + workspace.insert_test_runtime( + first_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + workspace.insert_test_runtime( + second_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_pane_id = server.app.public_pane_id(0, second_pane).unwrap(); + + let (control, _) = connect_test_shell(&mut server, 66, 100, 30); + let _ = control.recv().expect("snapshot"); + let shrunk = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(); + assert!(shrunk.0 < 30); + + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + assert!( + server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage { + request: crate::api::schema::Request { + id: "public-close-geometry".into(), + method: crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { + pane_id: second_pane_id, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + ); + + let runtime = &server.app.state.workspaces[0].test_runtimes[&first_pane]; + let grown = runtime.current_size(); + assert!(grown.0 > shrunk.0); + assert_eq!(runtime.terminal_dimensions(), Some((grown.1, grown.0))); + assert_eq!( + runtime.scroll_metrics().unwrap().viewport_rows, + grown.0 as usize + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn geometry_reapply_replaces_a_controller_that_left_the_tab() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("geometry-controller-viewer"); + let first_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + let third_tab = workspace.test_add_tab(Some("third")); + let third_pane = workspace.tabs[third_tab].root_pane; + for pane_id in [first_pane, second_pane, third_pane] { + workspace.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + } + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + let third_tab_id = server.app.public_tab_id(0, third_tab).unwrap(); + + let (first_control, _) = connect_test_shell(&mut server, 67, 100, 30); + let (second_control, _) = connect_test_shell(&mut server, 68, 70, 20); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + + assert!(server.focus_shell_client_on_tab(67, &second_tab_id)); + assert!(server.claim_shell_tab_geometry(67, false)); + assert!(server.focus_shell_client_on_tab(67, &third_tab_id)); + assert!(server.claim_shell_tab_geometry(67, false)); + assert!(server.focus_shell_client_on_tab(68, &second_tab_id)); + assert_eq!( + server.tab_geometry_controllers.get(&second_tab_id), + Some(&67) + ); + let stale_size = server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(); + + assert!(server.reapply_controlled_shell_tab_geometry(false)); + + assert_eq!( + server.tab_geometry_controllers.get(&second_tab_id), + Some(&68) + ); + assert_ne!( + server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(), + stale_size + ); + shutdown_test_runtimes(&mut server); +} + #[tokio::test] async fn client_shell_tabs_render_accept_input_and_resize_independently() { let mut server = test_headless_server(); @@ -3436,8 +3538,8 @@ async fn pane_death_reconciles_each_client_view_and_focus() { .public_tab_id(0, second_tab) .expect("second tab id"); - let (first_control, _) = connect_matching_test_shell(&mut server, 71); - let (second_control, _) = connect_matching_test_shell(&mut server, 72); + let (first_control, _) = connect_test_shell(&mut server, 71, 100, 30); + let (second_control, _) = connect_test_shell(&mut server, 72, 70, 20); let _ = first_control.recv().expect("first snapshot"); let _ = second_control.recv().expect("second snapshot"); assert!(server.focus_shell_client_on_tab(72, &second_tab_id)); @@ -3464,6 +3566,60 @@ async fn pane_death_reconciles_each_client_view_and_focus() { second_input.try_recv().is_err(), "focus gain was duplicated" ); + assert_eq!( + server.tab_geometry_controllers.get(&second_tab_id), + Some(&71) + ); + let before_resize = server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(); + assert!(server.handle_server_event(ServerEvent::ClientShellResize { + client_id: 71, + surface_cols: 90, + surface_rows: 25, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + assert_ne!( + server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(), + before_resize + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn pane_death_reapplies_controller_geometry() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("pane-death-geometry"); + let first_pane = workspace.tabs[0].root_pane; + let dead_pane = workspace.test_split(ratatui::layout::Direction::Vertical); + workspace.insert_test_runtime( + first_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + workspace.insert_test_runtime( + dead_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + + let (control, _) = connect_test_shell(&mut server, 73, 185, 46); + let _ = control.recv().expect("snapshot"); + let shrunk = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(); + assert!(shrunk.0 < 46); + + assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id: dead_pane })); + + let runtime = &server.app.state.workspaces[0].test_runtimes[&first_pane]; + let grown = runtime.current_size(); + assert!(grown.0 > shrunk.0); + assert_eq!(runtime.terminal_dimensions(), Some((grown.1, grown.0))); + assert_eq!( + runtime.scroll_metrics().unwrap().viewport_rows, + grown.0 as usize + ); shutdown_test_runtimes(&mut server); } -- cgit v1.2.3