diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-26 19:19:47 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-27 02:19:47 +0000 |
| commit | b649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch) | |
| tree | ca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/state.rs | |
| parent | fix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff) | |
| download | atuin-b649a7ab8de6488c1341e94c37d032c07d5b3f13.zip | |
feat: Use eye-declare for more performant and flexible AI TUI (#3343)
This PR replaces the mess of custom rendering code in Atuin AI with
[eye-declare](https://github.com/BinaryMuse/eye-declare), and updates
the TUI to feel more terminal-native: output appears inline and persists
in scrollback, so you can scroll up and look at previous conversations
for reference.
The "review" state — which used to exist between the LLM generating a
response and the user either executing or following up — has been
removed; just start typing to follow up with the LLM, or press `enter`
at the empty input box to execute the suggested command.
<img width="1203" height="633" alt="image"
src="https://github.com/user-attachments/assets/159ee447-9a2a-4edd-b56e-a79bf1aaaa94"
/>
Diffstat (limited to 'crates/atuin-ai/src/tui/state.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 382 |
1 files changed, 201 insertions, 181 deletions
diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs index ba9c8ac6..c7271d29 100644 --- a/crates/atuin-ai/src/tui/state.rs +++ b/crates/atuin-ai/src/tui/state.rs @@ -3,10 +3,7 @@ //! This module contains the core state types that represent the application's //! domain model. Conversation events match the API protocol format. -use std::time::Instant; -use tui_textarea::TextArea; - -use super::spinner::{ACTIVE_SPINNER, active_tick_interval}; +use tokio::task::AbortHandle; /// Streaming status indicators from server #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,7 +20,7 @@ impl StreamingStatus { "processing" => Self::Processing, "searching" => Self::Searching, "waiting_for_tools" => Self::WaitingForTools, - _ => Self::Thinking, // Default to thinking for "thinking" and unknown + _ => Self::Thinking, } } @@ -56,6 +53,12 @@ pub enum ConversationEvent { content: String, is_error: bool, }, + /// Out-of-band output from the system - not sent to the server + OutOfBandOutput { + name: String, + command: Option<String>, + content: String, + }, } impl ConversationEvent { @@ -86,6 +89,16 @@ impl ConversationEvent { "content": content, "is_error": is_error }), + ConversationEvent::OutOfBandOutput { + name, + command, + content, + } => serde_json::json!({ + "type": "out_of_band_output", + "name": name, + "command": command, + "content": content + }), } } @@ -94,7 +107,6 @@ impl ConversationEvent { if let ConversationEvent::ToolCall { name, input, .. } = self && name == "suggest_command" { - // command can be null for pure conversational turns return input.get("command").and_then(|v| v.as_str()); } None @@ -109,8 +121,6 @@ pub enum AppMode { Generating, /// Streaming SSE response Streaming, - /// Reviewing generated command - Review, /// Error state, can retry Error, } @@ -125,49 +135,32 @@ pub enum ExitAction { Cancel, } -/// Application state - the domain model +/// Application state — the domain model /// /// Conversation is stored as a sequence of events matching the API protocol. -/// The view model is derived from this state via `Blocks::from_state()`. +/// The view function derives the UI from this state. +#[derive(Debug)] pub struct AppState { /// Current application mode pub mode: AppMode, /// Conversation events (source of truth, matches API protocol) pub events: Vec<ConversationEvent>, - /// Text being streamed (accumulated, flushed to Text event on completion) - pub streaming_text: String, - /// Active text input (uses tui-textarea for proper cursor handling) - pub textarea: TextArea<'static>, - /// Current error message (renders at end of blocks) + /// Current error message pub error: Option<String>, - /// Whether app should exit - pub should_exit: bool, /// Exit action (set when exiting) pub exit_action: Option<ExitAction>, - /// Session ID from server (store after first response, send on subsequent) + /// Session ID from server pub session_id: Option<String>, - /// Current streaming status (for spinner text) + /// Current streaming status pub streaming_status: Option<StreamingStatus>, + /// Whether the input is blank + pub is_input_blank: bool, /// Whether current turn was interrupted by user pub was_interrupted: bool, - /// Spinner animation state - pub spinner_frame: usize, - /// When spinner frame last advanced (for timing control) - pub last_spinner_tick: Instant, - /// When streaming started (for spinner delay) - pub streaming_started: Option<Instant>, /// True when user has pressed Enter once on a dangerous command pub confirmation_pending: bool, -} - -/// Create a TextArea with our preferred configuration -fn create_textarea() -> TextArea<'static> { - let mut textarea = TextArea::default(); - // Disable underline on cursor line - it's distracting - textarea.set_cursor_line_style(ratatui::style::Style::default()); - // Enable word wrapping - textarea.set_wrap_mode(tui_textarea::WrapMode::Word); - textarea + /// Abort handle for the active streaming task, if any + pub stream_abort: Option<AbortHandle>, } impl AppState { @@ -175,38 +168,18 @@ impl AppState { Self { mode: AppMode::Input, events: Vec::new(), - streaming_text: String::new(), - textarea: create_textarea(), error: None, - should_exit: false, exit_action: None, session_id: None, streaming_status: None, + is_input_blank: false, was_interrupted: false, - spinner_frame: 0, - last_spinner_tick: Instant::now(), - streaming_started: None, confirmation_pending: false, + stream_abort: None, } } - /// Get the current input text - pub fn input(&self) -> String { - self.textarea.lines().join("\n") - } - - /// Check if input is empty - pub fn input_is_empty(&self) -> bool { - self.textarea.is_empty() - } - - /// Clear the input - pub fn clear_input(&mut self) { - self.textarea = create_textarea(); - } - /// Convert conversation events to Claude API message format - /// Groups consecutive tool calls, handles role alternation pub fn events_to_messages(&self) -> Vec<serde_json::Value> { let mut messages = Vec::new(); let mut i = 0; @@ -229,7 +202,6 @@ impl AppState { i += 1; } ConversationEvent::ToolCall { .. } => { - // Group consecutive tool calls into single assistant message let mut tool_uses = Vec::new(); while i < events.len() { if let ConversationEvent::ToolCall { id, name, input } = &events[i] { @@ -265,6 +237,10 @@ impl AppState { })); i += 1; } + ConversationEvent::OutOfBandOutput { .. } => { + // Out-of-band output is not sent to the server, so we don't need to add it to the messages + i += 1; + } } } @@ -273,59 +249,13 @@ impl AppState { // ===== Generation lifecycle methods ===== - /// Start generating from current input - pub fn start_generating(&mut self) { - // Add user message event - self.events.push(ConversationEvent::UserMessage { - content: self.input(), - }); - - // Clear input, switch mode - self.clear_input(); + /// Start generating from submitted input + pub fn start_generating(&mut self, input: String) { + self.events + .push(ConversationEvent::UserMessage { content: input }); self.mode = AppMode::Generating; } - /// Generation complete with command (legacy method, kept for compatibility) - pub fn generation_complete( - &mut self, - command: String, - explanation: Option<String>, - dangerous: bool, - warnings: Vec<String>, - ) { - // Add explanation as text event if present - if let Some(ref exp) = explanation { - self.events.push(ConversationEvent::Text { - content: exp.clone(), - }); - } - - // Add tool_call event for suggest_command - let tool_id = format!("gen_{}", uuid::Uuid::new_v4().simple()); - let mut tool_input = serde_json::json!({ - "command": command, - "conversation_only": false, - "confidence": "high" - }); - if let Some(ref exp) = explanation { - tool_input["message"] = serde_json::json!(exp); - } - if dangerous { - tool_input["danger"] = serde_json::json!("high"); - } - if !warnings.is_empty() { - tool_input["warning"] = serde_json::json!(warnings.join("; ")); - } - - self.events.push(ConversationEvent::ToolCall { - id: tool_id, - name: "suggest_command".to_string(), - input: tool_input, - }); - - self.mode = AppMode::Review; - } - /// Generation error occurred pub fn generation_error(&mut self, error: String) { self.error = Some(error); @@ -334,22 +264,25 @@ impl AppState { /// Cancel during generation pub fn cancel_generation(&mut self) { - // Remove the last user message since generation was cancelled + if let Some(abort) = self.stream_abort.take() { + abort.abort(); + } if let Some(ConversationEvent::UserMessage { .. }) = self.events.last() { self.events.pop(); } self.mode = AppMode::Input; - self.clear_input(); } // ===== Streaming lifecycle methods ===== - /// Start streaming response + /// Start streaming response. + /// Pushes an empty Text event that will be mutated in-place as chunks arrive. pub fn start_streaming(&mut self) { - self.streaming_text.clear(); + self.events.push(ConversationEvent::Text { + content: String::new(), + }); self.streaming_status = None; self.was_interrupted = false; - self.streaming_started = Some(Instant::now()); self.mode = AppMode::Streaming; } @@ -363,66 +296,81 @@ impl AppState { self.streaming_status = Some(StreamingStatus::from_status_str(status)); } + /// 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 + } + }) + } + /// Cancel streaming with context preservation pub fn cancel_streaming(&mut self) { - // Mark as interrupted + if let Some(abort) = self.stream_abort.take() { + abort.abort(); + } self.was_interrupted = true; - // Flush partial text with interruption marker if any - // Trim leading whitespace since LLM responses often start with \n\n - let content = std::mem::take(&mut self.streaming_text); - let trimmed = content.trim_start(); - if !trimmed.is_empty() { - let interrupted_text = format!("{trimmed}\n\n[User cancelled this generation]"); - self.events.push(ConversationEvent::Text { - content: interrupted_text, - }); + if let Some(content) = self.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.remove_empty_trailing_text(); - // Clear status and return to input self.streaming_status = None; self.confirmation_pending = false; self.mode = AppMode::Input; } - /// Append text chunk during streaming - /// Trims leading whitespace from the first chunk(s) since LLM responses often start with \n\n + /// Append text chunk during streaming (mutates the last Text event in-place) pub fn append_streaming_text(&mut self, chunk: &str) { - if self.streaming_text.is_empty() { - // First chunk(s): trim leading whitespace - let trimmed = chunk.trim_start(); - if !trimmed.is_empty() { - self.streaming_text.push_str(trimmed); + // 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); } - } else { - // Subsequent chunks: append as-is - self.streaming_text.push_str(chunk); } } - /// Add a tool call event during streaming - /// Flushes any pending streaming text first to maintain correct event order - /// For suggest_command, also transitions to Review mode since that ends the LLM turn + /// 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) { - // Flush streaming text before adding tool call to maintain correct order - let content = std::mem::take(&mut self.streaming_text); - let trimmed = content.trim_start(); - if !trimmed.is_empty() { - self.events.push(ConversationEvent::Text { - content: trimmed.to_string(), - }); + // Trim the streaming text event + if let Some(content) = self.streaming_content_mut() { + let trimmed = content.trim_start().to_string(); + *content = trimmed; } + self.remove_empty_trailing_text(); - // suggest_command marks the end of the LLM turn - transition to Review let is_suggest_command = name == "suggest_command"; - self.events .push(ConversationEvent::ToolCall { id, name, input }); if is_suggest_command { self.streaming_status = None; - self.streaming_started = None; - self.mode = AppMode::Review; + self.mode = AppMode::Input; } } @@ -435,72 +383,77 @@ impl AppState { }); } - /// Finalize streaming - flush accumulated text to event + /// Finalize streaming — trim the accumulated text and change mode pub fn finalize_streaming(&mut self) { - // Flush streaming text to a Text event if non-empty - // Trim leading whitespace since LLM responses often start with \n\n - let content = std::mem::take(&mut self.streaming_text); - let trimmed = content.trim_start(); - if !trimmed.is_empty() { - self.events.push(ConversationEvent::Text { - content: trimmed.to_string(), - }); + if let Some(content) = self.streaming_content_mut() { + let trimmed = content.trim_start().to_string(); + *content = trimmed; } + self.remove_empty_trailing_text(); self.streaming_status = None; - self.streaming_started = None; - self.mode = AppMode::Review; + self.mode = AppMode::Input; } - /// Streaming error + /// Streaming error — remove the partial text event pub fn streaming_error(&mut self, error: String) { - // Discard any partial streaming text - self.streaming_text.clear(); - self.streaming_started = None; + self.remove_empty_trailing_text(); self.error = Some(error); self.mode = AppMode::Error; } + /// 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; + } + } + } + // ===== Edit mode and exit methods ===== /// Start edit mode for refinement pub fn start_edit_mode(&mut self) { self.confirmation_pending = false; - self.clear_input(); self.mode = AppMode::Input; } - /// Exit with action - pub fn exit(&mut self, action: ExitAction) { - self.exit_action = Some(action); - self.should_exit = true; - } - /// Retry after error pub fn retry(&mut self) { self.error = None; self.mode = AppMode::Generating; } - // ===== Utility methods ===== + /// Handle a slash command + pub fn handle_slash_command(&mut self, command: &str) { + match command.trim() { + "/help" => { + let content = include_str!("./content/help.md"); - /// Advance spinner frame if enough time has passed - /// Called on every event loop tick (50ms), but only advances spinner - /// when the active spinner's interval has elapsed - pub fn tick(&mut self) { - let interval = active_tick_interval(); - if self.last_spinner_tick.elapsed() >= interval { - self.spinner_frame = (self.spinner_frame + 1) % ACTIVE_SPINNER.frame_count(); - self.last_spinner_tick = Instant::now(); + self.events.push(ConversationEvent::OutOfBandOutput { + name: "System".to_string(), + command: Some("/help".to_string()), + content: content.to_string(), + }); + } + _ => self.events.push(ConversationEvent::OutOfBandOutput { + name: "System".to_string(), + command: None, + content: (format!("Unknown command: {command}")), + }), } } + // ===== Query methods ===== + /// 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 the most recent command suggestion is marked dangerous - /// Checks the `danger` field for "high", "medium", or "med" values + /// Check if the most recent command is marked dangerous pub fn is_current_command_dangerous(&self) -> bool { self.events .iter() @@ -521,6 +474,73 @@ impl AppState { }) .unwrap_or(false) } + + /// Count non-suggest_command tool calls since the last user message + pub fn tool_count_since_last_user(&self) -> usize { + let last_user_idx = self + .events + .iter() + .rposition(|e| matches!(e, ConversationEvent::UserMessage { .. })) + .unwrap_or(0); + + let mut completed = 0; + let mut in_flight = false; + + for event in &self.events[last_user_idx..] { + match event { + ConversationEvent::ToolCall { name, .. } if name != "suggest_command" => { + if in_flight { + completed += 1; + } + in_flight = true; + } + ConversationEvent::ToolResult { .. } => { + if in_flight { + completed += 1; + in_flight = false; + } + } + _ => {} + } + } + + completed + } + + /// 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 + } + }) + } + + /// Get the footer text for current mode + pub fn footer_text(&self) -> &'static str { + match self.mode { + AppMode::Input => { + if self.has_any_command() && self.is_input_blank { + if self.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() + } } impl Default for AppState { |
