diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-23 20:31:53 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-23 20:31:53 -0700 |
| commit | f72fdf7565d18b044f035fa6aca9ae8dbba34fc6 (patch) | |
| tree | d0c08204ed712f0f788cd262f56894b8a2af7d49 /crates/atuin-ai/src/driver.rs | |
| parent | feat: Add skill discovery, loading, and invocation (#3444) (diff) | |
| download | atuin-f72fdf7565d18b044f035fa6aca9ae8dbba34fc6.zip | |
perf: Reduce AI TUI rendering overhead for long conversations (#3447)
Fixes keystroke lag in Atuin AI that scales with conversation length.
After extended use (many turns, lots of tool calls with output
viewports), pressing a key had noticeable delay before the letter
appeared.
Three layers of optimization:
- **Skip `sync_view_state` for `InputUpdated`** — every keystroke was
cloning all events, tools, and archived data even though no FSM state
changed. Uses `handle.update_tracked()` (eye_declare 0.5) to skip
rebuilds when values haven't actually changed.
- **Pre-compute turns and `has_command` on the driver thread** — the
view function was rebuilding the full turn structure from raw events and
scanning for `suggest_command` tool calls 3× per render frame. Now
computed once per FSM state change and cached in ViewState.
- **Commit-based element tree pruning** — turns that scroll into
terminal scrollback are tracked via `on_commit` and filtered from the
element tree, keeping rendering work proportional to visible content.
Turn views are now direct children of the root VStack (not nested inside
AtuinAi) so `detect_committed` can see them.
Diffstat (limited to 'crates/atuin-ai/src/driver.rs')
| -rw-r--r-- | crates/atuin-ai/src/driver.rs | 74 |
1 files changed, 55 insertions, 19 deletions
diff --git a/crates/atuin-ai/src/driver.rs b/crates/atuin-ai/src/driver.rs index 1285f2da..ddb839b7 100644 --- a/crates/atuin-ai/src/driver.rs +++ b/crates/atuin-ai/src/driver.rs @@ -27,6 +27,7 @@ use crate::stream::ChatRequest; use crate::tools::ClientToolCall; use crate::tui::events::{AiTuiEvent, PermissionResult}; use crate::tui::state::ConversationEvent; +use crate::tui::view::turn; // ============================================================================ // Driver event — the unified channel type @@ -82,6 +83,12 @@ pub(crate) struct ViewState { // ─── View-only ────────────────────────────────────────────── pub archived_events: Vec<ConversationEvent>, + // ─── Pre-computed for rendering ──────────────────────────── + pub turns: Vec<turn::UiTurn>, + pub has_command: bool, + pub committed_turn_count: usize, + pub archived_turn_count: usize, + // ─── Ephemeral interaction state ──────────────────────────── pub is_input_blank: bool, pub slash_command_input: Option<String>, @@ -113,21 +120,10 @@ impl ViewState { matches!(self.agent_state, AgentState::Idle { .. }) && !self.has_confirmation() } - /// Whether any command has been suggested in the current invocation. - pub fn has_command(&self) -> bool { - self.visible_events.iter().any(|e| { - if let ConversationEvent::ToolCall { name, input, .. } = e { - name == "suggest_command" && input.get("command").and_then(|v| v.as_str()).is_some() - } else { - false - } - }) - } - pub fn footer_text(&self) -> &'static str { match &self.agent_state { AgentState::Idle { confirmation: None } => { - if self.has_command() && self.is_input_blank { + if self.has_command && self.is_input_blank { "[Enter] Execute suggested command [Tab] Insert Command" } else { "[Enter] Send [Shift+Enter] New line [Esc] Exit" @@ -220,10 +216,10 @@ pub(crate) fn run_driver( if !effects.is_empty() { sync_view_state(&handle, &fsm, in_git_project); } - } else { - // Event was handled directly (e.g. InputUpdated) — just sync - sync_view_state(&handle, &fsm, in_git_project); } + // InputUpdated (the only event that returns None) already pushed + // its view-only changes via handle.update() — no FSM state changed, + // so skip the expensive sync_view_state that clones all events. if exiting.load(Ordering::Acquire) { break; @@ -273,8 +269,14 @@ fn translate_tui_event(event: AiTuiEvent, handle: &Handle<ViewState>) -> Option< } AiTuiEvent::InputUpdated(text) => { let is_blank = text.is_empty(); - handle.update(move |vs| { - vs.is_input_blank = is_blank; + + // Hot path (every keystroke); uses handle.update_tracked + // to allow read()ing the state without marking it dirty. + handle.update_tracked(move |vs| { + if vs.read().is_input_blank != is_blank { + vs.is_input_blank = is_blank; + } + if text.starts_with('/') { let query = text.trim_start_matches('/').to_string(); let mut results = vs.slash_registry.search_fuzzy(&query); @@ -286,8 +288,13 @@ fn translate_tui_event(event: AiTuiEvent, handle: &Handle<ViewState>) -> Option< vs.slash_command_input = Some(query); vs.slash_command_search_results = results; } else { - vs.slash_command_input = None; - vs.slash_command_search_results.clear(); + if vs.read().slash_command_input.is_some() { + vs.slash_command_input = None; + } + + if !vs.read().slash_command_search_results.is_empty() { + vs.slash_command_search_results.clear(); + } } }); None @@ -407,6 +414,32 @@ fn sync_view_state(handle: &Handle<ViewState>, fsm: &AgentFsm, in_git_project: b }); } + // Pre-compute turns and has_command on the driver thread so the + // render-thread view function doesn't redo O(n) work every frame. + let mut archived_builder = turn::TurnBuilder::new(&tools); + for event in &archived_events { + archived_builder.add_event(event); + } + let archived_turns = archived_builder.build(); + let archived_turn_count = archived_turns.len(); + + let mut visible_builder = turn::TurnBuilder::new_starting_at(&tools, archived_turn_count); + for event in &visible_events { + visible_builder.add_event(event); + } + let visible_turns = visible_builder.build(); + + let mut turns = archived_turns; + turns.extend(visible_turns); + + let has_command = visible_events.iter().any(|e| { + if let ConversationEvent::ToolCall { name, input, .. } = e { + name == "suggest_command" && input.get("command").and_then(|v| v.as_str()).is_some() + } else { + false + } + }); + tracing::trace!(?state, "sync_view_state pushing to handle"); handle.update(move |vs| { vs.agent_state = state; @@ -419,6 +452,9 @@ fn sync_view_state(handle: &Handle<ViewState>, fsm: &AgentFsm, in_git_project: b vs.last_event_time = last_event_time; vs.in_git_project = in_git_project; vs.archived_events = archived_events; + vs.turns = turns; + vs.has_command = has_command; + vs.archived_turn_count = archived_turn_count; }); } |
