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/state.rs | |
| 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/state.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 511 |
1 files changed, 10 insertions, 501 deletions
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() - } -} |
