diff options
Diffstat (limited to 'crates/atuin-ai')
| -rw-r--r-- | crates/atuin-ai/Cargo.toml | 2 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/inline.rs | 48 | ||||
| -rw-r--r-- | crates/atuin-ai/src/driver.rs | 74 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 81 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 111 |
5 files changed, 210 insertions, 106 deletions
diff --git a/crates/atuin-ai/Cargo.toml b/crates/atuin-ai/Cargo.toml index 90e64b2a..377017b7 100644 --- a/crates/atuin-ai/Cargo.toml +++ b/crates/atuin-ai/Cargo.toml @@ -45,7 +45,7 @@ async-stream = "0.3" uuid = { workspace = true } tui-textarea-2 = "0.10.2" unicode-width = "0.2" -eye_declare = "0.4.3" +eye_declare = "0.5" ratatui-core = "0.1" ratatui-widgets = "0.3" thiserror = { workspace = true } diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index 70f26c65..989b95c0 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -292,6 +292,17 @@ async fn run_inline_tui( .bracketed_paste(true) .with_context(tui_tx) .extra_newlines_at_exit(1) + .on_commit(|committed, state| { + if let Some(key) = &committed.key + && let Some(id_str) = key.strip_prefix("turn-") + && let Ok(id) = id_str.parse::<usize>() + { + let new_count = id + 1; + if new_count > state.committed_turn_count { + state.committed_turn_count = new_count; + } + } + }) .build()?; // ─── Driver loop ──────────────────────────────────────────── @@ -349,17 +360,48 @@ fn build_view_state( skill_names.insert(skill.name.clone()); } + let tools = fsm.ctx.tools.clone(); + let visible_events = fsm.ctx.events[safe_start..].to_vec(); + let archived_events = fsm.ctx.archived_events.clone(); + + let mut archived_builder = crate::tui::view::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 = + crate::tui::view::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| { + matches!(e, ConversationEvent::ToolCall { name, input, .. } + if name == "suggest_command" + && input.get("command").and_then(|v| v.as_str()).is_some()) + }); + ViewState { agent_state: fsm.state.clone(), - visible_events: fsm.ctx.events[safe_start..].to_vec(), + visible_events, all_events: fsm.ctx.events.clone(), session_id: fsm.ctx.session_id.clone(), - tools: fsm.ctx.tools.clone(), + tools, current_response: fsm.ctx.current_response.clone(), is_resumed: fsm.ctx.is_resumed, last_event_time: fsm.ctx.last_event_time, in_git_project, - archived_events: fsm.ctx.archived_events.clone(), + archived_events, + turns, + has_command, + committed_turn_count: 0, + archived_turn_count, is_input_blank: true, slash_command_input: None, slash_command_search_results: Vec::new(), 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; }); } diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index 96ad5d85..73dc2ad7 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -18,7 +18,7 @@ use super::components::markdown::Markdown; use super::components::select::Select; use super::state::AppMode; -mod turn; +pub(crate) mod turn; impl From<&AgentState> for AppMode { fn from(state: &AgentState) -> Self { @@ -42,50 +42,48 @@ impl From<&AgentState> for AppMode { /// - Spacer /// - Input box (bordered, with contextual keybindings) pub(crate) fn ai_view(state: &ViewState) -> Elements { - let mut turn_builder = turn::TurnBuilder::new(&state.tools); - - for event in &state.archived_events { - turn_builder.add_event(event); - } - for event in &state.visible_events { - turn_builder.add_event(event); - } - let turns = turn_builder.build(); - + let committed = state.committed_turn_count; + let turns: Vec<&turn::UiTurn> = state.turns.iter().filter(|t| t.id >= committed).collect(); let busy = state.is_busy(); let last_index = turns.len().saturating_sub(1); + // Turns are direct children of the root VStack so that eye_declare's + // on_commit can detect them scrolling into terminal scrollback and + // prune them from the tree. AtuinAi wraps only the interactive footer + // (input box, error display, pending banner) so its event capture/bubble + // handlers still fire for keyboard events. element! { + #(if state.is_resumed && (!state.is_exiting() || !turns.is_empty()) { + SessionContinue(key: "continuation-notice", continued_at: state.last_event_time) + }) + + #(for (index, turn) in turns.iter().enumerate() { + #(match &turn.kind { + turn::UiTurnKind::User { events } => { + user_turn_view(events, index == 0, turn.id) + } + turn::UiTurnKind::Agent { events } => { + agent_turn_view(events, busy && index == last_index, state.tools.awaiting_permission().is_some(), turn.id) + } + turn::UiTurnKind::OutOfBand { events } => { + out_of_band_turn_view(events, turn.id) + } + }) + }) + AtuinAi( + key: "footer", mode: AppMode::from(&state.agent_state), - has_command: state.has_command(), + has_command: state.has_command, is_input_blank: state.is_input_blank, pending_confirmation: state.has_confirmation(), has_executing_preview: state.tools.has_executing_preview(), ) { - #(if state.is_resumed && (!state.is_exiting() || !turns.is_empty()) { - SessionContinue(key: "continuation-notice", continued_at: state.last_event_time) - }) - - #(for (index, turn) in turns.iter().enumerate() { - #(match turn { - turn::UiTurn::User { events } => { - user_turn_view(events, index == 0) - } - turn::UiTurn::Agent { events } => { - agent_turn_view(events, busy && index == last_index, state.tools.awaiting_permission().is_some()) - } - turn::UiTurn::OutOfBand { events } => { - out_of_band_turn_view(events) - } - }) - }) - #({ - let needs_pending_banner = busy && !matches!(turns.last(), Some(turn::UiTurn::Agent { .. })); + let needs_pending_banner = busy && !matches!(turns.last(), Some(turn::UiTurn { kind: turn::UiTurnKind::Agent { .. }, .. })); if needs_pending_banner { let empty: &[turn::UiEvent] = &[]; - agent_turn_view(empty, true, false) + agent_turn_view(empty, true, false, usize::MAX) } else { element! {} } @@ -133,7 +131,7 @@ fn input_view(state: &ViewState) -> Elements { slash_suggestion: first_slash_result.cloned() ) - #(if state.is_input_blank && state.has_command() && state.is_input_active() { + #(if state.is_input_blank && state.has_command && state.is_input_active() { #(if state.has_confirmation() { Text { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } } else { @@ -244,7 +242,7 @@ fn permission_options_for_tool(tool: &ClientToolCall, in_git_project: bool) -> V } } -fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements { +fn user_turn_view(events: &[turn::UiEvent], first_turn: bool, turn_id: usize) -> Elements { let label_style = Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD); @@ -252,7 +250,7 @@ fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements { let padding = if first_turn { 0 } else { 1 }; element! { - View(padding_top: Cells::from(padding)) { + View(key: format!("turn-{turn_id}"), padding_top: Cells::from(padding)) { Text { Span(text: " You ", style: label_style.reversed()) } @@ -274,13 +272,18 @@ fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements { } } -fn agent_turn_view(events: &[turn::UiEvent], busy: bool, showing_ui: bool) -> Elements { +fn agent_turn_view( + events: &[turn::UiEvent], + busy: bool, + showing_ui: bool, + turn_id: usize, +) -> Elements { let label_style = Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD); element! { - View { + View(key: format!("turn-{turn_id}")) { Text { Span(text: " Atuin AI ", style: label_style.reversed()) } @@ -360,9 +363,9 @@ fn agent_turn_view(events: &[turn::UiEvent], busy: bool, showing_ui: bool) -> El } } -fn out_of_band_turn_view(events: &[turn::UiEvent]) -> Elements { +fn out_of_band_turn_view(events: &[turn::UiEvent], turn_id: usize) -> Elements { element! { - View { + View(key: format!("turn-{turn_id}")) { Text { Span(text: " System ", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD).add_modifier(Modifier::REVERSED)) } diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs index 9f4460eb..c74395b8 100644 --- a/crates/atuin-ai/src/tui/view/turn.rs +++ b/crates/atuin-ai/src/tui/view/turn.rs @@ -204,16 +204,23 @@ pub(crate) enum ToolResultStatus { } #[derive(Debug)] -pub(crate) enum UiTurn { +pub(crate) struct UiTurn { + pub(crate) id: usize, + pub(crate) kind: UiTurnKind, +} + +#[derive(Debug)] +pub(crate) enum UiTurnKind { User { events: Vec<UiEvent> }, Agent { events: Vec<UiEvent> }, OutOfBand { events: Vec<UiEvent> }, } pub(crate) struct TurnBuilder<'a> { - turns: Vec<UiTurn>, - current_turn: Option<UiTurn>, + turns: Vec<UiTurnKind>, + current_turn: Option<UiTurnKind>, tracker: &'a ToolManager, + next_id: usize, } /// A struct to iteratively build [UiTurn] events from [ConversationEvent]s. @@ -223,6 +230,16 @@ impl<'a> TurnBuilder<'a> { turns: Vec::new(), current_turn: None, tracker, + next_id: 0, + } + } + + pub(crate) fn new_starting_at(tracker: &'a ToolManager, start_id: usize) -> Self { + Self { + turns: Vec::new(), + current_turn: None, + tracker, + next_id: start_id, } } @@ -280,7 +297,7 @@ impl<'a> TurnBuilder<'a> { // into a ToolGroup (e.g. N file reads → one group) // - All other events pass through unchanged for turn in &mut self.turns { - if let UiTurn::Agent { events } = turn { + if let UiTurnKind::Agent { events } = turn { let mut new_events: Vec<UiEvent> = Vec::new(); let mut pending_remote: Vec<ToolCallDetails> = Vec::new(); let mut pending_group: Option<(ToolGroupKind, Vec<ToolCallDetails>)> = None; @@ -322,7 +339,15 @@ impl<'a> TurnBuilder<'a> { } } - std::mem::take(&mut self.turns) + let kinds = std::mem::take(&mut self.turns); + kinds + .into_iter() + .enumerate() + .map(|(i, kind)| UiTurn { + id: self.next_id + i, + kind, + }) + .collect() } fn commit_turn(&mut self) { @@ -332,37 +357,39 @@ impl<'a> TurnBuilder<'a> { } fn start_user_turn(&mut self) { - if !matches!(self.current_turn, Some(UiTurn::User { .. })) { + if !matches!(self.current_turn, Some(UiTurnKind::User { .. })) { self.commit_turn(); - self.current_turn = Some(UiTurn::User { events: vec![] }); + self.current_turn = Some(UiTurnKind::User { events: vec![] }); } } fn start_agent_turn(&mut self) { - if !matches!(self.current_turn, Some(UiTurn::Agent { .. })) { + if !matches!(self.current_turn, Some(UiTurnKind::Agent { .. })) { self.commit_turn(); - self.current_turn = Some(UiTurn::Agent { events: vec![] }); + self.current_turn = Some(UiTurnKind::Agent { events: vec![] }); } } fn start_out_of_band_turn(&mut self) { - if !matches!(self.current_turn, Some(UiTurn::OutOfBand { .. })) { + if !matches!(self.current_turn, Some(UiTurnKind::OutOfBand { .. })) { self.commit_turn(); - self.current_turn = Some(UiTurn::OutOfBand { events: vec![] }); + self.current_turn = Some(UiTurnKind::OutOfBand { events: vec![] }); } } - fn turn_mut_unsafe(&mut self) -> &mut UiTurn { - self.current_turn.as_mut().unwrap() + fn current_events_mut(&mut self) -> &mut Vec<UiEvent> { + match self.current_turn.as_mut().unwrap() { + UiTurnKind::User { events } + | UiTurnKind::Agent { events } + | UiTurnKind::OutOfBand { events } => events, + } } fn add_user_message(&mut self, content: &str) { self.start_user_turn(); - if let UiTurn::User { events } = self.turn_mut_unsafe() { - events.push(UiEvent::Text { - content: content.to_string(), - }); - } + self.current_events_mut().push(UiEvent::Text { + content: content.to_string(), + }); } fn add_agent_text(&mut self, content: &str) { @@ -370,11 +397,9 @@ impl<'a> TurnBuilder<'a> { return; } self.start_agent_turn(); - if let UiTurn::Agent { events } = self.turn_mut_unsafe() { - events.push(UiEvent::Text { - content: content.to_string(), - }); - } + self.current_events_mut().push(UiEvent::Text { + content: content.to_string(), + }); } fn add_suggested_command(&mut self, input: &serde_json::Value) { @@ -389,7 +414,8 @@ impl<'a> TurnBuilder<'a> { } self.start_agent_turn(); - if let UiTurn::Agent { events } = self.turn_mut_unsafe() { + { + let events = self.current_events_mut(); let danger_level = input .get("danger") .and_then(|v| v.as_str()) @@ -433,14 +459,13 @@ impl<'a> TurnBuilder<'a> { let render_data = self.build_render_data(id, name); self.start_agent_turn(); - if let UiTurn::Agent { events } = self.turn_mut_unsafe() { - events.push(UiEvent::ToolCall(ToolCallDetails { + self.current_events_mut() + .push(UiEvent::ToolCall(ToolCallDetails { tool_use_id: id.to_string(), name: name.to_string(), status: ToolResultStatus::Pending, render_data, })); - } } /// Build tool-type-specific render data from the ToolTracker. @@ -482,31 +507,29 @@ impl<'a> TurnBuilder<'a> { fn add_tool_result(&mut self, tool_use_id: &str, _content: &str, is_error: bool) { self.start_agent_turn(); - if let UiTurn::Agent { events } = self.turn_mut_unsafe() { - let event = events.iter_mut().find(|e| match e { - UiEvent::ToolCall(ToolCallDetails { - tool_use_id: id, .. - }) => id == tool_use_id, - _ => false, - }); - if let Some(UiEvent::ToolCall(ToolCallDetails { status, .. })) = event { - *status = if is_error { - ToolResultStatus::Error - } else { - ToolResultStatus::Success - }; - } + let events = self.current_events_mut(); + let event = events.iter_mut().find(|e| match e { + UiEvent::ToolCall(ToolCallDetails { + tool_use_id: id, .. + }) => id == tool_use_id, + _ => false, + }); + if let Some(UiEvent::ToolCall(ToolCallDetails { status, .. })) = event { + *status = if is_error { + ToolResultStatus::Error + } else { + ToolResultStatus::Success + }; } } fn add_out_of_band_output(&mut self, _name: &str, command: Option<&str>, content: &str) { self.start_out_of_band_turn(); - if let UiTurn::OutOfBand { events } = self.turn_mut_unsafe() { - events.push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails { + self.current_events_mut() + .push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails { command: command.map(|c| c.to_string()), content: content.to_string(), })); - } } } |
