aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/state.rs
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-26 19:19:47 -0700
committerGitHub <noreply@github.com>2026-03-27 02:19:47 +0000
commitb649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch)
treeca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/tui/state.rs
parentfix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff)
downloadatuin-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.rs382
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 {