diff --git a/docs/devlogs/2026-07-07-optimize-runtime-hot-paths.md b/docs/devlogs/2026-07-07-optimize-runtime-hot-paths.md new file mode 100644 index 0000000..9962c01 --- /dev/null +++ b/docs/devlogs/2026-07-07-optimize-runtime-hot-paths.md @@ -0,0 +1,35 @@ +# Optimize runtime hot paths + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Reduced avoidable work in GridBash's runtime loop for busy, high-pane-count + sessions. + +## What Changed + +- Batched pending PTY output by pane before parsing it into vt100 screens. +- Added a pane ID index so PTY events no longer scan every pane to find their + target. +- Throttled child exit polling to avoid checking every pane on every loop tick. +- Cached conversation footer summaries on pane output/resize instead of scanning + screen rows during every render. +- Added cached rendered screen rows for unchanged panes, with invalidation on + output, resize, selection, pane swap, and pane removal. +- Increased the PTY read buffer to reduce message churn during output bursts. + +## Why It Matters + +- Large grids should spend less CPU on quiet panes and repeated metadata work, + leaving the runtime loop more responsive when one or more panes are noisy. + +## Validation + +- `cargo test` + +## Release Notes + +- Improved runtime performance for busy GridBash sessions without changing the + v1 single-process session model. diff --git a/src/app.rs b/src/app.rs index c075951..0b4dac7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crossterm::{ execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect, style::Color}; +use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect, style::Color, text::Line}; use tokio::sync::mpsc; use vt100::Screen; @@ -38,6 +38,7 @@ use crate::{ pub type Tui = Terminal>; const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(16); +const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(500); const MAX_PANE_NAME_CHARS: usize = 32; const CONVERSATION_SUMMARY_MAX_CHARS: usize = 120; const ACTIVITY_DECAY_INTERVAL: Duration = Duration::from_millis(250); @@ -50,6 +51,10 @@ pub struct App { layout: GridLayout, grid_area: Rect, panes: Vec, + pane_index_by_id: BTreeMap, + pane_render_caches: Vec, + pane_screen_dirty: Vec, + pane_conversation_summaries: Vec>, focus: usize, selected: BTreeSet, pane_names: Vec>, @@ -71,6 +76,7 @@ pub struct App { profile_usage: BTreeMap, api_spend_label: Option, last_activity_decay: Instant, + last_exit_poll: Instant, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -647,6 +653,10 @@ impl App { layout: GridLayout::new(grid), grid_area: Rect::default(), panes: Vec::new(), + pane_index_by_id: BTreeMap::new(), + pane_render_caches: Vec::new(), + pane_screen_dirty: Vec::new(), + pane_conversation_summaries: Vec::new(), focus: 0, selected: BTreeSet::new(), pane_names: Vec::new(), @@ -668,6 +678,7 @@ impl App { profile_usage: BTreeMap::new(), api_spend_label: None, last_activity_decay: Instant::now(), + last_exit_poll: Instant::now(), }) } @@ -705,10 +716,15 @@ impl App { .ok_or_else(|| anyhow!("no launch plan selected"))?; self.layout = GridLayout::new(plan.grid); self.panes.clear(); + self.pane_index_by_id.clear(); + self.pane_render_caches.clear(); + self.pane_screen_dirty.clear(); + self.pane_conversation_summaries.clear(); self.pane_names = vec![None; plan.panes.len()]; self.text_selection = None; self.sleeping.clear(); self.next_pane_id = 0; + self.last_exit_poll = Instant::now(); for spec in &plan.panes { self.spawn_pane_spec(spec)?; @@ -736,7 +752,12 @@ impl App { fn spawn_pane_spec(&mut self, spec: &PaneLaunchSpec) -> Result<()> { let pane_index = self.panes.len(); let pane = self.spawn_pane_instance(spec, pane_index)?; + let pane_id = pane.id(); self.panes.push(pane); + self.pane_index_by_id.insert(pane_id, pane_index); + self.pane_render_caches.push(ui::PaneRenderCache::default()); + self.pane_screen_dirty.push(true); + self.pane_conversation_summaries.push(None); Ok(()) } @@ -787,8 +808,7 @@ impl App { self.grid_area = draw_state.grid_area; self.rects = draw_state.pane_rects; })?; - self.sync_pane_sizes(); - needs_render = false; + needs_render = self.sync_pane_sizes(); } self.sync_mouse_capture(terminal, &mut mouse_capture_enabled)?; @@ -844,6 +864,8 @@ impl App { fn drain_pty_events(&mut self) -> bool { let mut changed = false; + let mut pending_output = BTreeMap::<(PaneId, u64), Vec>::new(); + let mut exited = Vec::new(); while let Ok(event) = self.event_rx.try_recv() { match event { @@ -852,31 +874,44 @@ impl App { generation, bytes, } => { - if let Some(target) = self - .panes - .iter_mut() - .find(|p| p.id() == pane && p.generation() == generation) - { - target.process_output(&bytes); - changed = true; - } - } - PtyEvent::Exited { pane, generation } => { - if let Some(target) = self - .panes - .iter_mut() - .find(|p| p.id() == pane && p.generation() == generation) - && !target.exited - { - target.exited = true; - changed = true; - } + pending_output + .entry((pane, generation)) + .or_default() + .extend(bytes); } + PtyEvent::Exited { pane, generation } => exited.push((pane, generation)), } } - for pane in &mut self.panes { - changed |= pane.poll_exit(); + for ((pane_id, generation), bytes) in pending_output { + let Some(index) = self.pane_index_for_event(pane_id, generation) else { + continue; + }; + + self.panes[index].process_output(&bytes); + self.mark_pane_screen_dirty(index); + self.refresh_conversation_summary(index); + changed = true; + } + + for (pane_id, generation) in exited { + let Some(index) = self.pane_index_for_event(pane_id, generation) else { + continue; + }; + + if !self.panes[index].exited { + self.panes[index].exited = true; + changed = true; + } + } + + if self.last_exit_poll.elapsed() >= EXIT_POLL_INTERVAL { + for index in 0..self.panes.len() { + if self.panes[index].poll_exit() { + changed = true; + } + } + self.last_exit_poll = Instant::now(); } changed @@ -1332,6 +1367,10 @@ impl App { { plan.panes.swap(first, second); } + self.pane_render_caches.swap(first, second); + self.pane_screen_dirty.swap(first, second); + self.pane_conversation_summaries.swap(first, second); + self.rebuild_pane_index(); swap_set_indices(&mut self.sleeping, first, second); self.focus = swapped_index(self.focus, first, second); self.status = format!("swapped panes {} and {}", first + 1, second + 1); @@ -1672,6 +1711,10 @@ impl App { self.focus = target_count.saturating_sub(1); } self.pane_names.truncate(target_count); + self.pane_render_caches.truncate(target_count); + self.pane_screen_dirty.truncate(target_count); + self.pane_conversation_summaries.truncate(target_count); + self.rebuild_pane_index(); self.sleeping = self .sleeping .iter() @@ -1795,9 +1838,17 @@ impl App { self.panes[index] = pane; self.sleeping.remove(&index); + if let Some(cache) = self.pane_render_caches.get_mut(index) { + *cache = ui::PaneRenderCache::default(); + } + self.mark_pane_screen_dirty(index); + if let Some(summary) = self.pane_conversation_summaries.get_mut(index) { + *summary = None; + } restarted += 1; } + self.rebuild_pane_index(); self.sync_pane_sizes(); self.status = if restart.running > 0 { format!( @@ -1869,10 +1920,6 @@ impl App { self.layout.rects(area, self.panes.len()) } - pub fn panes(&self) -> &[PtyPane] { - &self.panes - } - pub fn focus(&self) -> usize { self.focus } @@ -1953,9 +2000,11 @@ impl App { .as_ref() .and_then(|plan| plan.panes.get(index)) .and_then(|pane| pane.agent_label())?; - let pane = self.panes.get(index)?; - let summary = conversation_summary(pane.screen()) - .unwrap_or_else(|| "waiting for conversation".into()); + let summary = self + .pane_conversation_summaries + .get(index) + .and_then(|summary| summary.as_deref()) + .unwrap_or("waiting for conversation"); Some(truncate_chars(&format!("{label} | {summary}"), max_chars)) } @@ -1979,18 +2028,106 @@ impl App { (!parts.is_empty()).then(|| parts.join(" | ")) } - pub fn sync_pane_sizes(&mut self) { - for (index, rect) in self.rects.iter().enumerate() { + pub fn pane_count(&self) -> usize { + self.panes.len() + } + + pub fn pane_active(&self, index: usize) -> bool { + self.panes.get(index).is_some_and(|pane| pane.active) + } + + pub fn pane_exited(&self, index: usize) -> bool { + self.panes.get(index).is_some_and(|pane| pane.exited) + } + + pub fn pane_output_quiet(&self, index: usize) -> bool { + self.panes + .get(index) + .is_some_and(|pane| pane.output_quiet()) + } + + pub fn pane_cwd(&self, index: usize) -> Option<&std::path::Path> { + self.panes.get(index).map(|pane| pane.cwd()) + } + + pub fn pane_screen(&self, index: usize) -> Option<&Screen> { + self.panes.get(index).map(|pane| pane.screen()) + } + + pub fn pane_screen_lines( + &mut self, + index: usize, + width: u16, + height: u16, + selection: Option, + ) -> Vec> { + let Some(pane) = self.panes.get(index) else { + return Vec::new(); + }; + let Some(cache) = self.pane_render_caches.get_mut(index) else { + return ui::screen_lines(pane.screen(), width, height, selection); + }; + let Some(dirty) = self.pane_screen_dirty.get_mut(index) else { + return ui::screen_lines(pane.screen(), width, height, selection); + }; + + ui::cached_screen_lines(cache, dirty, pane.screen(), width, height, selection) + } + + pub fn sync_pane_sizes(&mut self) -> bool { + let mut changed = false; + + for index in 0..self.rects.len() { + let rect = self.rects[index]; let Some(pane) = self.panes.get_mut(index) else { continue; }; let rows = rect.height.saturating_sub(2).max(1); let cols = rect.width.saturating_sub(2).max(1); - if let Err(error) = pane.resize(rows, cols) { - self.status = format!("resize failed: {error:#}"); + match pane.resize(rows, cols) { + Ok(true) => { + self.mark_pane_screen_dirty(index); + self.refresh_conversation_summary(index); + changed = true; + } + Ok(false) => {} + Err(error) => { + self.status = format!("resize failed: {error:#}"); + } } } + + changed + } + + fn pane_index_for_event(&self, id: PaneId, generation: u64) -> Option { + let index = *self.pane_index_by_id.get(&id)?; + let pane = self.panes.get(index)?; + (pane.id() == id && pane.generation() == generation).then_some(index) + } + + fn rebuild_pane_index(&mut self) { + self.pane_index_by_id.clear(); + for (index, pane) in self.panes.iter().enumerate() { + self.pane_index_by_id.insert(pane.id(), index); + } + } + + fn mark_pane_screen_dirty(&mut self, index: usize) { + if let Some(dirty) = self.pane_screen_dirty.get_mut(index) { + *dirty = true; + } + } + + fn refresh_conversation_summary(&mut self, index: usize) { + let Some(summary) = self.pane_conversation_summaries.get_mut(index) else { + return; + }; + *summary = self + .panes + .get(index) + .and_then(|pane| conversation_summary(pane.screen())); } fn sync_initial_pane_sizes(&mut self, terminal: &Tui) -> Result<()> { diff --git a/src/layout.rs b/src/layout.rs index 3c9dac8..f47f8d2 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,6 +1,6 @@ use ratatui::layout::Rect; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct PaneId(pub usize); #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/pty.rs b/src/pty.rs index a501340..6050713 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -18,6 +18,7 @@ const CURSOR_POSITION_QUERY: &[u8] = b"\x1b[6n"; const PRIMARY_DEVICE_ATTRIBUTES_QUERY: &[u8] = b"\x1b[c"; const PRIMARY_DEVICE_ATTRIBUTES_ZERO_QUERY: &[u8] = b"\x1b[0c"; const MAX_TERMINAL_QUERY_LEN: usize = 4; +const PTY_READ_BUFFER_BYTES: usize = 32 * 1024; #[derive(Debug, Clone)] pub enum PtyEvent { @@ -189,9 +190,9 @@ impl PtyPane { writer.flush().context("failed to flush PTY") } - pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> { + pub fn resize(&mut self, rows: u16, cols: u16) -> Result { if self.rows == rows && self.cols == cols { - return Ok(()); + return Ok(false); } self.parser.screen_mut().set_size(rows, cols); @@ -205,7 +206,7 @@ impl PtyPane { .context("failed to resize PTY")?; self.rows = rows; self.cols = cols; - Ok(()) + Ok(true) } pub fn poll_exit(&mut self) -> bool { @@ -275,7 +276,7 @@ fn spawn_reader( mut reader: Box, ) { thread::spawn(move || { - let mut buffer = [0_u8; 8192]; + let mut buffer = [0_u8; PTY_READ_BUFFER_BYTES]; loop { match reader.read(&mut buffer) { Ok(0) => { diff --git a/src/ui.rs b/src/ui.rs index 84135fe..60f05dc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -29,7 +29,14 @@ pub struct DrawState { const QUIET_MARKER: &str = " *"; -pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { +#[derive(Debug, Clone, Default)] +pub struct PaneRenderCache { + width: u16, + height: u16, + lines: Vec>, +} + +pub fn draw(frame: &mut Frame<'_>, app: &mut App) -> DrawState { let area = frame.area(); let chunks = Layout::default() .direction(Direction::Vertical) @@ -39,12 +46,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { let grid_area = chunks[0]; let status_area = chunks[1]; let rects = app.pane_rects(grid_area); - let palette = app.palette(); + let palette = *app.palette(); let rename_view = app.rename_pane_view(); - let image_overlay = app.image_overlay_view(); - let modal_open = app.settings_open() || rename_view.is_some() || image_overlay.is_some(); + let image_overlay_open = app.image_overlay_view().is_some(); + let modal_open = app.settings_open() || rename_view.is_some() || image_overlay_open; - for (index, pane) in app.panes().iter().enumerate() { + for index in 0..app.pane_count() { let Some(rect) = rects.get(index).copied() else { continue; }; @@ -52,21 +59,22 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { let focused = app.focus() == index; let selected = app.selected().contains(&index); let sleeping = app.pane_sleeping(index); - let quiet = app.activity_badges_enabled() && pane.output_quiet(); + let quiet = app.activity_badges_enabled() && app.pane_output_quiet(index); let chrome = pane_chrome( selected, focused, - pane.active, - pane.exited, + app.pane_active(index), + app.pane_exited(index), sleeping, quiet, - palette, + &palette, ); let folder = app .pane_folder(index) .map(label_name) - .unwrap_or_else(|| folder_label(pane.cwd())); + .or_else(|| app.pane_cwd(index).map(folder_label)) + .unwrap_or_default(); let usage = app.pane_usage_label(index); let title = pane_title( &app.pane_label(index), @@ -92,11 +100,17 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { if sleeping { render_sleeping_screen(frame, inner); } else { - render_screen(frame, inner, pane.screen(), app.selection_for_pane(index)); + let selection = app.selection_for_pane(index); + let lines = app.pane_screen_lines(index, inner.width, inner.height, selection); + render_screen_lines(frame, inner, lines); } - if focused && !sleeping && !modal_open { - set_terminal_cursor(frame, inner, pane.screen()); + if focused + && !sleeping + && !modal_open + && let Some(screen) = app.pane_screen(index) + { + set_terminal_cursor(frame, inner, screen); } } @@ -141,12 +155,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { ); if app.settings_open() { - render_settings(frame, area, &app.settings_rows(), palette); + render_settings(frame, area, &app.settings_rows(), &palette); } if let Some(rename) = rename_view.as_ref() { render_rename_pane(frame, area, rename); } - if let Some(image) = image_overlay { + if let Some(image) = app.image_overlay_view() { render_image_overlay(frame, area, image); } @@ -817,26 +831,64 @@ fn render_sleeping_screen(frame: &mut Frame<'_>, area: Rect) { frame.render_widget(Paragraph::new(lines).style(style), area); } -fn render_screen( - frame: &mut Frame<'_>, - area: Rect, - screen: &vt100::Screen, - selection: Option, -) { +fn render_screen_lines(frame: &mut Frame<'_>, area: Rect, lines: Vec>) { if area.width == 0 || area.height == 0 { return; } - let lines = (0..area.height) - .map(|row| render_screen_row(screen, row, area.width, selection)) - .collect::>(); - frame.render_widget( Paragraph::new(lines).style(Style::default().fg(Color::Rgb(230, 237, 243)).bg(APP_BG)), area, ); } +pub fn cached_screen_lines( + cache: &mut PaneRenderCache, + dirty: &mut bool, + screen: &vt100::Screen, + width: u16, + height: u16, + selection: Option, +) -> Vec> { + if width == 0 || height == 0 { + return Vec::new(); + } + + if selection.is_none() + && !*dirty + && cache.width == width + && cache.height == height + && cache.lines.len() == height as usize + { + return cache.lines.clone(); + } + + let lines = screen_lines(screen, width, height, selection); + if selection.is_none() { + cache.width = width; + cache.height = height; + cache.lines = lines.clone(); + *dirty = false; + } + + lines +} + +pub fn screen_lines( + screen: &vt100::Screen, + width: u16, + height: u16, + selection: Option, +) -> Vec> { + if width == 0 || height == 0 { + return Vec::new(); + } + + (0..height) + .map(|row| render_screen_row(screen, row, width, selection)) + .collect::>() +} + fn render_screen_row<'a>( screen: &vt100::Screen, row: u16, @@ -1078,4 +1130,40 @@ mod tests { assert_eq!(quiet.quiet_marker, QUIET_MARKER); assert_eq!(quiet.border_style, active_quiet.border_style); } + + #[test] + fn selected_screen_lines_do_not_clean_dirty_cache() { + let mut parser = vt100::Parser::new(2, 10, 100); + parser.process(b"hello\r\nworld"); + let mut cache = PaneRenderCache::default(); + let mut dirty = true; + let selection = Some(PaneSelection { + start_row: 0, + start_column: 0, + end_row: 0, + end_column: 4, + }); + + let lines = cached_screen_lines(&mut cache, &mut dirty, parser.screen(), 10, 2, selection); + + assert_eq!(lines.len(), 2); + assert!(dirty); + assert!(cache.lines.is_empty()); + } + + #[test] + fn unselected_screen_lines_populate_clean_cache() { + let mut parser = vt100::Parser::new(2, 10, 100); + parser.process(b"hello\r\nworld"); + let mut cache = PaneRenderCache::default(); + let mut dirty = true; + + let lines = cached_screen_lines(&mut cache, &mut dirty, parser.screen(), 10, 2, None); + + assert_eq!(lines.len(), 2); + assert!(!dirty); + assert_eq!(cache.width, 10); + assert_eq!(cache.height, 2); + assert_eq!(cache.lines.len(), 2); + } }