diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-21 13:07:27 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 13:07:27 -0700 |
| commit | 2f702ad446fcd6a261a3bea0ab2807d70eca43e2 (patch) | |
| tree | 4cfa6276257cefbe73f7fa46a74026170aaf8435 /crates/atuin-ai/src/tui | |
| parent | docs: document show_numeric_shortcuts (#3433) (diff) | |
| download | atuin-2f702ad446fcd6a261a3bea0ab2807d70eca43e2.zip | |
refactor: Replace ad-hoc dispatch with FSM + driver architecture (#3434)
Replaces the tangled dispatch handler system (`tui/dispatch.rs`,
`tui/state.rs`) with a pure finite state machine + driver architecture.
The FSM handles all state transitions as explicit `(State, Event) →
(NewState, Effects)` mappings. The driver executes IO effects and
bridges the TUI to the FSM.
Diffstat (limited to 'crates/atuin-ai/src/tui')
| -rw-r--r-- | crates/atuin-ai/src/tui/components/atuin_ai.rs | 7 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/input_box.rs | 12 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/select.rs | 7 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/dispatch.rs | 894 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/events.rs | 4 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 3 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 511 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 62 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 13 |
9 files changed, 74 insertions, 1439 deletions
diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs index c7227fbd..31dff1c3 100644 --- a/crates/atuin-ai/src/tui/components/atuin_ai.rs +++ b/crates/atuin-ai/src/tui/components/atuin_ai.rs @@ -5,11 +5,10 @@ //! Tab) are handled in the bubble phase so child components like the //! permission Select can consume them first. -use std::sync::mpsc; - use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use eye_declare::{Elements, EventResult, Hooks, component, props}; +use crate::commands::inline::DriverEventSender; use crate::tui::events::AiTuiEvent; use crate::tui::state::AppMode; @@ -28,7 +27,7 @@ pub(crate) struct AtuinAi { #[derive(Default)] pub(crate) struct AtuinAiState { - tx: Option<mpsc::Sender<AiTuiEvent>>, + tx: Option<DriverEventSender>, } #[component(props = AtuinAi, state = AtuinAiState, children = Elements)] @@ -38,7 +37,7 @@ fn atuin_ai( hooks: &mut Hooks<AtuinAi, AtuinAiState>, children: Elements, ) -> Elements { - hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| { + hooks.use_context::<DriverEventSender>(|tx, _, state| { state.tx = tx.cloned(); }); diff --git a/crates/atuin-ai/src/tui/components/input_box.rs b/crates/atuin-ai/src/tui/components/input_box.rs index 6e041418..6b81322c 100644 --- a/crates/atuin-ai/src/tui/components/input_box.rs +++ b/crates/atuin-ai/src/tui/components/input_box.rs @@ -6,7 +6,7 @@ //! //! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel. -use std::sync::{Arc, Mutex, mpsc}; +use std::sync::{Arc, Mutex}; use crossterm::event::KeyModifiers; use eye_declare::{Canvas, Elements, EventResult, Hooks, component, element, props}; @@ -19,6 +19,7 @@ use ratatui_core::{ }; use tui_textarea::TextArea; +use crate::commands::inline::DriverEventSender; use crate::tui::{events::AiTuiEvent, slash::SlashCommandSearchResult}; /// A bordered text input box backed by tui-textarea. @@ -41,7 +42,7 @@ pub(crate) struct InputBox { pub(crate) struct InputBoxState { textarea: Arc<Mutex<TextArea<'static>>>, - tx: Option<mpsc::Sender<AiTuiEvent>>, + tx: Option<DriverEventSender>, } impl Default for InputBoxState { @@ -97,10 +98,13 @@ fn input_box( state: &InputBoxState, hooks: &mut Hooks<InputBox, InputBoxState>, ) -> Elements { - hooks.use_focusable(props.active); + // Always focusable so focus isn't lost when the permission Select is + // removed from the tree. The `active` prop controls visual state and + // whether keystrokes are processed, not focusability. + hooks.use_focusable(true); hooks.use_autofocus(); - hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| { + hooks.use_context::<DriverEventSender>(|tx, _, state| { state.tx = tx.cloned(); }); diff --git a/crates/atuin-ai/src/tui/components/select.rs b/crates/atuin-ai/src/tui/components/select.rs index 5abbe655..771d7830 100644 --- a/crates/atuin-ai/src/tui/components/select.rs +++ b/crates/atuin-ai/src/tui/components/select.rs @@ -1,10 +1,9 @@ -use std::sync::mpsc; - use crossterm::event::KeyCode; use eye_declare::{Elements, EventResult, Hooks, Span, Text, View, component, element, props}; use ratatui::style::Style; use typed_builder::TypedBuilder; +use crate::commands::inline::DriverEventSender; use crate::tui::events::AiTuiEvent; type OnSelectFn = Box<dyn Fn(&SelectOption) -> Option<AiTuiEvent> + Send + Sync + 'static>; @@ -24,7 +23,7 @@ pub(crate) struct SelectOption { #[derive(Default)] pub(crate) struct PermissionSelectorState { selected_option: usize, - tx: Option<mpsc::Sender<AiTuiEvent>>, + tx: Option<DriverEventSender>, } #[props] @@ -42,7 +41,7 @@ pub(crate) fn permission_selector( hooks.use_focusable(true); hooks.use_autofocus(); - hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, _, state| { + hooks.use_context::<DriverEventSender>(|tx, _, state| { state.tx = tx.cloned(); }); diff --git a/crates/atuin-ai/src/tui/dispatch.rs b/crates/atuin-ai/src/tui/dispatch.rs deleted file mode 100644 index 46eebd9b..00000000 --- a/crates/atuin-ai/src/tui/dispatch.rs +++ /dev/null @@ -1,894 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc; - -use crate::context::{AppContext, ClientContext}; -use crate::context_window::ContextWindowBuilder; -use crate::permissions::check::PermissionResponse; -use crate::permissions::resolver::PermissionResolver; -use crate::permissions::rule::Rule; -use crate::permissions::writer::{self, RuleDisposition}; -use crate::session::SessionManager; -use crate::stream::{ChatRequest, run_chat_stream}; -use crate::tools::{ClientToolCall, ToolPhase}; -use crate::tui::events::{AiTuiEvent, PermissionResult}; -use crate::tui::state::{ConversationEvent, ExitAction, Session}; -use eye_declare::Handle; -use tokio::task::JoinHandle; - -/// Shared context for the dispatch loop. Bundles the references every -/// handler might need so `dispatch` doesn't forward a different subset -/// to each one. -pub(crate) struct DispatchContext<'a> { - pub handle: &'a Handle<Session>, - pub tx: &'a mpsc::Sender<AiTuiEvent>, - pub app_ctx: &'a AppContext, - pub client_ctx: &'a ClientContext, - pub session_mgr: &'a mut SessionManager, - /// Set by any handler that calls `h.exit()`. Read by `dispatch()` - /// to break the loop — without round-tripping through the handle, - /// which would hang if the TUI has already stopped. - pub exiting: Arc<AtomicBool>, -} - -/// Dispatch a single event. Returns `true` to keep the loop running, -/// `false` to shut down (after the final persist has completed). -pub(crate) fn dispatch(ctx: &mut DispatchContext, event: AiTuiEvent) -> bool { - match event { - AiTuiEvent::ContinueAfterTools => on_continue_after_tools(ctx), - AiTuiEvent::InputUpdated(input) => on_input_updated(ctx, input), - AiTuiEvent::SubmitInput(input) => on_submit_input(ctx, input), - AiTuiEvent::SlashCommand(cmd) => on_slash_command(ctx, cmd), - AiTuiEvent::CheckToolCallPermission(id) => on_check_tool_permission(ctx, id), - AiTuiEvent::SelectPermission(result) => on_select_permission(ctx, result), - AiTuiEvent::CancelGeneration => on_cancel_generation(ctx), - AiTuiEvent::ExecuteCommand => on_execute_command(ctx), - AiTuiEvent::CancelConfirmation => on_cancel_confirmation(ctx), - AiTuiEvent::InterruptToolExecution => on_interrupt_tool_execution(ctx), - AiTuiEvent::InsertCommand => on_insert_command(ctx), - AiTuiEvent::Retry => on_retry(ctx), - AiTuiEvent::Exit => on_exit(ctx), - } - - // Persist any new conversation events after each dispatch cycle. - persist_session(ctx); - - // The exiting flag is set by any handler that calls h.exit(). We - // read it here rather than querying state through the handle, - // because the TUI thread may have already stopped processing - // handle requests by this point. - !ctx.exiting.load(Ordering::Acquire) -} - -/// Persist new events, server session ID, file tracker, and edit permissions. -/// Called from the dispatch thread (sync), bridges to async via the tokio handle. -fn persist_session(ctx: &mut DispatchContext) { - let Ok((events, server_sid, file_tracker_json, edit_perms_json)) = ctx - .handle - .fetch(|state| { - ( - state.conversation.events.clone(), - state.conversation.session_id.clone(), - state.file_tracker.to_json().ok(), - state.edit_permissions.to_json().ok(), - ) - }) - .blocking_recv() - else { - return; - }; - - let rt = tokio::runtime::Handle::current(); - if let Err(e) = rt.block_on(ctx.session_mgr.persist_events(&events)) { - tracing::warn!("failed to persist session events: {e}"); - } - if let Some(ref sid) = server_sid - && let Err(e) = rt.block_on(ctx.session_mgr.persist_server_session_id(sid)) - { - tracing::warn!("failed to persist server session ID: {e}"); - } - if let Some(ref json) = file_tracker_json - && let Err(e) = rt.block_on( - ctx.session_mgr - .set_metadata(crate::file_tracker::METADATA_KEY, json), - ) - { - tracing::warn!("failed to persist file tracker: {e}"); - } - if let Some(ref json) = edit_perms_json - && let Err(e) = rt.block_on( - ctx.session_mgr - .set_metadata(crate::edit_permissions::METADATA_KEY, json), - ) - { - tracing::warn!("failed to persist edit permissions: {e}"); - } -} - -fn launch_stream(ctx: &DispatchContext, setup: impl FnOnce(&mut Session) + Send + 'static) { - let h2 = ctx.handle.clone(); - let tx2 = ctx.tx.clone(); - let app = ctx.app_ctx.clone(); - let cc = ctx.client_ctx.clone(); - let caps = ctx.app_ctx.capabilities.clone(); - ctx.handle.update(move |state| { - (setup)(state); - state.start_streaming(); - let messages = - ContextWindowBuilder::with_default_budget().build(&state.conversation.events); - let sid = state.conversation.session_id.clone(); - let request = ChatRequest::new(messages, sid, &caps, state.invocation_id.clone()); - let task: JoinHandle<()> = tokio::spawn(async move { - run_chat_stream(h2, tx2, app, cc, request).await; - }); - state.stream_abort = Some(task.abort_handle()); - }); -} - -fn on_continue_after_tools(ctx: &mut DispatchContext) { - launch_stream(ctx, |_state| {}); -} - -fn on_input_updated(ctx: &mut DispatchContext, input: String) { - let input_blank = input.is_empty(); - let slash_command = if input.starts_with('/') { - Some(input.trim_start_matches('/').to_string()) - } else { - None - }; - - ctx.handle.update(move |state| { - state.interaction.is_input_blank = input_blank; - state.interaction.slash_command_input = slash_command; - - if let Some(query) = state.interaction.slash_command_input.as_ref() { - let mut results = state.slash_registry.search_fuzzy(query); - - results.sort_by(|a, b| { - b.relevance - .partial_cmp(&a.relevance) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - state.interaction.slash_command_search_results = results; - } else { - state.interaction.slash_command_search_results.clear(); - } - }); -} - -fn on_submit_input(ctx: &mut DispatchContext, input: String) { - ctx.handle.update(move |state| { - state.interaction.slash_command_input = None; - state.interaction.slash_command_search_results.clear(); - }); - - let input = input.trim().to_string(); - if input.is_empty() { - let h2 = ctx.handle.clone(); - let exiting = ctx.exiting.clone(); - ctx.handle.update(move |state| { - if state.conversation.has_any_command() { - state.exit_action = Some(ExitAction::Execute( - state.conversation.current_command().unwrap().to_string(), - )); - } else { - state.exit_action = Some(ExitAction::Cancel); - } - exiting.store(true, Ordering::Release); - h2.exit(); - }); - return; - } - - if input.starts_with('/') { - if input.trim() == "/new" { - on_new_session(ctx); - } else { - ctx.handle.update(move |state| { - state - .conversation - .handle_slash_command(&input, &state.slash_registry); - }); - } - return; - } - - // Start generation and spawn streaming task - launch_stream(ctx, |state| { - state.start_generating(input); - state.interaction.is_input_blank = true; - }); -} - -fn on_slash_command(ctx: &mut DispatchContext, command: String) { - ctx.handle.update(move |state| { - state - .conversation - .handle_slash_command(&command, &state.slash_registry); - }); -} - -// ─────────────────────────────────────────────────────────────────── -// Tool execution dispatch -// ─────────────────────────────────────────────────────────────────── - -/// Execute a tool call. Handles Shell tools (streaming with preview) and -/// non-shell tools (synchronous) uniformly. -fn execute_tool( - handle: &Handle<Session>, - tx: &mpsc::Sender<AiTuiEvent>, - tool_id: String, - tool: ClientToolCall, - db: &std::sync::Arc<atuin_client::database::Sqlite>, -) { - match &tool { - ClientToolCall::Shell(shell_call) => { - let shell_call = shell_call.clone(); - execute_shell_tool(handle, tx, &tool_id, &shell_call); - } - ClientToolCall::Edit(edit_call) => { - let edit_call = edit_call.clone(); - execute_edit_tool(handle, tx, tool_id, edit_call); - } - ClientToolCall::Write(write_call) => { - let write_call = write_call.clone(); - execute_write_tool(handle, tx, tool_id, write_call); - } - _ => { - execute_simple_tool(handle, tx, tool_id, tool, db); - } - } -} - -/// Execute a non-shell tool and finish the tool call. -/// The ToolCall event is already in the conversation (added by handle_client_tool_call). -fn execute_simple_tool( - handle: &Handle<Session>, - tx: &mpsc::Sender<AiTuiEvent>, - tool_id: String, - tool: ClientToolCall, - db: &std::sync::Arc<atuin_client::database::Sqlite>, -) { - let h = handle.clone(); - let tx = tx.clone(); - let db = db.clone(); - - tokio::spawn(async move { - let outcome = tool.execute(&db).await; - - // After a successful file read, capture tracking data for freshness - // checking. This re-stats the file to get content hash and mtime. - let read_tracking = if let ClientToolCall::Read(ref read_tool) = tool - && !outcome.is_error() - { - capture_read_tracking(&read_tool.path) - } else { - None - }; - - h.update(move |state| { - if let Some((path, content, mtime)) = read_tracking { - state.file_tracker.record_read(path, &content, mtime); - } - state.finish_tool_call(&tool_id, outcome); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - }); -} - -/// Capture file content and mtime for the read tracker. -/// Returns None for directories or if the file can't be read. -fn capture_read_tracking( - path: &std::path::Path, -) -> Option<(std::path::PathBuf, Vec<u8>, std::time::SystemTime)> { - let resolved = if path.is_relative() { - std::env::current_dir().ok()?.join(path) - } else { - path.to_path_buf() - }; - if !resolved.is_file() { - return None; - } - let content = std::fs::read(&resolved).ok()?; - let mtime = std::fs::metadata(&resolved).ok()?.modified().ok()?; - Some((resolved, content, mtime)) -} - -/// Execute an edit_file tool call. -/// -/// Orchestrates snapshot → execute → tracker update. The snapshot and -/// tracker mutations happen via `h.update()` (on the TUI thread) since -/// they need mutable Session state. The actual file I/O (freshness check, -/// read, match, atomic write) runs in the tokio task. -fn execute_edit_tool( - handle: &Handle<Session>, - tx: &mpsc::Sender<AiTuiEvent>, - tool_id: String, - edit_call: crate::tools::EditToolCall, -) { - let h = handle.clone(); - let tx = tx.clone(); - - tokio::spawn(async move { - let resolved = edit_call.resolved_path(); - - // 1. Read the original file content (used for snapshot + diff). - let old_content = std::fs::read(&resolved).ok(); - - // 2. Snapshot the original file before editing. - if let Some(ref content) = old_content { - let snap_path = resolved.clone(); - let snap_content = content.clone(); - h.update(move |state| { - if let Some(ref mut store) = state.snapshot_store - && let Err(e) = store.ensure_snapshot(&snap_path, &snap_content) - { - tracing::warn!("failed to create file snapshot: {e}"); - } - }); - } - - // 3. Fetch a clone of the file tracker for freshness checking. - let Ok(tracker) = h.fetch(|state| state.file_tracker.clone()).await else { - let tc_id = tool_id.clone(); - h.update(move |state| { - state.finish_tool_call( - &tc_id, - crate::tools::ToolOutcome::Error("Internal error: TUI unavailable".into()), - ); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - return; - }; - - // 4. Execute: freshness check → read → match → atomic write - let (outcome, new_bytes) = edit_call.execute(&resolved, &tracker); - - // 5. Compute diff preview on success - let edit_preview = if let Some(ref new_bytes) = new_bytes { - if let Some(ref old_bytes) = old_content { - let old_str = String::from_utf8_lossy(old_bytes); - let new_str = String::from_utf8_lossy(new_bytes); - let preview = crate::diff::EditPreview::compute(&old_str, &new_str); - if preview.hunks.is_empty() { - None - } else { - Some(preview) - } - } else { - None - } - } else { - None - }; - - // 6. Update tracker, store diff preview, and finish the tool call - let tc_id = tool_id; - h.update(move |state| { - if let Some(ref new_bytes) = new_bytes - && let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified()) - { - state - .file_tracker - .update_after_edit(&resolved, new_bytes, mtime); - } - if let Some(preview) = edit_preview - && let Some(tracked) = state.tool_tracker.get_mut(&tc_id) - { - tracked.edit_preview = Some(preview); - } - state.finish_tool_call(&tc_id, outcome); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - }); -} - -/// Execute a write_file tool call. -/// -/// Snapshots the existing file (if any) before overwriting, writes atomically, -/// stores a content preview on the tracker, and updates the file tracker. -fn execute_write_tool( - handle: &Handle<Session>, - tx: &mpsc::Sender<AiTuiEvent>, - tool_id: String, - write_call: crate::tools::WriteToolCall, -) { - let h = handle.clone(); - let tx = tx.clone(); - - tokio::spawn(async move { - let resolved = write_call.resolved_path(); - - // 1. Snapshot the existing file before overwriting (if it exists). - if resolved.exists() - && let Ok(original_content) = std::fs::read(&resolved) - { - let snap_path = resolved.clone(); - h.update(move |state| { - if let Some(ref mut store) = state.snapshot_store - && let Err(e) = store.ensure_snapshot(&snap_path, &original_content) - { - tracing::warn!("failed to create file snapshot: {e}"); - } - }); - } - - // 2. Execute: check exists/overwrite, atomic write - let (outcome, new_bytes) = write_call.execute(&resolved); - - // 3. Build content preview on success - let write_preview = if new_bytes.is_some() { - Some(crate::diff::WritePreview::from_content(&write_call.content)) - } else { - None - }; - - // 4. Update tracker, store preview, and finish - let tc_id = tool_id; - h.update(move |state| { - if let Some(ref new_bytes) = new_bytes - && let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified()) - { - state - .file_tracker - .update_after_edit(&resolved, new_bytes, mtime); - } - if let Some(preview) = write_preview - && let Some(tracked) = state.tool_tracker.get_mut(&tc_id) - { - tracked.write_preview = Some(preview); - } - state.finish_tool_call(&tc_id, outcome); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - }); -} - -/// Execute a shell tool with streaming VT100 preview. -fn execute_shell_tool( - handle: &Handle<Session>, - tx: &mpsc::Sender<AiTuiEvent>, - tool_id: &str, - shell_call: &crate::tools::ShellToolCall, -) { - let h = handle.clone(); - let tx = tx.clone(); - let shell_call = shell_call.clone(); - let command = shell_call.command.clone(); - let tc_id = tool_id.to_string(); - - // 1. Set up channels for streaming output and interruption - let (output_tx, mut output_rx) = tokio::sync::mpsc::channel::<Vec<String>>(32); - let (abort_tx, abort_rx) = tokio::sync::oneshot::channel::<()>(); - - // 2. Mark as executing with preview and store the abort sender on the tracker entry - let tc_id_setup = tc_id.clone(); - h.update(move |state| { - if let Some(tracked) = state.tool_tracker.get_mut(&tc_id_setup) { - tracked.mark_executing_preview(command); - tracked.abort_tx = Some(abort_tx); - } - }); - - // 3. Spawn a task to consume output updates and feed them to state - let h_output = h.clone(); - let preview_id = tc_id.clone(); - let output_task = tokio::spawn(async move { - while let Some(lines) = output_rx.recv().await { - let id = preview_id.clone(); - h_output.update(move |state| { - if let Some(tracked) = state.tool_tracker.get_mut(&id) - && let ToolPhase::ExecutingWithPreview { - ref mut output_lines, - .. - } = tracked.phase - { - *output_lines = lines; - } - }); - } - }); - - // 4. Spawn the streaming execution task - let tc_id_finish = tc_id; - tokio::spawn(async move { - let outcome = - crate::tools::execute_shell_command_streaming(&shell_call, output_tx, abort_rx).await; - - // Wait for the output task to finish so the final preview lines are captured - let _ = output_task.await; - - h.update(move |state| { - state.finish_tool_call(&tc_id_finish, outcome); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - }); -} - -// ─────────────────────────────────────────────────────────────────── -// Permission handlers -// ─────────────────────────────────────────────────────────────────── - -fn on_check_tool_permission(ctx: &mut DispatchContext, id: String) { - let h2 = ctx.handle.clone(); - let tx_for_task = ctx.tx.clone(); - let db = ctx.app_ctx.history_db.clone(); - - tokio::spawn(async move { - let id_for_error = id.clone(); - let result = check_tool_permission_inner(&h2, &tx_for_task, &db, id).await; - - // If the inner function didn't handle the tool (returned an error message), - // finish the tool call with that error so the conversation doesn't stall. - if let Err(error_msg) = result { - let tx = tx_for_task.clone(); - h2.update(move |state| { - state.finish_tool_call(&id_for_error, crate::tools::ToolOutcome::Error(error_msg)); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - } - }); -} - -/// Inner permission check that returns Err(message) if the tool call should be -/// finished with an error. Returns Ok(()) if the tool was handled (executed, -/// denied, or sent to the permission UI). -async fn check_tool_permission_inner( - h2: &Handle<Session>, - tx: &mpsc::Sender<AiTuiEvent>, - db: &std::sync::Arc<atuin_client::database::Sqlite>, - id: String, -) -> Result<(), String> { - // 1. Fetch the tracked tool's data - let id_for_fetch = id.clone(); - let (tool, target_dir) = h2 - .fetch(move |state| { - state - .tool_tracker - .get(&id_for_fetch) - .map(|t| (t.tool.clone(), t.target_dir().map(PathBuf::from))) - }) - .await - .map_err(|e| format!("Internal error fetching tool state: {e}"))? - .ok_or_else(|| "Internal error: tool not found in tracker".to_string())?; - - // 2. For edit tools, check session-scoped permission grants before - // hitting the filesystem-based resolver. A valid grant means the user - // already approved this file recently. - if let ClientToolCall::Edit(ref edit) = tool { - let resolved = edit.resolved_path(); - let has_grant = h2 - .fetch(move |state| state.edit_permissions.has_valid_grant(&resolved)) - .await - .unwrap_or(false); - - if has_grant { - execute_tool(h2, tx, id, tool, db); - return Ok(()); - } - } - - // 3. Resolve working directory - let working_dir = target_dir - .or_else(|| std::env::current_dir().ok()) - .ok_or_else(|| "Could not determine working directory".to_string())?; - - // 4. Create permission resolver and check - let resolver = PermissionResolver::new(working_dir) - .await - .map_err(|e| format!("Permission check failed: {e}"))?; - - let response = resolver - .check(&tool) - .await - .map_err(|e| format!("Permission check failed: {e}"))?; - - // 5. Handle response — all paths here handle the tool, so return Ok - let id_clone = id.clone(); - match response { - PermissionResponse::Allowed => { - execute_tool(h2, tx, id, tool, db); - } - PermissionResponse::Denied => { - let tx = tx.clone(); - h2.update(move |state| { - state.finish_tool_call( - &id_clone, - crate::tools::ToolOutcome::Error( - "Permission denied on the user's system".to_string(), - ), - ); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - } - PermissionResponse::Ask => { - h2.update(move |state| { - if let Some(tracked) = state.tool_tracker.get_mut(&id_clone) { - tracked.mark_asking(); - } - }); - } - } - - Ok(()) -} - -fn on_select_permission(ctx: &mut DispatchContext, permission: PermissionResult) { - let tx = ctx.tx.clone(); - let h2 = ctx.handle.clone(); - - match permission { - PermissionResult::Allow => { - // Fetch the tool that's asking for permission, then execute it - let db = ctx.app_ctx.history_db.clone(); - tokio::spawn(async move { - let Ok(Some((tool_id, tool))) = h2 - .fetch(move |state| { - state - .tool_tracker - .asking_for_permission() - .map(|t| (t.id.clone(), t.tool.clone())) - }) - .await - else { - return; - }; - - execute_tool(&h2, &tx, tool_id, tool, &db); - }); - } - PermissionResult::AllowFileForSession => { - // Cache a session-scoped, time-limited grant for this file - let db = ctx.app_ctx.history_db.clone(); - tokio::spawn(async move { - let Ok(Some((tool_id, tool))) = h2 - .fetch(move |state| { - state - .tool_tracker - .asking_for_permission() - .map(|t| (t.id.clone(), t.tool.clone())) - }) - .await - else { - return; - }; - - if let ClientToolCall::Edit(ref edit) = tool { - let resolved = edit.resolved_path(); - h2.update(move |state| { - state.edit_permissions.grant(resolved); - }); - } - - execute_tool(&h2, &tx, tool_id, tool, &db); - }); - } - PermissionResult::AlwaysAllowInDir => { - let db = ctx.app_ctx.history_db.clone(); - let git_root = ctx.app_ctx.git_root.clone(); - tokio::spawn(async move { - let Ok(Some((tool_id, tool))) = h2 - .fetch(move |state| { - state - .tool_tracker - .asking_for_permission() - .map(|t| (t.id.clone(), t.tool.clone())) - }) - .await - else { - return; - }; - - // Write the rule to the project (git root) or cwd permissions file - let project_root = git_root - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")); - let file_path = writer::project_permissions_path(&project_root); - let rule = Rule { - tool: tool.rule_name().to_string(), - scope: None, - }; - if let Err(e) = writer::write_rule(&file_path, &rule, RuleDisposition::Allow).await - { - tracing::error!("Failed to write project permission rule: {e}"); - } - - execute_tool(&h2, &tx, tool_id, tool, &db); - }); - } - PermissionResult::AlwaysAllow => { - let db = ctx.app_ctx.history_db.clone(); - tokio::spawn(async move { - let Ok(Some((tool_id, tool))) = h2 - .fetch(move |state| { - state - .tool_tracker - .asking_for_permission() - .map(|t| (t.id.clone(), t.tool.clone())) - }) - .await - else { - return; - }; - - // Write the rule to the global permissions file - let file_path = writer::global_permissions_path(); - let rule = Rule { - tool: tool.rule_name().to_string(), - scope: None, - }; - if let Err(e) = writer::write_rule(&file_path, &rule, RuleDisposition::Allow).await - { - tracing::error!("Failed to write global permission rule: {e}"); - } - - execute_tool(&h2, &tx, tool_id, tool, &db); - }); - } - PermissionResult::Deny => { - h2.update(move |state| { - let Some(tracked) = state.tool_tracker.asking_for_permission() else { - return; - }; - let tool_id = tracked.id.clone(); - - state.finish_tool_call( - &tool_id, - crate::tools::ToolOutcome::Error("Permission denied by the user".to_string()), - ); - if !state.tool_tracker.has_pending() { - let _ = tx.send(AiTuiEvent::ContinueAfterTools); - } - }); - } - } -} - -// ─────────────────────────────────────────────────────────────────── -// Other handlers -// ─────────────────────────────────────────────────────────────────── - -fn on_cancel_generation(ctx: &mut DispatchContext) { - ctx.handle.update(|state| match state.interaction.mode { - crate::tui::state::AppMode::Generating => { - state.cancel_generation(); - } - crate::tui::state::AppMode::Streaming => { - state.cancel_streaming(); - } - _ => {} - }); -} - -fn on_execute_command(ctx: &mut DispatchContext) { - let h2 = ctx.handle.clone(); - let exiting = ctx.exiting.clone(); - ctx.handle.update(move |state| { - let cmd = state.conversation.current_command().map(|c| c.to_string()); - if let Some(cmd) = cmd { - if state.conversation.is_current_command_dangerous() - && !state.interaction.confirmation_pending - { - state.interaction.confirmation_pending = true; - } else { - state.interaction.confirmation_pending = false; - state.exit_action = Some(ExitAction::Execute(cmd)); - exiting.store(true, Ordering::Release); - h2.exit(); - } - } - }); -} - -fn on_cancel_confirmation(ctx: &mut DispatchContext) { - ctx.handle.update(move |state| { - state.interaction.confirmation_pending = false; - }); -} - -fn on_insert_command(ctx: &mut DispatchContext) { - let h2 = ctx.handle.clone(); - let exiting = ctx.exiting.clone(); - ctx.handle.update(move |state| { - let cmd = state.conversation.current_command().map(|c| c.to_string()); - if let Some(cmd) = cmd { - state.interaction.confirmation_pending = false; - state.exit_action = Some(ExitAction::Insert(cmd)); - exiting.store(true, Ordering::Release); - h2.exit(); - } - }); -} - -fn on_retry(ctx: &mut DispatchContext) { - launch_stream(ctx, |state| { - state.retry(); - }); -} - -fn on_new_session(ctx: &mut DispatchContext) { - let rt = tokio::runtime::Handle::current(); - - if let Err(e) = rt.block_on(ctx.session_mgr.archive_and_reset()) { - tracing::warn!("failed to start new session: {e}"); - return; - } - - ctx.handle.update(|state| { - // Move the current invocation's visible events to the archived view - // so they remain on screen but are no longer sent to the API. - let visible_events: Vec<ConversationEvent> = - state.conversation.events[state.view_start_index..].to_vec(); - state.archived_view_events.extend(visible_events); - - state.conversation.events.clear(); - state.conversation.session_id = None; - state.tool_tracker = crate::tools::ToolTracker::new(); - state.view_start_index = 0; - state.is_resumed = false; - state.last_event_time = None; - state - .conversation - .events - .push(ConversationEvent::OutOfBandOutput { - name: "System".to_string(), - command: Some("/new".to_string()), - content: "Started a new session.".to_string(), - }); - }); -} - -fn on_exit(ctx: &mut DispatchContext) { - let h2 = ctx.handle.clone(); - let exiting = ctx.exiting.clone(); - ctx.handle.update(move |state| { - if let Some(abort) = state.stream_abort.take() { - abort.abort(); - } - state.exit_action = Some(ExitAction::Cancel); - exiting.store(true, Ordering::Release); - h2.exit(); - }); -} - -fn on_interrupt_tool_execution(ctx: &mut DispatchContext) { - ctx.handle.update(move |state| { - // Find executing previews, send interrupt, and mark as interrupted - for tracked in state.tool_tracker.iter_mut() { - if let ToolPhase::ExecutingWithPreview { - ref mut interrupted, - ref mut exit_code, - .. - } = tracked.phase - { - *interrupted = true; - if exit_code.is_none() { - *exit_code = Some(-1); - } - // Send interrupt signal via the tracker entry's abort channel - if let Some(abort_tx) = tracked.abort_tx.take() { - let _ = abort_tx.send(()); - } - } - } - - // The spawned execution task will handle finalizing and sending - // ContinueAfterTools when the process exits. Input mode is already active. - }); -} diff --git a/crates/atuin-ai/src/tui/events.rs b/crates/atuin-ai/src/tui/events.rs index 969f6ae5..abcb1bd9 100644 --- a/crates/atuin-ai/src/tui/events.rs +++ b/crates/atuin-ai/src/tui/events.rs @@ -13,12 +13,8 @@ pub(crate) enum AiTuiEvent { /// User entered a slash command (e.g. "/help") #[allow(unused)] SlashCommand(String), - /// Check the permission for a tool call - CheckToolCallPermission(String), /// User selected a permission SelectPermission(PermissionResult), - /// Continue after client tools have completed - ContinueAfterTools, /// Cancel active generation or streaming (Esc during Generating/Streaming) CancelGeneration, /// Execute the suggested command diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs index 05a040a1..9727f362 100644 --- a/crates/atuin-ai/src/tui/mod.rs +++ b/crates/atuin-ai/src/tui/mod.rs @@ -1,8 +1,7 @@ pub(crate) mod components; -pub(crate) mod dispatch; pub(crate) mod events; pub(crate) mod slash; pub(crate) mod state; pub(crate) mod view; -pub(crate) use state::{ConversationEvent, Session, events_to_messages}; +pub(crate) use state::{ConversationEvent, events_to_messages}; diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs index af1ebffe..e008bd3c 100644 --- a/crates/atuin-ai/src/tui/state.rs +++ b/crates/atuin-ai/src/tui/state.rs @@ -1,36 +1,10 @@ -//! Domain state types for the TUI application +//! Core state types for the conversation protocol. //! -//! This module contains the core state types that represent the application's -//! domain model. Conversation events match the API protocol format. +//! ConversationEvent and events_to_messages are the canonical representations +//! used by both the FSM and the context window builder. AppMode is used by +//! the view layer for component prop derivation. -use tokio::task::AbortHandle; - -use crate::{ - tools::{ClientToolCall, ToolOutcome, ToolTracker}, - tui::slash::{SlashCommandRegistry, SlashCommandSearchResult}, -}; - -/// Streaming status indicators from server -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum StreamingStatus { - Processing, - Searching, - Thinking, - WaitingForTools, -} - -impl StreamingStatus { - pub(crate) fn from_status_str(s: &str) -> Self { - match s { - "processing" => Self::Processing, - "searching" => Self::Searching, - "waiting_for_tools" => Self::WaitingForTools, - _ => Self::Thinking, - } - } -} - -/// Conversation event types matching the API protocol +/// Conversation event types matching the API protocol. #[derive(Debug, Clone)] pub(crate) enum ConversationEvent { /// User message (what the user typed) @@ -54,7 +28,7 @@ pub(crate) enum ConversationEvent { /// Approximate content length for token estimation of remote results. content_length: Option<usize>, }, - /// Out-of-band output from the system - not sent to the server + /// Out-of-band output from the system — not sent to the server OutOfBandOutput { name: String, command: Option<String>, @@ -67,7 +41,6 @@ pub(crate) enum ConversationEvent { impl ConversationEvent { /// Whether this event represents actual conversation content sent to the API. - /// Used to determine if a resumed session has meaningful context. pub(crate) fn is_api_content(&self) -> bool { match self { ConversationEvent::UserMessage { .. } => true, @@ -79,7 +52,7 @@ impl ConversationEvent { } } - /// Extract command from a suggest_command tool call + /// Extract command from a suggest_command tool call. pub(crate) fn as_command(&self) -> Option<&str> { if let ConversationEvent::ToolCall { name, input, .. } = self && name == "suggest_command" @@ -90,7 +63,9 @@ impl ConversationEvent { } } -/// Application mode for key handling and footer text. +/// Application mode for key handling and component props. +/// +/// Derived from AgentState in the view layer via `From<&AgentState>`. #[derive(Debug, Clone, PartialEq, Eq, Copy)] pub(crate) enum AppMode { /// User is typing input @@ -103,167 +78,6 @@ pub(crate) enum AppMode { Error, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ExitAction { - /// Run the command - Execute(String), - /// Insert command without running - Insert(String), - /// User canceled - Cancel, -} - -/// Owned event log and session ID -#[derive(Debug)] -pub(crate) struct Conversation { - /// Conversation events (source of truth, matches API protocol) - pub events: Vec<ConversationEvent>, - /// Session ID from server - pub session_id: Option<String>, -} - -impl Conversation { - pub fn new() -> Self { - Self { - events: Vec::new(), - session_id: None, - } - } - - /// Get the most recent command from events - pub fn current_command(&self) -> Option<&str> { - self.events.iter().rev().find_map(|e| e.as_command()) - } - - /// Check if any turn in the conversation has a command - pub fn has_any_command(&self) -> bool { - self.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 - } - }) - } - - /// Check if the most recent command is marked dangerous - pub fn is_current_command_dangerous(&self) -> bool { - self.events - .iter() - .rev() - .find_map(|e| { - if let ConversationEvent::ToolCall { name, input, .. } = e - && name == "suggest_command" - { - let danger_level = input - .get("danger") - .and_then(|v| v.as_str()) - .unwrap_or("low"); - return Some( - danger_level == "high" || danger_level == "medium" || danger_level == "med", - ); - } - None - }) - .unwrap_or(false) - } - - /// Get a mutable reference to the last Text event's content (the streaming buffer). - fn streaming_content_mut(&mut self) -> Option<&mut String> { - self.events.iter_mut().rev().find_map(|e| { - if let ConversationEvent::Text { content } = e { - Some(content) - } else { - None - } - }) - } - - /// Remove trailing empty Text events from the events list - fn remove_empty_trailing_text(&mut self) { - while let Some(ConversationEvent::Text { content }) = self.events.last() { - if content.is_empty() { - self.events.pop(); - } else { - break; - } - } - } - - /// Append text chunk during streaming (mutates the last Text event in-place) - pub fn append_streaming_text(&mut self, chunk: &str) { - // If the last event isn't a Text, we need a fresh buffer - // (e.g. after a tool call removed the empty streaming buffer) - if !matches!(self.events.last(), Some(ConversationEvent::Text { .. })) { - self.events.push(ConversationEvent::Text { - content: String::new(), - }); - } - - if let Some(content) = self.streaming_content_mut() { - if content.is_empty() { - // First chunk(s): trim leading whitespace - let trimmed = chunk.trim_start(); - if !trimmed.is_empty() { - content.push_str(trimmed); - } - } else { - content.push_str(chunk); - } - } - } - - /// Add a tool result event during streaming - pub fn add_tool_result( - &mut self, - tool_use_id: String, - content: String, - is_error: bool, - remote: bool, - content_length: Option<usize>, - ) { - self.events.push(ConversationEvent::ToolResult { - tool_use_id, - content, - is_error, - remote, - content_length, - }); - } - - /// Store session ID from server response - pub fn store_session_id(&mut self, session_id: String) { - self.session_id = Some(session_id); - } - - /// Handle a slash command - pub fn handle_slash_command(&mut self, command: &str, registry: &SlashCommandRegistry) { - match command.trim() { - "/help" => { - let commands = registry - .get_commands() - .iter() - .map(|cmd| format!("- `/{}` - {}", cmd.name, cmd.description)) - .collect::<Vec<_>>() - .join("\n"); - - let content = include_str!("./content/help.md").replace("{commands}", &commands); - - self.events.push(ConversationEvent::OutOfBandOutput { - name: "System".to_string(), - command: Some("/help".to_string()), - content, - }); - } - _ => self.events.push(ConversationEvent::OutOfBandOutput { - name: "System".to_string(), - command: None, - content: (format!("Unknown command: {command}")), - }), - } - } -} - /// Convert a slice of conversation events to Claude API message format. /// /// This is the canonical event-to-message conversion, used by the context window @@ -284,13 +98,9 @@ pub(crate) fn events_to_messages(events: &[ConversationEvent]) -> Vec<serde_json i += 1; } ConversationEvent::Text { content } if content.is_empty() => { - // Skip empty text events (e.g. streaming buffer before - // any data arrived). i += 1; } ConversationEvent::Text { content } => { - // Check if the next event(s) are ToolCalls — if so, combine - // into a single assistant message with mixed content blocks. let next_is_tool_call = events .get(i + 1) .is_some_and(|e| matches!(e, ConversationEvent::ToolCall { .. })); @@ -332,8 +142,6 @@ pub(crate) fn events_to_messages(events: &[ConversationEvent]) -> Vec<serde_json } } ConversationEvent::ToolCall { .. } => { - // ToolCalls without preceding Text (shouldn't normally happen, - // but handle defensively) let mut tool_uses = Vec::new(); while i < events.len() { if let ConversationEvent::ToolCall { @@ -389,7 +197,6 @@ pub(crate) fn events_to_messages(events: &[ConversationEvent]) -> Vec<serde_json i += 1; } ConversationEvent::OutOfBandOutput { .. } => { - // Out-of-band output is not sent to the server i += 1; } ConversationEvent::SystemContext { content } => { @@ -404,301 +211,3 @@ pub(crate) fn events_to_messages(events: &[ConversationEvent]) -> Vec<serde_json messages } - -/// Ephemeral UI/presentation state -#[derive(Debug)] -pub(crate) struct Interaction { - /// Current application mode - pub mode: AppMode, - /// Whether the input is blank - pub is_input_blank: bool, - /// The currently in-progress slash command (if any) - pub slash_command_input: Option<String>, - /// Search results for the current slash command input - pub slash_command_search_results: Vec<SlashCommandSearchResult>, - /// True when user has pressed Enter once on a dangerous command - pub confirmation_pending: bool, - /// Current streaming status - pub streaming_status: Option<StreamingStatus>, - /// Whether current turn was interrupted by user - pub was_interrupted: bool, - /// Current error message - pub error: Option<String>, -} - -impl Interaction { - pub fn new() -> Self { - Self { - mode: AppMode::Input, - is_input_blank: false, - slash_command_input: None, - slash_command_search_results: Vec::new(), - confirmation_pending: false, - streaming_status: None, - was_interrupted: false, - error: None, - } - } -} - -/// Top-level session state -/// -/// Decomposed into `Conversation` (event log + session ID) and -/// `Interaction` (ephemeral UI state). Session methods that cross -/// both sub-structs live here. -#[derive(Debug)] -pub(crate) struct Session { - pub conversation: Conversation, - pub interaction: Interaction, - /// Tracks all tool calls through their full lifecycle. - pub tool_tracker: ToolTracker, - /// Whether the session is running inside a git project (for permission UI labels). - pub in_git_project: bool, - /// Exit action (set when exiting) - pub exit_action: Option<ExitAction>, - /// Abort handle for the active streaming task, if any - pub stream_abort: Option<AbortHandle>, - /// Index into `conversation.events` where the current TUI invocation starts. - /// Events before this index are historical context sent to the API but not - /// rendered in the TUI. - pub view_start_index: usize, - /// Whether this session was resumed from a prior invocation. - pub is_resumed: bool, - /// Time of the last event from a previous invocation when resuming a session - pub last_event_time: Option<chrono::DateTime<chrono::Utc>>, - /// Events from archived sessions that are still rendered on screen but no - /// longer sent to the API. Accumulated by `/new` commands within a single - /// TUI lifetime. - pub archived_view_events: Vec<ConversationEvent>, - /// A registry of available slash commands - pub slash_registry: SlashCommandRegistry, - /// The unique ID for this invocation - pub invocation_id: String, - /// Tracks which files have been read, for freshness checking before edits. - pub file_tracker: crate::file_tracker::FileReadTracker, - /// Session-scoped edit permission grants (per-file, time-limited). - pub edit_permissions: crate::edit_permissions::EditPermissionCache, - /// Backs up files before the first edit in a session. - pub snapshot_store: Option<crate::snapshots::SnapshotStore>, -} - -impl Session { - pub fn new(in_git_project: bool, invocation_id: Option<String>) -> Self { - Self { - conversation: Conversation::new(), - interaction: Interaction::new(), - tool_tracker: ToolTracker::new(), - in_git_project, - exit_action: None, - stream_abort: None, - view_start_index: 0, - is_resumed: false, - last_event_time: None, - archived_view_events: Vec::new(), - slash_registry: Default::default(), - invocation_id: invocation_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string()), - file_tracker: Default::default(), - edit_permissions: Default::default(), - snapshot_store: None, - } - } - - // ===== Generation lifecycle methods ===== - - /// Start generating from submitted input - pub fn start_generating(&mut self, input: String) { - self.conversation - .events - .push(ConversationEvent::UserMessage { content: input }); - self.interaction.mode = AppMode::Generating; - } - - /// Generation error occurred - #[expect(dead_code)] - pub fn generation_error(&mut self, error: String) { - self.interaction.error = Some(error); - self.interaction.mode = AppMode::Error; - } - - /// Cancel during generation - pub fn cancel_generation(&mut self) { - if let Some(abort) = self.stream_abort.take() { - abort.abort(); - } - if let Some(ConversationEvent::UserMessage { .. }) = self.conversation.events.last() { - self.conversation.events.pop(); - } - self.interaction.mode = AppMode::Input; - } - - // ===== Streaming lifecycle methods ===== - - /// Start streaming response. - /// Pushes an empty Text event so the UI immediately creates an agent - /// turn (which renders the spinner). The empty event is skipped by - /// `events_to_messages` so it never becomes an empty assistant turn - /// in the API payload. - pub fn start_streaming(&mut self) { - self.conversation.events.push(ConversationEvent::Text { - content: String::new(), - }); - self.interaction.streaming_status = None; - self.interaction.was_interrupted = false; - self.interaction.mode = AppMode::Streaming; - } - - /// Update streaming status from SSE event - pub fn update_streaming_status(&mut self, status: &str) { - self.interaction.streaming_status = Some(StreamingStatus::from_status_str(status)); - } - - /// Cancel streaming with context preservation - pub fn cancel_streaming(&mut self) { - if let Some(abort) = self.stream_abort.take() { - abort.abort(); - } - self.interaction.was_interrupted = true; - - if let Some(content) = self.conversation.streaming_content_mut() { - let trimmed = content.trim_start().to_string(); - if trimmed.is_empty() { - // Remove the empty text event - *content = String::new(); - } else { - *content = format!("{trimmed}\n\n[User cancelled this generation]"); - } - } - // Remove trailing empty Text events - self.conversation.remove_empty_trailing_text(); - - self.interaction.streaming_status = None; - self.interaction.confirmation_pending = false; - self.interaction.mode = AppMode::Input; - } - - /// Add a tool call event during streaming. - /// The current streaming text is already in events, so we just push the tool call. - pub fn add_tool_call(&mut self, id: String, name: String, input: serde_json::Value) { - // Trim the streaming text event - if let Some(content) = self.conversation.streaming_content_mut() { - let trimmed = content.trim_start().to_string(); - *content = trimmed; - } - self.conversation.remove_empty_trailing_text(); - - let is_suggest_command = name == "suggest_command"; - self.conversation - .events - .push(ConversationEvent::ToolCall { id, name, input }); - - if is_suggest_command { - self.interaction.streaming_status = None; - self.interaction.mode = AppMode::Input; - } - } - - /// Finalize streaming — trim the accumulated text and change mode - pub fn finalize_streaming(&mut self) { - if let Some(content) = self.conversation.streaming_content_mut() { - let trimmed = content.trim_start().to_string(); - *content = trimmed; - } - self.conversation.remove_empty_trailing_text(); - self.interaction.streaming_status = None; - self.interaction.mode = AppMode::Input; - } - - /// Streaming error — remove the partial text event - pub fn streaming_error(&mut self, error: String) { - self.conversation.remove_empty_trailing_text(); - self.interaction.error = Some(error); - self.interaction.mode = AppMode::Error; - } - - pub(crate) fn handle_client_tool_call( - &mut self, - id: String, - tool: ClientToolCall, - input: serde_json::Value, - ) { - let desc = tool.descriptor(); - let name = desc.canonical_names[0].to_string(); - - self.tool_tracker.insert(id.clone(), tool); - - // Add the ToolCall event to the conversation immediately so it appears - // in the view. Preview data is sourced from tool_tracker. - self.conversation - .events - .push(ConversationEvent::ToolCall { id, name, input }); - - // Client tool calls can only happen at the last part of a turn - self.interaction.streaming_status = None; - self.interaction.mode = AppMode::Input; - } - - /// Retry after error - pub fn retry(&mut self) { - self.interaction.error = None; - self.interaction.mode = AppMode::Generating; - } - - // ===== Tool lifecycle methods ===== - - /// Finish a tool call: transition tracker to Completed, push ToolResult to conversation. - /// - /// For shell commands, captures the final preview from the ExecutingWithPreview phase - /// and patches exit_code/interrupted from the authoritative ToolOutcome. - pub fn finish_tool_call(&mut self, tool_id: &str, outcome: ToolOutcome) { - let mut preview = self.tool_tracker.get(tool_id).and_then(|t| t.preview()); - - // Patch preview with authoritative outcome data (handles race where - // final VT100 update hasn't been applied yet). - if let Some(ref mut p) = preview - && let ToolOutcome::Structured { - exit_code, - interrupted, - .. - } = &outcome - { - p.interrupted = *interrupted; - if p.exit_code.is_none() { - p.exit_code = *exit_code; - } - } - - // Transition tracker entry to Completed - if let Some(tracked) = self.tool_tracker.get_mut(tool_id) { - tracked.complete(preview); - } - - let content = outcome.format_for_llm(); - let is_error = outcome.is_error(); - self.conversation - .add_tool_result(tool_id.to_string(), content, is_error, false, None); - } - - /// Get the footer text for current mode - pub fn footer_text(&self) -> &'static str { - match self.interaction.mode { - AppMode::Input => { - if self.conversation.has_any_command() && self.interaction.is_input_blank { - if self.interaction.confirmation_pending { - "[Enter] Confirm dangerous command [Esc] Cancel" - } else { - "[Enter] Execute suggested command [Tab] Insert Command" - } - } else { - "[Enter] Send [Shift+Enter] New line [Esc] Exit" - } - } - AppMode::Generating | AppMode::Streaming => "[Esc] Cancel", - AppMode::Error => "[Enter]/[r] Retry [Esc] Exit", - } - } - - /// Check if the application is exiting - pub fn is_exiting(&self) -> bool { - self.exit_action.is_some() - } -} diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index 6e13e406..d40a44d4 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -5,7 +5,9 @@ use eye_declare::{ }; use ratatui_core::style::{Color, Modifier, Style}; -use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview, TrackedTool}; +use crate::driver::ViewState; +use crate::fsm::{AgentState, StreamPhase}; +use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview}; use crate::tui::components::select::SelectOption; use crate::tui::components::session_continue::SessionContinue; use crate::tui::events::{AiTuiEvent, PermissionResult}; @@ -14,10 +16,23 @@ use super::components::atuin_ai::AtuinAi; use super::components::input_box::InputBox; use super::components::markdown::Markdown; use super::components::select::Select; -use super::state::{AppMode, Session}; +use super::state::AppMode; mod turn; +impl From<&AgentState> for AppMode { + fn from(state: &AgentState) -> Self { + match state { + AgentState::Idle { .. } => AppMode::Input, + AgentState::Turn { + stream: StreamPhase::Connecting, + } => AppMode::Generating, + AgentState::Turn { .. } => AppMode::Streaming, + AgentState::Error(_) => AppMode::Error, + } + } +} + /// Build the element tree from current state. /// /// Layout (top to bottom): @@ -26,28 +41,27 @@ mod turn; /// - Error display (if in error state) /// - Spacer /// - Input box (bordered, with contextual keybindings) -pub(crate) fn ai_view(state: &Session) -> Elements { - let mut turn_builder = turn::TurnBuilder::new(&state.tool_tracker); +pub(crate) fn ai_view(state: &ViewState) -> Elements { + let mut turn_builder = turn::TurnBuilder::new(&state.tools); - for event in &state.archived_view_events { + for event in &state.archived_events { turn_builder.add_event(event); } - for event in &state.conversation.events[state.view_start_index..] { + for event in &state.visible_events { turn_builder.add_event(event); } let turns = turn_builder.build(); - let busy = state.interaction.mode == AppMode::Streaming - || state.interaction.mode == AppMode::Generating; + let busy = state.is_busy(); let last_index = turns.len().saturating_sub(1); element! { AtuinAi( - mode: state.interaction.mode, - has_command: state.conversation.has_any_command(), - is_input_blank: state.interaction.is_input_blank, - pending_confirmation: state.interaction.confirmation_pending, - has_executing_preview: state.tool_tracker.has_executing_preview(), + mode: AppMode::from(&state.agent_state), + 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) @@ -77,6 +91,15 @@ pub(crate) fn ai_view(state: &Session) -> Elements { } }) + #(if let AgentState::Error(ref msg) = state.agent_state { + View(key: "error-display", padding_left: Cells::from(2), padding_top: Cells::from(1)) { + Text { + Span(text: "Error: ", style: Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + Span(text: msg, style: Style::default().fg(Color::Red)) + } + } + }) + #(if !state.is_exiting() { #(input_view(state)) }) @@ -84,11 +107,10 @@ pub(crate) fn ai_view(state: &Session) -> Elements { } } -fn input_view(state: &Session) -> Elements { - let asking_tool = state.tool_tracker.asking_for_permission(); +fn input_view(state: &ViewState) -> Elements { + let asking_tool = state.tools.awaiting_permission(); let in_git_project = state.in_git_project; let slash_results = state - .interaction .slash_command_search_results .iter() .take(4) @@ -107,12 +129,12 @@ fn input_view(state: &Session) -> Elements { title: "Generate a command or ask a question", title_right: "Atuin AI", footer: state.footer_text(), - active: state.interaction.mode == AppMode::Input && !state.interaction.confirmation_pending, + active: state.is_input_active(), slash_suggestion: first_slash_result.cloned() ) - #(if state.interaction.is_input_blank && state.conversation.has_any_command() && state.interaction.mode == AppMode::Input { - #(if state.interaction.confirmation_pending { + #(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 { Text { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } @@ -140,7 +162,7 @@ fn input_view(state: &Session) -> Elements { } } -fn tool_call_view(tool_call: &TrackedTool, in_git_project: bool) -> Elements { +fn tool_call_view(tool_call: &crate::fsm::tools::TrackedTool, in_git_project: bool) -> Elements { let verb = tool_call.tool.descriptor().display_verb; let tool_desc = match &tool_call.tool { ClientToolCall::Read(tool) => tool.path.display().to_string(), diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs index 6c3d5c29..98ae5eff 100644 --- a/crates/atuin-ai/src/tui/view/turn.rs +++ b/crates/atuin-ai/src/tui/view/turn.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; +use crate::fsm::tools::ToolManager; use crate::tools::descriptor; -use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview, ToolTracker}; +use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview}; use crate::tui::ConversationEvent; /// Server-sent danger level for a suggested command @@ -210,12 +211,12 @@ pub(crate) enum UiTurn { pub(crate) struct TurnBuilder<'a> { turns: Vec<UiTurn>, current_turn: Option<UiTurn>, - tracker: &'a ToolTracker, + tracker: &'a ToolManager, } /// A struct to iteratively build [UiTurn] events from [ConversationEvent]s. impl<'a> TurnBuilder<'a> { - pub(crate) fn new(tracker: &'a ToolTracker) -> Self { + pub(crate) fn new(tracker: &'a ToolManager) -> Self { Self { turns: Vec::new(), current_turn: None, @@ -441,18 +442,18 @@ impl<'a> TurnBuilder<'a> { match &tracked.tool { ClientToolCall::Shell(shell) => ToolRenderData::Shell { command: shell.command.clone(), - preview: tracked.preview(), + preview: tracked.shell_preview(), }, ClientToolCall::Read(read) => ToolRenderData::FileRead { path: read.path.clone(), }, ClientToolCall::Edit(edit) => ToolRenderData::FileEdit { path: edit.path.clone(), - preview: tracked.edit_preview.clone(), + preview: tracked.edit_preview().cloned(), }, ClientToolCall::Write(write) => ToolRenderData::FileWrite { path: write.path.clone(), - preview: tracked.write_preview.clone(), + preview: tracked.write_preview().cloned(), }, ClientToolCall::AtuinHistory(history) => ToolRenderData::HistorySearch { query: history.query.clone(), |
