From f6a452eeae4018ae789ee46cad5c2f50d81025f0 Mon Sep 17 00:00:00 2001 From: Amit Tamari <15573913+amittamari@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:43:36 +0300 Subject: [PATCH] perf: trim hot-path allocations in indexing and TUI render Concrete, std-only allocation cleanups (no new dependencies): - Adapters: reuse one decode buffer per file instead of allocating a fresh Vec for every JSONL line (clear + extend_from_slice). Applies to the Claude, Codex, and Cursor parse loops. - Results list: compute each visible cell's (text, style) once per frame via `compute_cells`, then feed the same grid to both the width solver (`layout_from_cells`) and the row builder (`session_row`). Previously every non-flex cell string was built twice per frame (measure pass + build pass). This also halves the per-frame `document_key()` probes for the PR column and lets `cell()`/`enrichment_cell()` take `&RowCtx`, dropping the `too_many_arguments` allow. - Footer: build the status line once and size its region from it instead of rebuilding it just to measure the width (`line_display_width`). - Return `Cow` from the no-op-common text transforms so they borrow when the input is unchanged (the common case): `strip_codex_wrappers`, `strip_redacted` (now fully allocation-free), and index `sanitize`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/adapters/claude.rs | 6 +- src/adapters/codex.rs | 14 +++- src/adapters/cursor.rs | 19 +++-- src/index.rs | 10 ++- src/tui/results_list.rs | 166 +++++++++++++++++++++------------------- src/tui/view.rs | 35 ++++----- 6 files changed, 140 insertions(+), 110 deletions(-) diff --git a/src/adapters/claude.rs b/src/adapters/claude.rs index 8cb9a60..6824851 100644 --- a/src/adapters/claude.rs +++ b/src/adapters/claude.rs @@ -81,11 +81,15 @@ impl ClaudeAdapter { let mut messages: Vec = Vec::new(); let mut model: Option = None; + // One decode buffer reused across lines; simd-json needs `&mut [u8]`, so we + // refill this rather than allocating a fresh Vec per line. + let mut buf: Vec = Vec::new(); for line in raw.lines() { if line.trim().is_empty() { continue; } - let mut buf = line.as_bytes().to_vec(); + buf.clear(); + buf.extend_from_slice(line.as_bytes()); let parsed: Line = match simd_json::serde::from_slice(&mut buf) { Ok(l) => l, Err(_) => continue, diff --git a/src/adapters/codex.rs b/src/adapters/codex.rs index da07dbf..fbbd2e5 100644 --- a/src/adapters/codex.rs +++ b/src/adapters/codex.rs @@ -40,11 +40,15 @@ impl CodexAdapter { let mut history_mode = HistoryMode::Legacy; let mut yolo = false; + // One decode buffer reused across lines; simd-json needs `&mut [u8]`, so we + // refill this rather than allocating a fresh Vec per line. + let mut buf: Vec = Vec::new(); for line in raw.lines() { if line.trim().is_empty() { continue; } - let mut buf = line.as_bytes().to_vec(); + buf.clear(); + buf.extend_from_slice(line.as_bytes()); let parsed: Line = match simd_json::serde::from_slice(&mut buf) { Ok(l) => l, Err(_) => continue, @@ -249,8 +253,12 @@ fn clean_event_message(text: &str) -> Option { if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } -fn strip_codex_wrappers(line: &str) -> String { - line.replace("", "").replace("", "") +fn strip_codex_wrappers(line: &str) -> std::borrow::Cow<'_, str> { + if line.contains("") || line.contains("") { + std::borrow::Cow::Owned(line.replace("", "").replace("", "")) + } else { + std::borrow::Cow::Borrowed(line) + } } fn derive_codex_title(messages: &[crate::core::Message]) -> String { diff --git a/src/adapters/cursor.rs b/src/adapters/cursor.rs index 85c70d8..77b267e 100644 --- a/src/adapters/cursor.rs +++ b/src/adapters/cursor.rs @@ -134,15 +134,16 @@ fn clean_user_text(text: &str) -> &str { } /// Cursor transcripts redact extended thinking as `[REDACTED]` in text blocks. -fn strip_redacted(text: &str) -> String { +fn strip_redacted(text: &str) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; let trimmed = text.trim(); if trimmed == "[REDACTED]" { - return String::new(); + return Cow::Borrowed(""); } if let Some(prefix) = trimmed.strip_suffix("[REDACTED]") { - return prefix.trim_end().to_string(); + return Cow::Borrowed(prefix.trim_end()); } - trimmed.to_string() + Cow::Borrowed(trimmed) } impl CursorAdapter { @@ -152,11 +153,15 @@ impl CursorAdapter { let mut messages: Vec = Vec::new(); let mut errored = false; + // One decode buffer reused across lines; simd-json needs `&mut [u8]`, so we + // refill this rather than allocating a fresh Vec per line. + let mut buf: Vec = Vec::new(); for line in raw.lines() { if line.trim().is_empty() { continue; } - let mut buf = line.as_bytes().to_vec(); + buf.clear(); + buf.extend_from_slice(line.as_bytes()); let parsed: Line = match simd_json::serde::from_slice(&mut buf) { Ok(l) => l, Err(_) => continue, @@ -187,8 +192,8 @@ impl CursorAdapter { } let joined = text_parts.join(" "); - let cleaned = if role == Role::User { - clean_user_text(&joined).to_string() + let cleaned: std::borrow::Cow<'_, str> = if role == Role::User { + std::borrow::Cow::Borrowed(clean_user_text(&joined)) } else { strip_redacted(&joined) }; diff --git a/src/index.rs b/src/index.rs index 1925bef..70ef77c 100644 --- a/src/index.rs +++ b/src/index.rs @@ -482,8 +482,14 @@ fn recency_boost(age_secs: u64) -> f32 { } /// Escape characters that would make tantivy's QueryParser error out. -fn sanitize(s: &str) -> String { - s.replace(['+', '-', '!', '^', '~', '*', '?', ':', '(', ')', '[', ']', '{', '}', '"'], " ") +fn sanitize(s: &str) -> std::borrow::Cow<'_, str> { + const SPECIAL: [char; 15] = + ['+', '-', '!', '^', '~', '*', '?', ':', '(', ')', '[', ']', '{', '}', '"']; + if s.contains(SPECIAL) { + std::borrow::Cow::Owned(s.replace(SPECIAL, " ")) + } else { + std::borrow::Cow::Borrowed(s) + } } /// Pure incremental diff. Returns (changed[(id, entry)], deleted[id]). diff --git a/src/tui/results_list.rs b/src/tui/results_list.rs index e9c062a..e174495 100644 --- a/src/tui/results_list.rs +++ b/src/tui/results_list.rs @@ -11,96 +11,85 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Cell, Row}; use std::collections::HashMap; -#[allow(clippy::too_many_arguments)] -fn cell( - s: &SessionSummary, - col: &Column, - enrichers: &[Box], - resolved: &HashMap<(String, &'static str), Option>, - now: i64, - frame: u64, - theme: &Theme, -) -> (String, Style) { +fn cell(s: &SessionSummary, col: &Column, ctx: &RowCtx<'_>) -> (String, Style) { match col.id { - "agent" => (s.agent.badge().to_string(), Style::default().fg(theme.agent_color(s.agent))), + "agent" => { + (s.agent.badge().to_string(), Style::default().fg(ctx.theme.agent_color(s.agent))) + } "title" => (s.title.clone(), Style::default()), "msgs" => ( if s.message_count > 0 { s.message_count.to_string() } else { "-".into() }, - Style::default().fg(theme.muted), + Style::default().fg(ctx.theme.muted), ), - "time" => (rel_time(s.timestamp, now), Style::default().fg(theme.muted)), + "time" => (rel_time(s.timestamp, ctx.now), Style::default().fg(ctx.theme.muted)), "model" => { - (s.model.clone().unwrap_or_else(|| "-".into()), Style::default().fg(theme.muted)) + (s.model.clone().unwrap_or_else(|| "-".into()), Style::default().fg(ctx.theme.muted)) } - other => enrichment_cell(other, s, enrichers, resolved, frame, theme), + other => enrichment_cell(other, s, ctx), } } -fn enrichment_cell( - id: &str, - s: &SessionSummary, - enrichers: &[Box], - resolved: &HashMap<(String, &'static str), Option>, - frame: u64, - theme: &Theme, -) -> (String, Style) { - let Some(enr) = enrichers.iter().find(|e| e.id() == id) else { +fn enrichment_cell(id: &str, s: &SessionSummary, ctx: &RowCtx<'_>) -> (String, Style) { + let Some(enr) = ctx.enrichers.iter().find(|e| e.id() == id) else { return (String::new(), Style::default()); }; match enr.kind() { EnrichKind::Fast => { let text = enr.resolve(s).map(|v| v.text).unwrap_or_else(|| "—".into()); - (text, Style::default().fg(theme.muted)) + (text, Style::default().fg(ctx.theme.muted)) } - EnrichKind::Slow => match resolved.get(&(s.document_key(), enr.id())) { - Some(Some(text)) => (text.clone(), Style::default().fg(theme.accent)), - Some(None) => ("—".into(), Style::default().fg(theme.muted)), + EnrichKind::Slow => match ctx.resolved.get(&(s.document_key(), enr.id())) { + Some(Some(text)) => (text.clone(), Style::default().fg(ctx.theme.accent)), + Some(None) => ("—".into(), Style::default().fg(ctx.theme.muted)), None => ( - crate::tui::view::spinner_glyph(frame).to_string(), - Style::default().fg(theme.muted), + crate::tui::view::spinner_glyph(ctx.frame).to_string(), + Style::default().fg(ctx.theme.muted), ), }, } } -/// Solve the layout using only the rows currently visible in the viewport. -pub fn layout_for_rows( +/// Compute the `(text, style)` of every column for the visible rows once per +/// frame. The width solver and the row builder both read this, so each cell's +/// `cell()` (and its per-row `document_key()` probe) runs once per frame instead +/// of twice. The flex (title) column is rendered specially with query-term +/// highlighting, so its slot is left as an empty placeholder here. +pub fn compute_cells( columns: &[Column], - width: u16, rows: &[SessionSummary], - enrichers: &[Box], - resolved: &HashMap<(String, &'static str), Option>, - now: i64, - frame: u64, -) -> Vec<(usize, u16)> { - let desired = desired_widths(columns, rows, enrichers, resolved, now, frame); - solve_layout_with_desired(columns, width, &desired) + ctx: &RowCtx<'_>, +) -> Vec> { + rows.iter() + .map(|s| { + columns + .iter() + .map( + |col| { + if col.flex { (String::new(), Style::default()) } else { cell(s, col, ctx) } + }, + ) + .collect() + }) + .collect() } -fn desired_widths( +/// Solve the layout from precomputed cell texts (visible rows only). Non-flex +/// column widths come from the cell text; the flex column absorbs the slack. +pub fn layout_from_cells( columns: &[Column], - rows: &[SessionSummary], - enrichers: &[Box], - resolved: &HashMap<(String, &'static str), Option>, - now: i64, - frame: u64, -) -> Vec { + width: u16, + cells: &[Vec<(String, Style)>], +) -> Vec<(usize, u16)> { let mut widths: Vec = columns.iter().map(|col| display_width(col.header) as u16).collect(); - - // Widths depend only on cell text, not style, so the theme is irrelevant - // here; build one default instead of one per cell. - let theme = Theme::default(); - for row in rows { + for row_cells in cells { for (i, col) in columns.iter().enumerate() { if col.flex { continue; } - let (text, _) = cell(row, col, enrichers, resolved, now, frame, &theme); - widths[i] = widths[i].max(display_width(&text) as u16); + widths[i] = widths[i].max(display_width(&row_cells[i].0) as u16); } } - - widths + solve_layout_with_desired(columns, width, &widths) } pub struct RowCtx<'a> { @@ -116,9 +105,11 @@ pub struct RowCtx<'a> { /// beyond the row dimming. const ARCHIVED_MARKER: &str = "arch "; -/// Build one Table row for a session across the kept (visible) columns. +/// Build one Table row for a session across the kept (visible) columns, reusing +/// the cells computed for this row by [`compute_cells`] (indexed by column). pub fn session_row( session: &SessionSummary, + row_cells: &[(String, Style)], layout: &[(usize, u16)], columns: &[Column], ctx: &RowCtx<'_>, @@ -136,9 +127,8 @@ pub fn session_row( session.archived, )) } else { - let (text, style) = - cell(session, col, ctx.enrichers, ctx.resolved, ctx.now, ctx.frame, ctx.theme); - Cell::from(Span::styled(fit(&text, width, col.align), style)) + let (text, style) = &row_cells[ci]; + Cell::from(Span::styled(fit(text, width, col.align), *style)) } }) .collect(); @@ -208,26 +198,20 @@ mod tests { let enr: Vec> = vec![Box::new(BranchEnricher), Box::new(RepoEnricher)]; let resolved = HashMap::new(); let row_data = sess(); - let layout = - layout_for_rows(&cols, 120, std::slice::from_ref(&row_data), &enr, &resolved, 3600, 0); + let theme = Theme::default(); let ctx = RowCtx { enrichers: &enr, resolved: &resolved, now: 3600, frame: 0, terms: &[], - theme: &Theme::default(), + theme: &theme, }; - session_row(&row_data, &layout, &cols, &ctx); - let (agent_text, _) = super::cell( - &row_data, - cols.iter().find(|c| c.id == "agent").unwrap(), - &enr, - &resolved, - 3600, - 0, - &Theme::default(), - ); + let grid = compute_cells(&cols, std::slice::from_ref(&row_data), &ctx); + let layout = layout_from_cells(&cols, 120, &grid); + session_row(&row_data, &grid[0], &layout, &cols, &ctx); + let (agent_text, _) = + super::cell(&row_data, cols.iter().find(|c| c.id == "agent").unwrap(), &ctx); assert_eq!(agent_text, "CLAUDE"); } @@ -247,7 +231,17 @@ mod tests { row.branch = Some("workflow/ghostty-terminal".into()); let enr: Vec> = vec![Box::new(BranchEnricher), Box::new(RepoEnricher)]; let resolved = HashMap::new(); - let layout = layout_for_rows(&cols, 120, &[row], &enr, &resolved, 0, 0); + let theme = Theme::default(); + let ctx = RowCtx { + enrichers: &enr, + resolved: &resolved, + now: 0, + frame: 0, + terms: &[], + theme: &theme, + }; + let grid = compute_cells(&cols, &[row], &ctx); + let layout = layout_from_cells(&cols, 120, &grid); let width = |id| layout.iter().find(|&&(i, _)| cols[i].id == id).map(|&(_, w)| w).unwrap(); assert_eq!(width("repo"), "responsive-editor".len() as u16); @@ -261,10 +255,19 @@ mod tests { let enr: Vec> = vec![Box::new(crate::enrich::gh_pr::GhPrEnricher)]; let resolved = HashMap::new(); let pr_col = cols.iter().find(|c| c.id == "pr").unwrap(); + let theme = Theme::default(); + let mk = |frame| RowCtx { + enrichers: &enr, + resolved: &resolved, + now: 0, + frame, + terms: &[], + theme: &theme, + }; // frame=0 -> first braille frame; frame=3 -> fourth. - let (t0, _) = super::cell(&sess(), pr_col, &enr, &resolved, 0, 0, &Theme::default()); + let (t0, _) = super::cell(&sess(), pr_col, &mk(0)); assert_eq!(t0, crate::tui::view::SPINNER_FRAMES[0]); - let (t3, _) = super::cell(&sess(), pr_col, &enr, &resolved, 0, 3, &Theme::default()); + let (t3, _) = super::cell(&sess(), pr_col, &mk(3)); assert_eq!(t3, crate::tui::view::SPINNER_FRAMES[3]); } @@ -279,7 +282,16 @@ mod tests { let mut resolved = HashMap::new(); resolved.insert(("claude:a".to_string(), "pr"), Some("#42".to_string())); let pr_col = cols.iter().find(|c| c.id == "pr").unwrap(); - let (text, style) = super::cell(&sess(), pr_col, &enr, &resolved, 0, 0, &Theme::default()); + let theme = Theme::default(); + let ctx = RowCtx { + enrichers: &enr, + resolved: &resolved, + now: 0, + frame: 0, + terms: &[], + theme: &theme, + }; + let (text, style) = super::cell(&sess(), pr_col, &ctx); assert_eq!(text, "#42"); assert_eq!(style.fg, Some(Theme::default().accent)); } diff --git a/src/tui/view.rs b/src/tui/view.rs index 089dad4..7e3d619 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -162,15 +162,6 @@ pub fn render(f: &mut Frame, app: &App, model: RenderModel<'_>) { Rect { x: list_area.x, y, width: list_area.width, height: 1.min(list_area.height) }; f.render_widget(para, centered); } else { - let layout = results_list::layout_for_rows( - cols, - list_inner_w, - visible_results, - model.enrichers, - model.resolved, - model.now, - app.frame(), - ); let ctx = results_list::RowCtx { enrichers: model.enrichers, resolved: model.resolved, @@ -179,9 +170,14 @@ pub fn render(f: &mut Frame, app: &App, model: RenderModel<'_>) { terms: model.query_terms, theme: &model.theme, }; + // Compute cells once, then feed the same grid to the width solver and the + // row builder so `cell()` runs a single time per visible cell per frame. + let grid = results_list::compute_cells(cols, visible_results, &ctx); + let layout = results_list::layout_from_cells(cols, list_inner_w, &grid); let rows: Vec = visible_results .iter() - .map(|s| results_list::session_row(s, &layout, cols, &ctx)) + .zip(&grid) + .map(|(s, row_cells)| results_list::session_row(s, row_cells, &layout, cols, &ctx)) .collect(); let mut state = TableState::default(); state.select(Some(app.selected().saturating_sub(visible_start))); @@ -243,9 +239,12 @@ pub fn render(f: &mut Frame, app: &App, model: RenderModel<'_>) { // footer: static hints on the left, volatile status on the right. The two // halves share the footer row via SpaceBetween so right-aligned status // (sync/pr/filters/warning) survives clipping ahead of the static hints. + // Build the status line once and size its region from it, rather than + // building it a second time just to measure the width. + let status_line = footer_status_line(model.status, &model.theme); let [hints_area, status_area] = Layout::horizontal([ Constraint::Min(0), - Constraint::Length(footer_status_width(model.status, &model.theme)), + Constraint::Length(line_display_width(&status_line)), ]) .flex(Flex::SpaceBetween) .areas(footer_area); @@ -253,10 +252,7 @@ pub fn render(f: &mut Frame, app: &App, model: RenderModel<'_>) { Paragraph::new(footer_hints_line(app.keymap(), app.search_mode(), &model.theme)), hints_area, ); - f.render_widget( - Paragraph::new(footer_status_line(model.status, &model.theme)).alignment(Alignment::Right), - status_area, - ); + f.render_widget(Paragraph::new(status_line).alignment(Alignment::Right), status_area); if let Some((index, yolo)) = app.yolo_modal() { let session = app.results().get(index); @@ -334,11 +330,10 @@ fn footer_status_line(status: &StatusLine, theme: &Theme) -> Line<'static> { Line::from(spans) } -/// Display width of the rendered status line, used to size the right footer -/// region so the status is never clipped. -fn footer_status_width(status: &StatusLine, theme: &Theme) -> u16 { - let text: String = - footer_status_line(status, theme).spans.iter().map(|s| s.content.as_ref()).collect(); +/// Display width of a rendered line, used to size the right footer region so +/// the status is never clipped. +fn line_display_width(line: &Line) -> u16 { + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); crate::tui::columns::display_width(&text).min(u16::MAX as usize) as u16 }