From 8c4a616d0329adab80ba464ae6e5c682d4f915ef Mon Sep 17 00:00:00 2001 From: Carlo Taleon Date: Sat, 18 Jul 2026 18:46:08 +0000 Subject: [PATCH 1/5] perf(persistence): batch streaming snapshots in one transaction with WAL replace_messages ran a delete plus one autocommitted insert per message on the UI thread every 250ms streaming snapshot, each with its own journal rewrite and fsync. Wrap the rewrite in a single transaction with a cached prepared statement and switch the database to WAL with synchronous=NORMAL so snapshots become one cheap log append. Co-authored-by: Carlo Taleon --- src/persistence/db.rs | 2 ++ src/persistence/history.rs | 38 +++++++++++++++++++++++++++----------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/persistence/db.rs b/src/persistence/db.rs index ba08a5a..1338d12 100644 --- a/src/persistence/db.rs +++ b/src/persistence/db.rs @@ -12,6 +12,8 @@ fn init_db_conn() -> Result { let mut conn = Connection::open(&db_path)?; conn.execute_batch("PRAGMA foreign_keys = ON;")?; + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + let _ = conn.pragma_update(None, "synchronous", "NORMAL"); run_migrations(&mut conn)?; Ok(Arc::new(Mutex::new(conn))) diff --git a/src/persistence/history.rs b/src/persistence/history.rs index 998870e..6803179 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -78,6 +78,11 @@ impl HistoryDAO { let db_path = data_dir.join("data.db"); let mut conn = Connection::open(&db_path)?; + // WAL keeps readers non-blocking and turns the frequent streaming + // snapshot writes into cheap log appends instead of full journal + // rewrites with an fsync per statement. + let _ = conn.pragma_update(None, "journal_mode", "WAL"); + let _ = conn.pragma_update(None, "synchronous", "NORMAL"); run_migrations(&mut conn)?; // Ensure session_identifier column exists on pre-existing databases @@ -452,7 +457,13 @@ impl HistoryDAO { } pub fn replace_messages(&self, session_id: i64, messages: &[Message]) -> Result<()> { - self.conn.execute( + // A single transaction turns the delete + N inserts into one commit. + // This runs on the UI thread every streaming snapshot, so per-statement + // autocommits (each with their own fsync) caused visible lag on long + // transcripts. + let tx = self.conn.unchecked_transaction()?; + + tx.execute( "DELETE FROM messages WHERE session_id = ?1", params![session_id], )?; @@ -460,18 +471,21 @@ impl HistoryDAO { let mut total_tokens: i64 = 0; let mut updated_at = chrono::Utc::now().timestamp(); - for msg in messages { - let parts_json = serde_json::to_string(&msg.parts)?; - total_tokens += msg.tokens_used as i64; - updated_at = msg.timestamp; - - self.conn.execute( + { + let mut insert = tx.prepare_cached( "INSERT INTO messages ( id, session_id, role, parts, timestamp, tokens_used, model, provider, agent_mode, duration_ms, t0_ms, t1_ms, tn_ms, output_tokens ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - params![ + )?; + + for msg in messages { + let parts_json = serde_json::to_string(&msg.parts)?; + total_tokens += msg.tokens_used as i64; + updated_at = msg.timestamp; + + insert.execute(params![ &msg.id, session_id, &msg.role, @@ -486,8 +500,8 @@ impl HistoryDAO { msg.t1_ms, msg.tn_ms, msg.output_tokens, - ], - )?; + ])?; + } } let session = self.get_session(session_id)?; @@ -501,7 +515,7 @@ impl HistoryDAO { 0.0 }; - self.conn.execute( + tx.execute( "UPDATE sessions SET total_tokens = ?1, total_cost = 0, @@ -518,6 +532,8 @@ impl HistoryDAO { ], )?; + tx.commit()?; + Ok(()) } From bd54e83f9b5b2b77664f444d4b1edfb365d8d36a Mon Sep 17 00:00:00 2001 From: Carlo Taleon Date: Sat, 18 Jul 2026 18:46:23 +0000 Subject: [PATCH 2/5] perf(chat): cache streaming tool rows and stop deep-cloning viewport lines Tool-heavy streaming messages (the common shape for subagent transcripts) reformatted every tool part on each layout refresh: a JSON clone, a serialize + reparse round-trip, row formatting, wrapping, and syntect diff highlighting per part. Cache each rendered tool row keyed by a structural hash of its payload, scoped to the actively streaming message so memory stays bounded; skip building parsed tool info for tools that can never join exploration/task groups. Chat::render also deep-cloned every visible line (including all span strings) on each frame at up to 25fps while streaming. Borrow the visible lines from the layout cache instead, and make the highlight/selection helpers lifetime-generic with Cow-preserving span splitting. Also fix an unbounded scan in editor_location_for_selection when the selection extends past the cached location table (it could spin on usize::MAX), and clear the ordered markdown cache in Chat::clear which previously leaked across session resets. Co-authored-by: Carlo Taleon --- src/ui/components/chat.rs | 395 +++++++++++++++++++++++++++++++++----- 1 file changed, 351 insertions(+), 44 deletions(-) diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 7e36db8..af3d209 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -128,6 +128,79 @@ struct CachedMarkdownPart { lines: Vec>, } +/// Rendered lines for a single tool part of the actively streaming assistant +/// message, keyed by a structural hash of the part payload. Avoids re-running +/// the JSON clone + serialize + parse + format (and syntect diff highlighting) +/// pipeline for every unchanged tool row on each streaming layout refresh. +#[derive(Debug, Clone)] +struct CachedToolRow { + data_hash: u64, + width: usize, + colors_hash: u64, + lines: Vec>, +} + +fn hash_json_value(value: &JsonValue, h: &mut impl std::hash::Hasher) { + use std::hash::Hash; + + match value { + JsonValue::Null => 0u8.hash(h), + JsonValue::Bool(b) => { + 1u8.hash(h); + b.hash(h); + } + JsonValue::Number(n) => { + 2u8.hash(h); + if let Some(i) = n.as_i64() { + i.hash(h); + } else if let Some(u) = n.as_u64() { + u.hash(h); + } else { + n.as_f64().unwrap_or(0.0).to_bits().hash(h); + } + } + JsonValue::String(s) => { + 3u8.hash(h); + s.hash(h); + } + JsonValue::Array(items) => { + 4u8.hash(h); + items.len().hash(h); + for item in items { + hash_json_value(item, h); + } + } + JsonValue::Object(map) => { + 5u8.hash(h); + map.len().hash(h); + for (key, item) in map { + key.hash(h); + hash_json_value(item, h); + } + } + } +} + +/// Structural hash of everything that feeds a rendered tool row. +fn tool_part_row_hash(message: &Message, part: &crate::session::types::MessagePart) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut h = std::collections::hash_map::DefaultHasher::new(); + part.part_type.hash(&mut h); + hash_json_value(&part.data, &mut h); + // A tool_result without args borrows them from the matching tool_call. + if part.part_type == "tool_result" && part.data.get("args").is_none() { + if let Some(args) = part + .tool_id() + .and_then(|id| message.tool_call_part_data(id)) + .and_then(|call| call.get("args")) + { + hash_json_value(args, &mut h); + } + } + h.finish() +} + fn path_mention_score(path: &std::path::Path, text: &str) -> Option { let path_text = path.to_string_lossy(); let candidates = [ @@ -198,6 +271,10 @@ pub struct Chat { std::cell::RefCell>, /// Stable formatted tool prefix before the actively streaming final text part. ordered_tool_prefix_cache: std::cell::RefCell>, + /// Rendered tool rows of the actively streaming assistant message, + /// keyed by (message_idx, part_idx) and validated by a payload hash. + ordered_tool_row_cache: + std::cell::RefCell>, /// Earliest streaming assistant index with text appended since the last markdown/layout refresh. pending_streaming_render_dirty_from: Option, /// Whether pending streaming changes include message content that must wait for markdown refresh. @@ -1472,6 +1549,7 @@ impl Chat { streaming_reasoning_renderer_content_len: 0, ordered_markdown_cache: std::cell::RefCell::new(std::collections::HashMap::new()), ordered_tool_prefix_cache: std::cell::RefCell::new(None), + ordered_tool_row_cache: std::cell::RefCell::new(std::collections::HashMap::new()), pending_streaming_render_dirty_from: None, pending_streaming_content_dirty: false, last_streaming_cache_refresh_at: None, @@ -1535,6 +1613,7 @@ impl Chat { streaming_reasoning_renderer_content_len: 0, ordered_markdown_cache: std::cell::RefCell::new(std::collections::HashMap::new()), ordered_tool_prefix_cache: std::cell::RefCell::new(None), + ordered_tool_row_cache: std::cell::RefCell::new(std::collections::HashMap::new()), pending_streaming_render_dirty_from: None, pending_streaming_content_dirty: false, last_streaming_cache_refresh_at: None, @@ -1820,6 +1899,8 @@ impl Chat { self.cached_active_tools_revision.set(0); self.cached_has_active_tools.set(false); *self.ordered_tool_prefix_cache.borrow_mut() = None; + self.ordered_markdown_cache.borrow_mut().clear(); + self.ordered_tool_row_cache.borrow_mut().clear(); self.streaming_renderer_content_len = 0; self.streaming_reasoning_renderer = None; self.streaming_reasoning_message_idx = None; @@ -1849,6 +1930,32 @@ impl Chat { *self.ordered_tool_prefix_cache.borrow_mut() = None; } + fn drop_ordered_tool_row_cache(&self) { + let mut cache = self.ordered_tool_row_cache.borrow_mut(); + if !cache.is_empty() { + *cache = std::collections::HashMap::new(); + } + } + + /// Drop rebuildable render caches to reclaim memory. Used when this chat + /// is stored away as a background session view; everything is rebuilt on + /// the next render. + pub fn release_render_caches(&mut self) { + self.cached_lines = Vec::new(); + self.cached_editor_locations = Vec::new(); + self.cached_positions = Vec::new(); + self.message_line_positions = Vec::new(); + self.cached_revision = 0; + self.cached_width = 0; + self.cached_colors_hash = 0; + self.render_dirty_from = 0; + self.search_cached_revision = 0; + self.search_matches = Vec::new(); + *self.ordered_tool_prefix_cache.borrow_mut() = None; + self.ordered_markdown_cache.borrow_mut().clear(); + self.drop_ordered_tool_row_cache(); + } + fn clear_ordered_tool_prefix_cache_from(&self, message_idx: usize) { let should_clear = self .ordered_tool_prefix_cache @@ -2130,7 +2237,7 @@ impl Chat { (now_epoch_ms() / 500) % 2 == 1 } - fn apply_active_tool_marker_blink(lines: &mut [Line<'static>]) { + fn apply_active_tool_marker_blink(lines: &mut [Line<'_>]) { if !Self::current_tool_marker_animation_phase() { return; } @@ -2236,6 +2343,7 @@ impl Chat { self.streaming_reasoning_renderer = None; self.streaming_reasoning_message_idx = None; self.streaming_reasoning_renderer_content_len = 0; + self.drop_ordered_tool_row_cache(); return; } @@ -2247,6 +2355,7 @@ impl Chat { self.streaming_reasoning_renderer = None; self.streaming_reasoning_message_idx = None; self.streaming_reasoning_renderer_content_len = 0; + self.drop_ordered_tool_row_cache(); return; }; @@ -2257,6 +2366,7 @@ impl Chat { self.streaming_renderer = Some(SimpleStreamingRenderer::new()); self.streaming_message_idx = Some(last_idx); self.streaming_renderer_content_len = 0; + self.drop_ordered_tool_row_cache(); } } else { // No renderer yet, create one @@ -2974,6 +3084,9 @@ impl Chat { } let ((start_line, start_col), (end_line, _)) = self.selection.range(); + // Bound the scan by the cached location table: lines beyond it have no + // locations, and an unclamped selection can extend to usize::MAX. + let end_line = end_line.min(self.cached_editor_locations.len().saturating_sub(1)); for line_idx in start_line..=end_line { let Some(Some(location)) = self.cached_editor_locations.get(line_idx) else { continue; @@ -3390,7 +3503,12 @@ impl Chat { .map(|message| timeline_highlight_bg(message, colors)) .unwrap_or(colors.interactive); - let mut content_lines: Vec> = all_lines[visible_start..visible_end].to_vec(); + // Borrow the visible lines from the cache instead of deep-cloning + // their span strings on every frame. + let mut content_lines: Vec> = all_lines[visible_start..visible_end] + .iter() + .map(borrowed_line) + .collect(); Self::apply_active_tool_marker_blink(&mut content_lines); apply_timeline_highlight_to_lines( &mut content_lines, @@ -4263,36 +4381,57 @@ impl Chat { lines.push(Line::from("")); } "tool_call" | "tool_result" => { - let Some(parsed) = - assistant_tool_part_info(message, part, &result_ids) - else { + // Same skip conditions as assistant_tool_part_info: + // superseded/id-less calls and non-object payloads + // render nothing. + if part.data.as_object().is_none() { continue; - }; - - if let Some(item) = exploration_tool_item(&parsed) { - flush_pending_tasks( - self, - &mut pending_tasks, - &mut lines, - max_width, - colors, - &mut emitted_anything, - ); - pending_exploration.push(item); + } + if part.part_type == "tool_call" + && part.tool_id().is_none_or(|id| result_ids.contains(id)) + { continue; } - if let Some(item) = task_tool_item(&parsed) { - flush_pending_exploration( - self, - &mut pending_exploration, - &mut lines, - max_width, - colors, - &mut emitted_anything, - ); - pending_tasks.push(item); - continue; + // Only these tool names can join exploration/task + // groups; for every other tool skip building the + // parsed info (it deep-clones args/metadata JSON). + let group_candidate = matches!( + part.data.get("name").and_then(JsonValue::as_str), + Some("read" | "list" | "glob" | "grep" | "task") + ); + if group_candidate { + let Some(parsed) = + assistant_tool_part_info(message, part, &result_ids) + else { + continue; + }; + + if let Some(item) = exploration_tool_item(&parsed) { + flush_pending_tasks( + self, + &mut pending_tasks, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + pending_exploration.push(item); + continue; + } + + if let Some(item) = task_tool_item(&parsed) { + flush_pending_exploration( + self, + &mut pending_exploration, + &mut lines, + max_width, + colors, + &mut emitted_anything, + ); + pending_tasks.push(item); + continue; + } } flush_pending_tool_groups( @@ -4305,17 +4444,48 @@ impl Chat { &mut emitted_anything, ); emitted_anything = true; + + let row_key = (idx, part_idx); + let row_hash = tool_part_row_hash(message, part); + let cached_row = self + .ordered_tool_row_cache + .borrow() + .get(&row_key) + .filter(|cached| { + cached.data_hash == row_hash + && cached.width == max_width + && cached.colors_hash == colors_hash + }) + .map(|cached| cached.lines.clone()); + if let Some(row_lines) = cached_row { + lines.extend(row_lines); + lines.push(Line::from("")); + continue; + } + let Some(content) = assistant_tool_part_content(message, part, &result_ids) else { continue; }; let tool_message = Message::tool(content); - let tool_lines = - self.format_tool_row(&tool_message, max_width, colors, true); - for line in tool_lines.into_iter().map(line_to_static) { - lines.push(line); + let tool_lines: Vec> = self + .format_tool_row(&tool_message, max_width, colors, true) + .into_iter() + .map(line_to_static) + .collect(); + if is_streaming { + self.ordered_tool_row_cache.borrow_mut().insert( + row_key, + CachedToolRow { + data_hash: row_hash, + width: max_width, + colors_hash, + lines: tool_lines.clone(), + }, + ); } + lines.extend(tool_lines); lines.push(Line::from("")); } _ => {} @@ -5828,7 +5998,7 @@ fn render_line_backgrounds( } fn apply_timeline_highlight_to_lines( - lines: &mut [Line<'static>], + lines: &mut [Line<'_>], highlight_range: Option<(usize, usize)>, visible_start: usize, bg: Color, @@ -5853,7 +6023,7 @@ fn apply_timeline_highlight_to_lines( } fn apply_search_highlights_to_lines( - lines: &mut [Line<'static>], + lines: &mut [Line<'_>], matches: &[ChatSearchMatch], active_match: Option, visible_start: usize, @@ -5891,21 +6061,26 @@ fn apply_search_highlights_to_lines( } } -fn apply_byte_range_style_to_line( - line: &mut Line<'static>, - start: usize, - end: usize, - style: Style, -) { +fn apply_byte_range_style_to_line<'a>(line: &mut Line<'a>, start: usize, end: usize, style: Style) { if start >= end { return; } + fn cow_slice<'a>( + content: &std::borrow::Cow<'a, str>, + range: std::ops::Range, + ) -> std::borrow::Cow<'a, str> { + match content { + std::borrow::Cow::Borrowed(s) => std::borrow::Cow::Borrowed(&s[range]), + std::borrow::Cow::Owned(s) => std::borrow::Cow::Owned(s[range].to_string()), + } + } + let mut new_spans = Vec::with_capacity(line.spans.len().saturating_add(2)); let mut line_offset = 0usize; for span in std::mem::take(&mut line.spans) { - let content = span.content.into_owned(); + let content = span.content; let span_start = line_offset; let span_end = span_start.saturating_add(content.len()); line_offset = span_end; @@ -5919,16 +6094,22 @@ fn apply_byte_range_style_to_line( let local_end = end.saturating_sub(span_start).min(content.len()); if local_start > 0 { - new_spans.push(Span::styled(content[..local_start].to_string(), span.style)); + new_spans.push(Span::styled( + cow_slice(&content, 0..local_start), + span.style, + )); } if local_end > local_start { new_spans.push(Span::styled( - content[local_start..local_end].to_string(), + cow_slice(&content, local_start..local_end), span.style.patch(style), )); } if local_end < content.len() { - new_spans.push(Span::styled(content[local_end..].to_string(), span.style)); + new_spans.push(Span::styled( + cow_slice(&content, local_end..content.len()), + span.style, + )); } } @@ -6372,6 +6553,23 @@ fn image_index_from_placeholder(placeholder: &str) -> Option { one_based.checked_sub(1) } +/// Shallow copy of a cached line whose spans borrow the original string data. +/// Used for per-frame viewport rendering to avoid deep-cloning span contents. +fn borrowed_line<'a>(line: &'a Line<'_>) -> Line<'a> { + Line { + style: line.style, + alignment: line.alignment, + spans: line + .spans + .iter() + .map(|span| Span { + content: std::borrow::Cow::Borrowed(span.content.as_ref()), + style: span.style, + }) + .collect(), + } +} + fn line_to_static(line: Line<'_>) -> Line<'static> { Line { spans: line @@ -6485,6 +6683,115 @@ mod tests { assert_eq!(chat.scroll_offset, 0); } + #[test] + fn editor_location_for_selection_terminates_on_unclamped_selection() { + let mut chat = Chat::new(); + chat.add_message(Message::assistant("alpha beta")); + // Selection extending far past the cached location table (e.g. before + // the first render clamps it) must not scan the whole range. + chat.selection.active = true; + chat.selection.start_line = 0; + chat.selection.end_line = usize::MAX; + + assert!(chat.editor_location_for_selection().is_none()); + } + + #[test] + fn streaming_tool_rows_are_cached_and_invalidated_by_payload_changes() { + let mut chat = Chat::new(); + let mut msg = Message::incomplete(""); + msg.add_tool_call_part("call_1", "bash", serde_json::json!({ "cmd": "ls" })); + chat.messages.push(msg); + let colors = test_colors(); + + let first = chat + .build_all_lines(80, "model", &colors) + .iter() + .map(trimmed_line_text) + .collect::>(); + assert!( + chat.ordered_tool_row_cache.borrow().contains_key(&(0, 0)), + "streaming tool row should be cached" + ); + + // Warm-cache rebuild must produce identical output. + let second = chat + .build_all_lines(80, "model", &colors) + .iter() + .map(trimmed_line_text) + .collect::>(); + assert_eq!(first, second); + assert!(first + .iter() + .any(|line| line.starts_with(TOOL_MARKER_ACTIVE))); + + // A tool result supersedes the call; the row must re-render. + chat.messages[0].add_or_update_tool_result_part(serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "ok", + "args": { "cmd": "ls" }, + "output_preview": "file.txt", + })); + let third = chat + .build_all_lines(80, "model", &colors) + .iter() + .map(trimmed_line_text) + .collect::>(); + assert_ne!(first, third); + assert!(third.iter().any(|line| line.starts_with(TOOL_MARKER_DONE))); + assert!(third.iter().any(|line| line.contains("file.txt"))); + } + + #[test] + fn completed_messages_do_not_populate_tool_row_cache() { + let mut chat = Chat::new(); + let mut msg = Message::assistant(""); + msg.add_tool_call_part("call_1", "bash", serde_json::json!({ "cmd": "ls" })); + msg.add_or_update_tool_result_part(serde_json::json!({ + "id": "call_1", + "name": "bash", + "status": "ok", + "output_preview": "file.txt", + })); + msg.mark_complete(); + chat.messages.push(msg); + let colors = test_colors(); + + let _ = chat.build_all_lines(80, "model", &colors); + assert!(chat.ordered_tool_row_cache.borrow().is_empty()); + } + + #[test] + fn release_render_caches_forces_full_rebuild_with_identical_output() { + let mut chat = Chat::new(); + chat.add_message(Message::user("hello")); + chat.add_message(Message::assistant("world **bold**")); + let colors = test_colors(); + + chat.ensure_render_cache(80, "model", &colors); + let before = chat + .cached_lines + .iter() + .map(trimmed_line_text) + .collect::>(); + let positions_before = chat.cached_positions.clone(); + assert!(!before.is_empty()); + + chat.release_render_caches(); + assert!(chat.cached_lines.is_empty()); + assert!(chat.cached_positions.is_empty()); + + chat.ensure_render_cache(80, "model", &colors); + let after = chat + .cached_lines + .iter() + .map(trimmed_line_text) + .collect::>(); + assert_eq!(before, after); + assert_eq!(positions_before, chat.cached_positions); + } + #[test] fn stream_rollback_rebuilds_visible_output_and_token_count() { let mut chat = Chat::new(); From 6f1b8df11af52a46f4233da8cf2485b7691c6df4 Mon Sep 17 00:00:00 2001 From: Carlo Taleon Date: Sat, 18 Jul 2026 18:46:34 +0000 Subject: [PATCH 3/5] perf(app): trim per-frame buffer clones and subagent tab rebuilds The event loop cloned the entire terminal cell grid after every full frame to feed the isolated subagent spinner fast-path, even when that path could never run. Keep the copy only while the fast-path is active and reuse its allocation via clone_from; spinner frames also restore the base buffer in place instead of cloning it twice. isolated_subagent_spinner_interval built the full subagent tab list (strings, theme colors, session walks) on every loop iteration just to answer a boolean, and the spinner render built it again for one color. Use a cheap parent-id check and a new allocation-free SessionManager::descendant_position for the tab color instead. Chats stored away as background session views now release their rebuildable line caches, so memory no longer scales with every session visited during a run. Co-authored-by: Carlo Taleon --- src/app.rs | 40 +++++++++++++++++++++------ src/main.rs | 22 ++++++++++++--- src/session/manager.rs | 61 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index b55de21..c6791f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1500,6 +1500,9 @@ impl App { if let Some(state) = self.session_view_states.get_mut(&session_id) { state.chat = std::mem::take(&mut self.chat_state.chat); + // Background sessions are not rendered; drop their rebuildable + // line caches so memory does not scale with every visited session. + state.chat.release_render_caches(); state.find_bar = std::mem::take(&mut self.find_bar); state.input_draft = if is_child_session { String::new() @@ -8272,32 +8275,53 @@ impl App { streaming_only && self.overlay_focus != OverlayFocus::SessionsDialog } + /// Whether the currently viewed session is a subagent child session. + /// Cheap equivalent of building the full tab list and checking + /// `is_child_session`; used on every event-loop iteration. + fn current_session_is_subagent_child(&self) -> bool { + self.session_manager + .get_current_session_id() + .is_some_and(|id| self.session_manager.parent_id_of(id).is_some()) + } + pub fn isolated_subagent_spinner_interval(&self) -> Option { if self.base_focus != BaseFocus::Chat || self.overlay_focus != OverlayFocus::None || !self.is_streaming + || !self.current_session_is_subagent_child() || self.current_session_retry_status().is_some() || self.compaction_receiver.is_some() - || !self - .subagent_tabs_for_current_session() - .is_some_and(|tabs| tabs.is_child_session) { return None; } self.chat_state.chat.tool_heavy_streaming_render_interval() } + /// Spinner color for the currently viewed session without materializing + /// the full subagent tab list on every spinner frame. + fn current_session_spinner_color(&self) -> Option { + let current_id = self.session_manager.get_current_session_id()?; + let root_id = self.session_manager.root_session_id_for(current_id)?; + if *current_id == root_id { + return Some(crate::theme::agent_color( + &self.agent, + &self.get_current_theme_colors(), + )); + } + let idx = self + .session_manager + .descendant_position(&root_id, current_id)?; + Some(agent_color_for_tab(idx, &self.get_current_theme_colors())) + } + pub fn render_isolated_subagent_spinner( &mut self, buffer: &mut ratatui::buffer::Buffer, ) -> bool { - let Some(tabs) = self.subagent_tabs_for_current_session() else { - return false; - }; - let Some(active) = tabs.tabs.iter().find(|tab| tab.active) else { + let Some(color) = self.current_session_spinner_color() else { return false; }; - render_subagent_spinner_only(buffer, &mut self.chat_state.wave_spinner, active.color) + render_subagent_spinner_only(buffer, &mut self.chat_state.wave_spinner, color) } fn has_active_selection_edge_scroll(&self) -> bool { diff --git a/src/main.rs b/src/main.rs index bb84027..940f349 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1210,15 +1210,29 @@ async fn run_event_loop( && !full_render_due { if let Some(base) = last_complete_frame.as_ref() { - let base = base.clone(); terminal.draw(|f| { - *f.buffer_mut() = base; - app.render_isolated_subagent_spinner(f.buffer_mut()); + let buffer = f.buffer_mut(); + buffer.area = base.area; + buffer.content.clone_from(&base.content); + app.render_isolated_subagent_spinner(buffer); })?; } } else { let completed = terminal.draw(|f| app.render(f))?; - last_complete_frame = Some(completed.buffer.clone()); + // Keep a copy of the frame only while the isolated subagent + // spinner fast-path can use it; cloning the full cell grid on + // every frame is wasted work otherwise. + if isolated_spinner_interval.is_some() { + match last_complete_frame.as_mut() { + Some(frame) => { + frame.area = completed.buffer.area; + frame.content.clone_from(&completed.buffer.content); + } + None => last_complete_frame = Some(completed.buffer.clone()), + } + } else { + last_complete_frame = None; + } last_full_render_at = std::time::Instant::now(); } needs_redraw = false; diff --git a/src/session/manager.rs b/src/session/manager.rs index 0fe83fb..d5e1e84 100644 --- a/src/session/manager.rs +++ b/src/session/manager.rs @@ -444,6 +444,37 @@ impl SessionManager { } } + /// Position of `session_id` in the depth-first descendant order used by + /// `descendant_sessions`, without materializing `SessionInfo` records. + pub fn descendant_position(&self, parent_id: &str, session_id: &str) -> Option { + let mut position = 0usize; + self.find_descendant_position(parent_id, session_id, &mut position) + } + + fn find_descendant_position( + &self, + parent_id: &str, + session_id: &str, + position: &mut usize, + ) -> Option { + let children = self.children_by_parent.get(parent_id)?; + + for child_id in children { + if !self.sessions.contains_key(child_id) { + continue; + } + if child_id == session_id { + return Some(*position); + } + *position += 1; + if let Some(found) = self.find_descendant_position(child_id, session_id, position) { + return Some(found); + } + } + + None + } + pub fn child_sessions(&self, parent_id: &str) -> Vec { self.children_by_parent .get(parent_id) @@ -941,6 +972,36 @@ mod tests { assert_eq!(manager.session_counter, 0); } + #[test] + fn descendant_position_matches_descendant_sessions_order() { + let mut manager = SessionManager::new(); + let root = manager.create_session(Some("root".to_string())); + let child_a = + manager.create_child_session(root.clone(), "child-a".to_string(), "A".to_string()); + let grandchild = manager.create_child_session( + child_a.clone(), + "grandchild".to_string(), + "A1".to_string(), + ); + let child_b = + manager.create_child_session(root.clone(), "child-b".to_string(), "B".to_string()); + + let descendants = manager.descendant_sessions(&root); + for (expected_idx, info) in descendants.iter().enumerate() { + assert_eq!( + manager.descendant_position(&root, &info.id), + Some(expected_idx), + "position mismatch for {}", + info.id + ); + } + assert_eq!(manager.descendant_position(&root, &grandchild), Some(1)); + assert_eq!(manager.descendant_position(&root, &child_b), Some(2)); + assert_eq!(manager.descendant_position(&root, "missing"), None); + assert_eq!(manager.descendant_position(&root, &root), None); + assert_eq!(manager.descendant_position(&child_a, &grandchild), Some(0)); + } + #[test] fn test_create_session_default_name() { let mut manager = SessionManager::new(); From 19bf9f4376be515f2f62d50117348af3c8a36df4 Mon Sep 17 00:00:00 2001 From: Carlo Taleon Date: Sat, 18 Jul 2026 18:48:52 +0000 Subject: [PATCH 4/5] perf(persistence): move message parts during snapshot conversion The session-to-persistence message conversion cloned every part's JSON payload; combined with the caller's own clone this deep-copied the whole transcript twice per streaming snapshot. Consume the owned parts instead. Co-authored-by: Carlo Taleon --- src/persistence/conversions.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/persistence/conversions.rs b/src/persistence/conversions.rs index 439d099..1044235 100644 --- a/src/persistence/conversions.rs +++ b/src/persistence/conversions.rs @@ -8,6 +8,8 @@ use crate::session::types::{ impl From for Message { fn from(msg: SessionMessage) -> Self { + // Move the owned parts instead of cloning them: this conversion runs + // for the whole transcript on every streaming snapshot. let mut parts: Vec = if msg.parts.is_empty() { let mut parts = Vec::new(); if !msg.content.is_empty() { @@ -19,10 +21,10 @@ impl From for Message { parts } else { msg.parts - .iter() + .into_iter() .map(|part| PersistenceMessagePart { - part_type: part.part_type.clone(), - data: part.data.clone(), + part_type: part.part_type, + data: part.data, }) .collect() }; From 7d9b5d5bd7d59f39c148245117acc94011c54107 Mon Sep 17 00:00:00 2001 From: Carlo Taleon Date: Sat, 18 Jul 2026 19:28:56 +0000 Subject: [PATCH 5/5] perf(app): make subagent tab switching warm-cache and fix per-refresh usage walk Switching between subagent tabs (ctrl+x down, left/right) rebuilt the entire transcript layout on every switch because render caches were released whenever a chat was stored away. Retain caches for the current session family (shared root) so cycling subagent tabs is a warm-cache render again, and release only chats outside the family, keeping the memory bound from the earlier change. While viewing a streaming session, the status-bar usage text re-walked the whole transcript on every layout refresh, re-serializing tool args (entire file bodies for edit/write) and re-parsing tool payloads to estimate tokens. Cache the completed-message token base keyed by session, message count, and streaming index; only the streaming message's counter changes per refresh. Editor-location inference over the streaming message also deep-cloned tool args per refresh via ParsedToolMessage and allocated three display strings per path candidate per line. Read payloads by reference, precompute mention needles once per pass, and reuse one line-text buffer. Co-authored-by: Carlo Taleon --- src/app.rs | 219 ++++++++++++++++++++++++++++++++++---- src/ui/components/chat.rs | 191 +++++++++++++++++++++++---------- 2 files changed, 332 insertions(+), 78 deletions(-) diff --git a/src/app.rs b/src/app.rs index c6791f5..0689c8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -860,6 +860,7 @@ pub struct App { discovery: Option, cached_usage_text: String, cached_usage_check: (usize, u64, usize), + cached_usage_streaming_base: Option, terminal_title_enabled: bool, terminal_title_items: Vec, terminal_title_last: Option, @@ -867,6 +868,16 @@ pub struct App { remote_launch_request: Option, } +/// Cached sum of context tokens for all completed messages of the currently +/// viewed streaming session; only the streaming message changes per refresh. +#[derive(Debug, Clone, PartialEq, Eq)] +struct StreamingUsageBase { + session_id: Option, + message_count: usize, + streaming_idx: Option, + base_tokens: usize, +} + impl App { pub fn new() -> Result { Self::new_with_model_override(None) @@ -1167,6 +1178,7 @@ impl App { discovery, cached_usage_text: String::new(), cached_usage_check: (0, 0, 0), + cached_usage_streaming_base: None, terminal_title_enabled: crate::notify::terminal_title_supported(), terminal_title_items, terminal_title_last: None, @@ -1500,9 +1512,6 @@ impl App { if let Some(state) = self.session_view_states.get_mut(&session_id) { state.chat = std::mem::take(&mut self.chat_state.chat); - // Background sessions are not rendered; drop their rebuildable - // line caches so memory does not scale with every visited session. - state.chat.release_render_caches(); state.find_bar = std::mem::take(&mut self.find_bar); state.input_draft = if is_child_session { String::new() @@ -1512,6 +1521,32 @@ impl App { } } + /// Free the rebuildable render caches of background chats that are not + /// part of the current session family (shared root session). Chats inside + /// the family keep their caches so cycling between subagent tabs stays a + /// warm-cache render, while memory does not scale with every session + /// visited during a run. + fn release_render_caches_outside_current_family(&mut self) { + let current_root = self + .session_manager + .get_current_session_id() + .and_then(|id| self.session_manager.root_session_id_for(id)); + let current_id = self.session_manager.get_current_session_id().cloned(); + let manager = &self.session_manager; + + for (id, state) in self.session_view_states.iter_mut() { + if current_id.as_deref() == Some(id.as_str()) { + continue; + } + let in_family = current_root + .as_deref() + .is_some_and(|root| manager.root_session_id_for(id).as_deref() == Some(root)); + if !in_family { + state.chat.release_render_caches(); + } + } + } + fn load_session_view_state(&mut self, session_id: &str) { self.ensure_session_view_state(session_id); let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); @@ -1544,6 +1579,7 @@ impl App { self.session_manager.switch_session(session_id); self.pending_session_title = None; self.load_session_view_state(session_id); + self.release_render_caches_outside_current_family(); let is_child_session = self.session_manager.parent_id_of(session_id).is_some(); self.base_focus = if !is_child_session && self.chat_state.chat.messages.is_empty() @@ -2168,13 +2204,13 @@ impl App { format!("Ask anything... \"{}\"", suggestions[index]) } - fn session_usage_text(&self) -> String { - let messages = &self.chat_state.chat.messages; + fn session_usage_text(&mut self) -> String { let total_tokens = if self.is_streaming { - Self::streaming_context_tokens(messages, self.chat_state.chat.streaming_token_count()) + self.streaming_context_tokens_cached() } else { - crate::session::compaction::total_context_tokens(messages) + crate::session::compaction::total_context_tokens(&self.chat_state.chat.messages) }; + let messages = &self.chat_state.chat.messages; let mut text = if total_tokens == 0 { String::new() @@ -2227,25 +2263,55 @@ impl App { text } - fn streaming_context_tokens( - messages: &[crate::session::types::Message], - streaming_token_count: usize, - ) -> usize { + /// Streaming variant of the usage token count with a cached base. + /// + /// `message_context_tokens` re-serializes tool args and re-parses tool + /// payloads, so walking the whole transcript on every streaming layout + /// refresh is O(transcript bytes). Completed messages cannot change while + /// their count and the streaming index stay the same, so only the actively + /// streaming message (already tracked by the chat's token counter) needs + /// per-refresh accounting. + fn streaming_context_tokens_cached(&mut self) -> usize { + let session_id = self.session_manager.get_current_session_id().cloned(); + let messages = &self.chat_state.chat.messages; + let message_count = messages.len(); let streaming_idx = messages.iter().rposition(|message| { message.role == crate::session::types::MessageRole::Assistant && !message.is_complete }); - messages - .iter() - .enumerate() - .map(|(idx, message)| { - if Some(idx) == streaming_idx { - streaming_token_count - } else { - crate::session::compaction::message_context_tokens(message) - } - }) - .sum() + let cache_valid = self + .cached_usage_streaming_base + .as_ref() + .is_some_and(|base| { + base.session_id == session_id + && base.message_count == message_count + && base.streaming_idx == streaming_idx + }); + if !cache_valid { + let base_tokens = messages + .iter() + .enumerate() + .filter(|(idx, _)| Some(*idx) != streaming_idx) + .map(|(_, message)| crate::session::compaction::message_context_tokens(message)) + .sum(); + self.cached_usage_streaming_base = Some(StreamingUsageBase { + session_id, + message_count, + streaming_idx, + base_tokens, + }); + } + + let base_tokens = self + .cached_usage_streaming_base + .as_ref() + .map(|base| base.base_tokens) + .unwrap_or(0); + if streaming_idx.is_some() { + base_tokens.saturating_add(self.chat_state.chat.streaming_token_count()) + } else { + base_tokens + } } fn reasoning_capability_for_model( @@ -10744,6 +10810,7 @@ mod tests { discovery: None, cached_usage_text: String::new(), cached_usage_check: (0, 0, 0), + cached_usage_streaming_base: None, terminal_title_enabled: false, terminal_title_items: crate::terminal_title::default_items(), terminal_title_last: None, @@ -13212,6 +13279,114 @@ mod tests { ); } + #[test] + fn streaming_usage_base_caches_completed_messages_and_tracks_appends() { + let mut app = test_app(); + app.chat_state + .chat + .add_message(crate::session::types::Message::user("hello there")); + let mut done = crate::session::types::Message::assistant("finished answer"); + done.token_count = Some(100); + app.chat_state.chat.add_message(done); + app.chat_state + .chat + .add_message(crate::session::types::Message::incomplete("streaming...")); + + let fresh = |app: &App| -> usize { + let messages = &app.chat_state.chat.messages; + let streaming_idx = messages.iter().rposition(|message| { + message.role == crate::session::types::MessageRole::Assistant + && !message.is_complete + }); + messages + .iter() + .enumerate() + .map(|(idx, message)| { + if Some(idx) == streaming_idx { + app.chat_state.chat.streaming_token_count() + } else { + crate::session::compaction::message_context_tokens(message) + } + }) + .sum() + }; + + let expected = fresh(&app); + assert_eq!(app.streaming_context_tokens_cached(), expected); + // Cached path must agree with a fresh walk on repeat calls. + assert_eq!(app.streaming_context_tokens_cached(), expected); + let cached_base = app + .cached_usage_streaming_base + .clone() + .expect("base cached"); + + // Appending a message invalidates the cached base. + let mut extra = crate::session::types::Message::assistant("more context"); + extra.token_count = Some(40); + let last_idx = app.chat_state.chat.messages.len() - 1; + app.chat_state.chat.messages.insert(last_idx, extra); + let expected_after = fresh(&app); + assert_eq!(app.streaming_context_tokens_cached(), expected_after); + assert_ne!( + app.cached_usage_streaming_base, + Some(cached_base), + "cache should refresh when the message count changes" + ); + assert!(expected_after > expected); + } + + #[test] + fn switching_sessions_keeps_family_render_caches_and_releases_others() { + let mut app = test_app(); + let colors = app.get_current_theme_colors(); + + let other = app.create_new_session(Some("Other".to_string())); + app.chat_state + .chat + .add_message(crate::session::types::Message::user("other session")); + app.chat_state + .chat + .ensure_render_cache(80, "model", &colors); + assert!(app.chat_state.chat.has_render_cache()); + + let root = app.create_new_session(Some("Root".to_string())); + app.chat_state + .chat + .add_message(crate::session::types::Message::user("root session")); + let child_a = app.session_manager.create_child_session( + root.clone(), + "child-a".to_string(), + "Subagent A".to_string(), + ); + let child_b = app.session_manager.create_child_session( + root.clone(), + "child-b".to_string(), + "Subagent B".to_string(), + ); + app.session_manager.switch_session(&root); + + // Warm the render cache of child A, then switch to child B. + assert!(app.switch_to_session(&child_a)); + app.chat_state + .chat + .add_message(crate::session::types::Message::user("child a transcript")); + app.chat_state + .chat + .ensure_render_cache(80, "model", &colors); + assert!(app.switch_to_session(&child_b)); + + // Child A shares the current root: its caches must stay warm. + assert!(app + .session_view_states + .get(&child_a) + .is_some_and(|state| state.chat.has_render_cache())); + // The unrelated session's caches must be released. + assert!(app + .session_view_states + .get(&other) + .is_some_and(|state| !state.chat.has_render_cache())); + } + #[test] fn start_blank_session_does_not_create_session_record() { let mut app = test_app(); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index af3d209..0a2f7f5 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -201,25 +201,41 @@ fn tool_part_row_hash(message: &Message, part: &crate::session::types::MessagePa h.finish() } -fn path_mention_score(path: &std::path::Path, text: &str) -> Option { - let path_text = path.to_string_lossy(); - let candidates = [ - path_text.into_owned(), - display_path(&path.to_string_lossy(), false), - display_path(&path.to_string_lossy(), true), - ]; +/// A tool path candidate with its precomputed display variants, so per-line +/// mention checks are plain substring searches without new allocations. +struct PathMentionCandidate { + path: std::path::PathBuf, + needles: [String; 3], +} - candidates - .iter() - .filter(|candidate| !candidate.is_empty() && text.contains(candidate.as_str())) - .map(|candidate| candidate.len()) - .max() +impl PathMentionCandidate { + fn new(path: std::path::PathBuf) -> Self { + let path_text = path.to_string_lossy(); + let needles = [ + display_path(&path_text, false), + display_path(&path_text, true), + path_text.into_owned(), + ]; + Self { path, needles } + } + + fn mention_score(&self, text: &str) -> Option { + self.needles + .iter() + .filter(|needle| !needle.is_empty() && text.contains(needle.as_str())) + .map(|needle| needle.len()) + .max() + } } -fn mentioned_path(candidates: &[std::path::PathBuf], text: &str) -> Option { +fn mentioned_path(candidates: &[PathMentionCandidate], text: &str) -> Option { candidates .iter() - .filter_map(|path| path_mention_score(path, text).map(|score| (path, score))) + .filter_map(|candidate| { + candidate + .mention_score(text) + .map(|score| (&candidate.path, score)) + }) .max_by_key(|(_, score)| *score) .map(|(path, _)| path.clone()) } @@ -1047,8 +1063,13 @@ fn push_terminal_preview<'a>( } } -fn tool_path_candidates(message: &Message) -> Vec { - let mut candidates = Vec::new(); +fn push_tool_path_refs( + name: &str, + args_obj: Option<&serde_json::Map>, + metadata_obj: Option<&serde_json::Map>, + title: Option<&str>, + candidates: &mut Vec, +) { let mut push_candidate = |value: Option<&str>| { if let Some(path) = value.and_then(path_candidate_from_value) { if !candidates.iter().any(|candidate| candidate == &path) { @@ -1057,46 +1078,52 @@ fn tool_path_candidates(message: &Message) -> Vec { } }; - let mut push_tool_info = |info: ParsedToolMessage| { - let args_obj = info.args.as_ref().and_then(|value| value.as_object()); - let metadata_obj = info.metadata.as_ref().and_then(|value| value.as_object()); - for key in ["path", "file_path", "filePath"] { - push_candidate(arg_string(args_obj, &[key])); - push_candidate(arg_string(metadata_obj, &[key])); - } + for key in ["path", "file_path", "filePath"] { + push_candidate(arg_string(args_obj, &[key])); + push_candidate(arg_string(metadata_obj, &[key])); + } - if info.name == "apply_patch" { - if let Some(patch) = arg_string(args_obj, &["patch"]) { - for path in crate::tools::patch::extract_patch_paths(patch) { - push_candidate(Some(&path)); - } + if name == "apply_patch" { + if let Some(patch) = arg_string(args_obj, &["patch"]) { + for path in crate::tools::patch::extract_patch_paths(patch) { + push_candidate(Some(&path)); } } + } - if info.name == "write_files" { - if let Some(files) = args_obj - .and_then(|obj| obj.get("files")) - .and_then(|value| value.as_array()) - { - for file in files { - let path = file.as_object().and_then(|obj| { - obj.get("file_path") - .or_else(|| obj.get("filePath")) - .and_then(|value| value.as_str()) - }); - push_candidate(path); - } + if name == "write_files" { + if let Some(files) = args_obj + .and_then(|obj| obj.get("files")) + .and_then(|value| value.as_array()) + { + for file in files { + let path = file.as_object().and_then(|obj| { + obj.get("file_path") + .or_else(|| obj.get("filePath")) + .and_then(|value| value.as_str()) + }); + push_candidate(path); } } + } - if let Some(title) = info.title.as_deref() { - push_candidate(title.split_once(':').map(|(_, path)| path.trim())); - } - }; + if let Some(title) = title { + push_candidate(title.split_once(':').map(|(_, path)| path.trim())); + } +} + +fn tool_path_candidates(message: &Message) -> Vec { + let mut candidates = Vec::new(); if message.role == MessageRole::Tool { if let Some(info) = parse_tool_message(&message.content) { - push_tool_info(info); + push_tool_path_refs( + &info.name, + info.args.as_ref().and_then(|value| value.as_object()), + info.metadata.as_ref().and_then(|value| value.as_object()), + info.title.as_deref(), + &mut candidates, + ); } } else if message.role == MessageRole::Assistant { let result_ids = assistant_tool_result_ids(message); @@ -1104,9 +1131,39 @@ fn tool_path_candidates(message: &Message) -> Vec { if !matches!(part.part_type.as_str(), "tool_call" | "tool_result") { continue; } - if let Some(info) = assistant_tool_part_info(message, part, &result_ids) { - push_tool_info(info); + // Same visibility rules as assistant_tool_part_info, but reading + // the payload by reference: cloning args deep-copies entire file + // bodies for edit/write tools and this runs on every streaming + // layout refresh of the message. + if part.data.as_object().is_none() { + continue; + } + if part.part_type == "tool_call" + && part.tool_id().is_none_or(|id| result_ids.contains(id)) + { + continue; } + + let args = match part.data.get("args") { + Some(args) => Some(args), + None if part.part_type == "tool_result" => part + .tool_id() + .and_then(|id| message.tool_call_part_data(id)) + .and_then(|call| call.get("args")), + None => None, + }; + push_tool_path_refs( + part.data + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("tool"), + args.and_then(|value| value.as_object()), + part.data + .get("metadata") + .and_then(|value| value.as_object()), + part.data.get("title").and_then(|value| value.as_str()), + &mut candidates, + ); } } @@ -1937,6 +1994,11 @@ impl Chat { } } + /// Whether the wrapped-line render cache is currently materialized. + pub fn has_render_cache(&self) -> bool { + !self.cached_lines.is_empty() + } + /// Drop rebuildable render caches to reclaim memory. Used when this chat /// is stored away as a background session view; everything is rebuilt on /// the next render. @@ -3316,7 +3378,12 @@ impl Chat { self.update_scrollbar(); } - fn ensure_render_cache(&mut self, max_width: usize, model: &str, colors: &ThemeColors) { + pub(crate) fn ensure_render_cache( + &mut self, + max_width: usize, + model: &str, + colors: &ThemeColors, + ) { self.update_streaming_renderer(max_width.max(1), colors); let colors_hash = Self::cache_colors_hash(colors); @@ -6118,10 +6185,17 @@ fn apply_byte_range_style_to_line<'a>(line: &mut Line<'a>, start: usize, end: us fn plain_line_text(line: &Line<'_>) -> String { let mut text = String::new(); + plain_line_text_into(line, &mut text); + text +} + +/// Collect a line's plain text into a reusable buffer to avoid one String +/// allocation per line in per-refresh scans. +fn plain_line_text_into(line: &Line<'_>, text: &mut String) { + text.clear(); for span in &line.spans { text.push_str(span.content.as_ref()); } - text } fn lowercase_with_original_byte_map(text: &str) -> (String, Vec) { @@ -6213,23 +6287,28 @@ fn infer_editor_locations_for_lines( lines: &[Line<'_>], ) -> Vec> { let mut locations = vec![None; lines.len()]; + let mut probe = String::new(); let has_diff_content = lines.iter().any(|line| { - let text = plain_line_text(line); - parse_rendered_diff_line(&text).is_some() - || parse_sign_only_rendered_diff_line(&text).is_some() + plain_line_text_into(line, &mut probe); + parse_rendered_diff_line(&probe).is_some() + || parse_sign_only_rendered_diff_line(&probe).is_some() }); if !has_diff_content { return locations; } - let candidates = tool_path_candidates(message); + let candidates = tool_path_candidates(message) + .into_iter() + .map(PathMentionCandidate::new) + .collect::>(); if candidates.is_empty() { return locations; } let mut active: Option = None; + let mut text = String::new(); for (idx, line) in lines.iter().enumerate() { - let text = plain_line_text(line); + plain_line_text_into(line, &mut text); let direct_parsed = parse_rendered_diff_line(&text); if direct_parsed.is_none() { if let Some(path) = mentioned_path(&candidates, &text) { @@ -6276,7 +6355,7 @@ fn infer_editor_locations_for_lines( continue; } active = Some(RenderedDiffLocationState { - path: candidates[0].clone(), + path: candidates[0].path.clone(), line: 0, content_start_col: 0, next_content_col: 0,