diff options
Diffstat (limited to 'crates/atuin-ai/src/tui/view_model.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/view_model.rs | 413 |
1 files changed, 0 insertions, 413 deletions
diff --git a/crates/atuin-ai/src/tui/view_model.rs b/crates/atuin-ai/src/tui/view_model.rs deleted file mode 100644 index 0a296065..00000000 --- a/crates/atuin-ai/src/tui/view_model.rs +++ /dev/null @@ -1,413 +0,0 @@ -//! View model types for the TUI application -//! -//! This module contains the view model types that represent the rendering -//! specification. These types are derived from the domain state (conversation -//! events) via the `Blocks::from_state()` function. - -use super::state::{AppMode, AppState, ConversationEvent}; - -/// Warning classification for command suggestions -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WarningKind { - /// Dangerous command (! indicator, AlertError color) - Danger, - /// Low confidence answer (? indicator, AlertWarn color) - LowConfidence, -} - -/// Content variants for blocks - each variant is fully self-describing -#[derive(Debug, Clone)] -pub enum Content { - Input { - text: String, - active: bool, - cursor_pos: usize, - }, - /// Command suggestion (from suggest_command tool call) - Command { - text: String, - faded: bool, // Phase 5 feature - }, - Text { - markdown: String, - }, - Error { - message: String, - }, - /// Warning for dangerous or low-confidence commands - Warning { - kind: WarningKind, - text: String, - pending_confirm: bool, // true when awaiting second Enter - }, - Spinner { - frame: usize, // 0-3 for animation - status_text: String, // Status-based text (Processing..., Thinking..., etc.) - }, - /// Tool call status display (in-flight or completed summary) - ToolStatus { - /// Number of non-suggest_command tools completed - completed_count: usize, - /// Current in-flight tool description (None if all done) - current_label: Option<String>, - /// Spinner frame for in-flight display - frame: usize, - }, -} - -impl Content { - /// Get the prefix symbol for this content type - pub fn prefix_symbol(&self) -> &'static str { - match self { - Content::Input { .. } => ">", - Content::Command { .. } => "$", - Content::Text { .. } => " ", - Content::Error { .. } => "!", - Content::Warning { kind, .. } => match kind { - WarningKind::Danger => "!", - WarningKind::LowConfidence => "?", - }, - Content::Spinner { .. } => "/", - Content::ToolStatus { current_label, .. } => { - if current_label.is_some() { - "/" - } else { - "\u{2713}" - } // spinner or checkmark - } - } - } -} - -/// A visual block in the UI -#[derive(Debug, Clone)] -pub struct Block { - pub content: Vec<Content>, - pub separator_above: bool, - pub title: Option<String>, -} - -/// Status bar content shown on the bottom border during processing -#[derive(Debug, Clone)] -pub struct StatusBar { - /// Spinner animation frame - pub frame: usize, - /// Status text to display (e.g., "Thinking...", "run_bash (used 2 tools)") - pub text: String, -} - -/// Complete view model - the rendering specification -#[derive(Debug, Clone)] -pub struct Blocks { - pub items: Vec<Block>, - pub footer: &'static str, - /// Transient status shown on bottom border during streaming/generating - pub status_bar: Option<StatusBar>, -} - -/// Count non-suggest_command tool calls since the last user message -fn count_tool_calls_since_last_user(events: &[ConversationEvent]) -> (usize, Option<String>) { - let last_user_idx = events - .iter() - .rposition(|e| matches!(e, ConversationEvent::UserMessage { .. })) - .unwrap_or(0); - - let mut completed = 0; - let mut in_flight: Option<String> = None; - - for event in &events[last_user_idx..] { - match event { - ConversationEvent::ToolCall { name, .. } if name != "suggest_command" => { - // New tool call starts as in-flight - if in_flight.is_some() { - // Previous tool is now completed - completed += 1; - } - in_flight = Some(name.clone()); - } - ConversationEvent::ToolResult { .. } => { - // Tool completed - if in_flight.is_some() { - completed += 1; - in_flight = None; - } - } - _ => {} - } - } - - (completed, in_flight) -} - -/// Check if any turn in the conversation has a command -fn has_any_command(events: &[ConversationEvent]) -> bool { - 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 - } - }) -} - -impl Blocks { - /// Pure function: derive the complete view model from state - /// - /// Iterates through conversation events and builds visual blocks. - /// Also handles streaming text and mode-dependent UI. - pub fn from_state(state: &AppState) -> Self { - let mut items = Vec::new(); - let mut status_bar = None; - - // 1. Build blocks from conversation events - for event in &state.events { - match event { - ConversationEvent::UserMessage { content } => { - items.push(Block { - content: vec![Content::Input { - text: content.clone(), - active: false, - cursor_pos: 0, - }], - separator_above: false, - title: None, - }); - } - ConversationEvent::Text { content } => { - // In Review mode with completed tool calls, prepend ToolStatus to this Text block - let (completed, _) = count_tool_calls_since_last_user(&state.events); - let mut block_content = Vec::new(); - - if state.mode == AppMode::Review && completed > 0 { - block_content.push(Content::ToolStatus { - completed_count: completed, - current_label: None, - frame: 0, - }); - } - - block_content.push(Content::Text { - markdown: content.clone(), - }); - - items.push(Block { - content: block_content, - separator_above: false, - title: None, - }); - } - ConversationEvent::ToolCall { name, input, .. } => { - // Only render suggest_command tool calls with a command - if name == "suggest_command" { - let command = input.get("command").and_then(|v| v.as_str()); - - // Build block content - only render if command is present - // When command is null, this is a conversation-only turn and the - // response text comes via a separate Text event - let mut block_content = Vec::new(); - - if let Some(cmd) = command { - block_content.push(Content::Command { - text: cmd.to_string(), - faded: false, - }); - } - - // Extract warning data from tool call input - // danger: "high" | "medium" | "med" | "low" - high/medium/med trigger warning - let danger_level = input - .get("danger") - .and_then(|v| v.as_str()) - .unwrap_or("low"); - let is_dangerous = danger_level == "high" - || danger_level == "medium" - || danger_level == "med"; - let danger_notes = input.get("danger_notes").and_then(|v| v.as_str()); - - // confidence: "high" | "medium" | "low" - low triggers warning - let confidence_level = input - .get("confidence") - .and_then(|v| v.as_str()) - .unwrap_or("high"); - let is_low_confidence = confidence_level == "low"; - let confidence_notes = - input.get("confidence_notes").and_then(|v| v.as_str()); - - // Add warning content if applicable (danger takes precedence) - if is_dangerous { - if let Some(notes) = danger_notes { - block_content.push(Content::Warning { - kind: WarningKind::Danger, - text: notes.to_string(), - pending_confirm: state.confirmation_pending, - }); - } - } else if is_low_confidence && let Some(notes) = confidence_notes { - block_content.push(Content::Warning { - kind: WarningKind::LowConfidence, - text: notes.to_string(), - pending_confirm: false, // low confidence doesn't require confirm - }); - } - - // Only add block if there's content - if !block_content.is_empty() { - items.push(Block { - content: block_content, - separator_above: false, - title: None, - }); - } - } - // Other tool calls are not rendered (internal protocol) - } - ConversationEvent::ToolResult { .. } => { - // Tool results are not rendered (internal protocol) - } - } - } - - // 2. AI response block (streaming text only) - shown during Streaming only - // Transient status (spinner, tool progress) goes to status_bar on the bottom border. - // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above. - if state.mode == AppMode::Streaming { - let (completed, in_flight) = count_tool_calls_since_last_user(&state.events); - - // Tool status -> status bar - if let Some(ref label) = in_flight { - let text = if completed > 0 { - format!( - "{} (used {} tool{})", - label, - completed, - if completed == 1 { "" } else { "s" } - ) - } else { - label.clone() - }; - status_bar = Some(StatusBar { - frame: state.spinner_frame, - text, - }); - } - - // Spinner -> status bar (only when no text yet and no tool in-flight) - if state.streaming_text.is_empty() { - let should_show_spinner = state.streaming_status.is_some() - || state - .streaming_started - .map(|start| start.elapsed() >= std::time::Duration::from_millis(200)) - .unwrap_or(true); - - if should_show_spinner && in_flight.is_none() { - let status_text = state - .streaming_status - .as_ref() - .map(|s| s.display_text().to_string()) - .unwrap_or_else(|| "Generating...".to_string()); - - status_bar = Some(StatusBar { - frame: state.spinner_frame, - text: status_text, - }); - } - } else { - // Show streaming text as content - items.push(Block { - content: vec![Content::Text { - markdown: state.streaming_text.clone(), - }], - separator_above: false, - title: None, - }); - } - } - - // 3. Mode-dependent UI - match state.mode { - AppMode::Input => { - // Active input uses TextArea widget, rendered directly - // We add a placeholder block that will be replaced by textarea rendering - items.push(Block { - content: vec![Content::Input { - text: state.input(), - active: true, - cursor_pos: 0, // Not used for active input - textarea handles cursor - }], - separator_above: false, - title: None, - }); - } - AppMode::Generating => { - let status_text = state - .streaming_status - .as_ref() - .map(|s| s.display_text().to_string()) - .unwrap_or_else(|| "Generating...".to_string()); - - status_bar = Some(StatusBar { - frame: state.spinner_frame, - text: status_text, - }); - } - AppMode::Streaming => { - // Handled above in streaming text section - } - AppMode::Review | AppMode::Error => { - // No additional UI elements - } - } - - // 4. Error if present (renders at end) - if let Some(ref err) = state.error { - items.push(Block { - content: vec![Content::Error { - message: err.clone(), - }], - separator_above: false, - title: None, - }); - } - - // 5. Set separator flags (first has no separator) - for (idx, block) in items.iter_mut().enumerate() { - block.separator_above = idx > 0; - } - - // 6. Set title on first block only - if let Some(first) = items.first_mut() { - first.title = Some("Ask questions or generate a command:".to_string()); - } - - // 7. Derive footer from mode and events - let footer = Self::footer_for_mode(&state.mode, &state.events, state.confirmation_pending); - - Self { - items, - footer, - status_bar, - } - } - - /// Derive footer text from current mode and conversation state - fn footer_for_mode( - mode: &AppMode, - events: &[ConversationEvent], - confirmation_pending: bool, - ) -> &'static str { - match mode { - AppMode::Input => "[Enter]: Accept [Esc]: Cancel", - AppMode::Generating | AppMode::Streaming => "[Esc]: Cancel", - AppMode::Review => { - if confirmation_pending { - "[Enter]: Confirm dangerous command [Esc]: Cancel" - } else if has_any_command(events) { - "[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel" - } else { - "[f]: Follow-up [Esc]: Cancel" - } - } - AppMode::Error => "[Enter]/[r]: Retry [Esc]: Cancel", - } - } -} |
