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 | |
| 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')
| -rw-r--r-- | crates/atuin-ai/src/tui/app.rs | 157 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/component.rs | 186 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components.rs | 510 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/atuin_ai.rs | 140 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/input_box.rs | 229 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/markdown.rs | 213 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/mod.rs | 3 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/content/help.md | 3 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/event.rs | 303 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/events.rs | 27 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 16 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/popup.rs | 363 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 234 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/spinner.rs | 99 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 382 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/terminal.rs | 278 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 342 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 409 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view_model.rs | 413 |
19 files changed, 1569 insertions, 2738 deletions
diff --git a/crates/atuin-ai/src/tui/app.rs b/crates/atuin-ai/src/tui/app.rs deleted file mode 100644 index ecb1eb81..00000000 --- a/crates/atuin-ai/src/tui/app.rs +++ /dev/null @@ -1,157 +0,0 @@ -use super::state::{AppMode, AppState, ExitAction}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use tui_textarea::{Input, Key}; - -/// Thin wrapper around AppState for compatibility -/// All state lives in AppState, this just provides the handle_key interface -pub struct App { - pub state: AppState, -} - -impl App { - pub fn new() -> Self { - Self { - state: AppState::new(), - } - } - - /// Handle a key event. Returns true if render is needed. - pub fn handle_key(&mut self, key: KeyEvent) -> bool { - match self.state.mode { - AppMode::Input => self.handle_input_key(key), - AppMode::Generating => self.handle_generating_key(key), - AppMode::Streaming => self.handle_streaming_key(key), - AppMode::Review => self.handle_review_key(key), - AppMode::Error => self.handle_error_key(key), - } - } - - fn handle_input_key(&mut self, key: KeyEvent) -> bool { - // Handle special keys ourselves - match key.code { - KeyCode::Esc => { - self.state.exit(ExitAction::Cancel); - return true; - } - KeyCode::Enter => { - if self.state.input_is_empty() { - self.state.exit(ExitAction::Cancel); - } else { - self.state.start_generating(); - } - return true; - } - _ => {} - } - - // Delegate all other keys to textarea - // Manually convert crossterm KeyEvent to tui-textarea Input - // (needed due to crossterm version mismatch) - let tui_key = match key.code { - KeyCode::Char(c) => Key::Char(c), - KeyCode::Backspace => Key::Backspace, - KeyCode::Delete => Key::Delete, - KeyCode::Left => Key::Left, - KeyCode::Right => Key::Right, - KeyCode::Up => Key::Up, - KeyCode::Down => Key::Down, - KeyCode::Home => Key::Home, - KeyCode::End => Key::End, - KeyCode::PageUp => Key::PageUp, - KeyCode::PageDown => Key::PageDown, - KeyCode::Tab => Key::Tab, - _ => Key::Null, - }; - - if tui_key != Key::Null { - let input = Input { - key: tui_key, - ctrl: key.modifiers.contains(KeyModifiers::CONTROL), - alt: key.modifiers.contains(KeyModifiers::ALT), - shift: key.modifiers.contains(KeyModifiers::SHIFT), - }; - self.state.textarea.input(input); - } - true - } - - fn handle_generating_key(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Esc => { - self.state.cancel_generation(); - true - } - _ => false, // Discard other keys during generation - } - } - - fn handle_streaming_key(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Esc => { - self.state.cancel_streaming(); - true - } - _ => false, // Ignore other keys during streaming - } - } - - fn handle_review_key(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Esc => { - self.state.confirmation_pending = false; // Clear confirmation state - self.state.exit(ExitAction::Cancel); - true - } - KeyCode::Enter => { - let cmd = self.state.current_command().map(|c| c.to_string()); - if let Some(cmd) = cmd { - if self.state.is_current_command_dangerous() && !self.state.confirmation_pending - { - // First Enter on dangerous command: enter confirmation mode - self.state.confirmation_pending = true; - } else { - // Second Enter (confirmation), or non-dangerous command: execute - self.state.confirmation_pending = false; - self.state.exit(ExitAction::Execute(cmd)); - } - } - true - } - KeyCode::Tab => { - let cmd = self.state.current_command().map(|c| c.to_string()); - if let Some(cmd) = cmd { - self.state.confirmation_pending = false; // Clear on Tab too - self.state.exit(ExitAction::Insert(cmd)); - } - true - } - KeyCode::Char('f') => { - // Changed from 'e' to 'f' for follow-up mode - self.state.confirmation_pending = false; // Clear on follow-up - self.state.start_edit_mode(); - true - } - _ => false, - } - } - - fn handle_error_key(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Esc => { - self.state.exit(ExitAction::Cancel); - true - } - KeyCode::Enter | KeyCode::Char('r') => { - self.state.retry(); - true - } - _ => false, - } - } -} - -impl Default for App { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/atuin-ai/src/tui/component.rs b/crates/atuin-ai/src/tui/component.rs deleted file mode 100644 index ff20f195..00000000 --- a/crates/atuin-ai/src/tui/component.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Component-oriented rendering primitives for the TUI. -//! -//! Defines the `Component` trait and container types (`VStack`, `SymbolRow`, etc.) -//! that enable declarative, composable UI layout. - -use atuin_client::theme::{Meaning, Theme}; -use ratatui::{ - Frame, backend::FromCrossterm, layout::Rect, style::Style, text::Span, widgets::Paragraph, -}; -use tui_textarea::TextArea; - -/// Context passed through the component tree during rendering. -pub struct RenderContext<'a> { - pub theme: &'a Theme, - pub anchor_col: u16, - pub textarea: Option<&'a TextArea<'static>>, - /// Maximum viewport height (for scroll calculations) - pub max_height: u16, - /// When true, the viewport is a fixed rect already positioned for the card. - /// The card fills the entire viewport instead of positioning via anchor_col. - pub popup_mode: bool, - /// When true, blocks are rendered in reverse order so that the input field - /// appears at the bottom of the card (close to the prompt when the popup - /// is above the cursor). - pub render_above: bool, -} - -/// A renderable component with intrinsic sizing. -pub trait Component { - /// Calculate the intrinsic height at the given width. - fn height(&self, width: u16) -> u16; - - /// Render into the given area. - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext); -} - -/// Vertical stack of components. -/// -/// Children are laid out top-to-bottom with optional spacing between them. -/// When `scroll_offset > 0`, content is scrolled so that only the visible -/// portion is rendered. -pub struct VStack { - pub children: Vec<Box<dyn Component>>, - pub spacing: u16, - pub scroll_offset: u16, -} - -impl VStack { - pub fn new(children: Vec<Box<dyn Component>>) -> Self { - Self { - children, - spacing: 0, - scroll_offset: 0, - } - } -} - -impl Component for VStack { - fn height(&self, width: u16) -> u16 { - if self.children.is_empty() { - return 0; - } - let content: u16 = self.children.iter().map(|c| c.height(width)).sum(); - let gaps = (self.children.len() as u16 - 1) * self.spacing; - content + gaps - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - if self.children.is_empty() { - return; - } - - let heights: Vec<u16> = self.children.iter().map(|c| c.height(area.width)).collect(); - - let viewport_start = self.scroll_offset; - let viewport_end = self.scroll_offset + area.height; - - let mut cum: u16 = 0; - for (i, (child, &h)) in self.children.iter().zip(heights.iter()).enumerate() { - let child_start = cum; - let child_end = cum + h; - - // Render if any part of the child is within the viewport - if child_end > viewport_start && child_start < viewport_end { - let visible_start = child_start.max(viewport_start); - let visible_end = child_end.min(viewport_end); - - let child_area = Rect { - x: area.x, - y: area.y + visible_start - viewport_start, - width: area.width, - height: visible_end - visible_start, - }; - - child.render(frame, child_area, ctx); - } - - cum = child_end; - if i < self.children.len() - 1 { - cum += self.spacing; - } - } - } -} - -/// Fixed-height empty space. -pub struct Spacer(pub u16); - -impl Component for Spacer { - fn height(&self, _width: u16) -> u16 { - self.0 - } - - fn render(&self, _frame: &mut Frame, _area: Rect, _ctx: &RenderContext) {} -} - -/// A row with a symbol in column 0 and content in columns 2+. -/// -/// This is the horizontal layout primitive used by all content types that -/// display a prefix symbol (>, $, !, ?, etc.) followed by text. -pub struct SymbolRow { - pub symbol: String, - pub symbol_meaning: Meaning, - pub inner: Box<dyn Component>, -} - -impl Component for SymbolRow { - fn height(&self, width: u16) -> u16 { - self.inner.height(width.saturating_sub(2)) - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - // Render symbol at column 0, first row only - let style = Style::from_crossterm(ctx.theme.as_style(self.symbol_meaning)); - let symbol_area = Rect { - x: area.x, - y: area.y, - width: 1, - height: 1, - }; - frame.render_widget( - Paragraph::new(self.symbol.as_str()).style(style), - symbol_area, - ); - - // Render inner content at column 2+ - let content_area = Rect { - x: area.x.saturating_add(2), - y: area.y, - width: area.width.saturating_sub(2), - height: area.height, - }; - self.inner.render(frame, content_area, ctx); - } -} - -/// Horizontal separator spanning the full card width (├───┤). -/// -/// Extends beyond its content area to overlap the card's left and right borders. -pub struct Separator { - pub card_width: u16, -} - -impl Component for Separator { - fn height(&self, _width: u16) -> u16 { - 1 - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - let inner_width = self.card_width.saturating_sub(2) as usize; - let separator = format!( - "\u{251c}{}\u{2524}", // ├ ... ┤ - "\u{2500}".repeat(inner_width) // ─ - ); - - // Extend left to overlap the card border (content area is inset by border + padding) - let sep_area = Rect { - x: area.x.saturating_sub(2), - y: area.y, - width: self.card_width, - height: 1, - }; - frame.render_widget(Paragraph::new(Span::styled(separator, style)), sep_area); - } -} diff --git a/crates/atuin-ai/src/tui/components.rs b/crates/atuin-ai/src/tui/components.rs deleted file mode 100644 index 50abd8c1..00000000 --- a/crates/atuin-ai/src/tui/components.rs +++ /dev/null @@ -1,510 +0,0 @@ -//! Leaf components for each content type and factory functions for building -//! the component tree from the view model. - -use atuin_client::theme::{Meaning, Theme}; -use ratatui::{ - Frame, - backend::FromCrossterm, - layout::Rect, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Paragraph, Wrap}, -}; - -use super::component::{Component, RenderContext, Separator, Spacer, SymbolRow, VStack}; -use super::spinner::active_frame; -use super::view_model::{Block, Content, WarningKind}; - -// --------------------------------------------------------------------------- -// Text measurement utilities -// --------------------------------------------------------------------------- - -/// Count lines when text is wrapped at given width. -/// Uses ratatui's Paragraph::line_count for accurate wrapping calculation. -pub(crate) fn line_count_wrapped(text: &str, width: usize) -> u16 { - if width == 0 { - return 1; - } - let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); - paragraph.line_count(width as u16).max(1) as u16 -} - -/// Count lines using word-wrap algorithm (matches TextArea's WrapMode::Word). -/// Words won't be broken mid-word, so this may produce more lines than character wrapping. -/// Returns (line_count, last_line_width) so caller can determine if cursor needs extra space. -pub(crate) fn word_wrap_line_count_with_last_width(text: &str, width: usize) -> (u16, usize) { - if width == 0 || text.is_empty() { - return (1, 0); - } - - let mut line_count = 0u16; - let mut current_line_width = 0usize; - - for line in text.lines() { - if line.is_empty() { - line_count += 1; - current_line_width = 0; - continue; - } - - let mut line_started = false; - - for word in line.split_whitespace() { - let word_width = unicode_width::UnicodeWidthStr::width(word); - - if !line_started { - if word_width > width { - line_count += word_width.div_ceil(width) as u16; - current_line_width = word_width % width; - if current_line_width == 0 { - current_line_width = 0; - line_started = false; - } else { - line_started = true; - } - } else { - current_line_width = word_width; - line_started = true; - } - } else { - let needed = current_line_width + 1 + word_width; - if needed > width { - line_count += 1; - if word_width > width { - line_count += word_width.div_ceil(width) as u16; - current_line_width = word_width % width; - if current_line_width == 0 { - line_started = false; - } - } else { - current_line_width = word_width; - } - } else { - current_line_width = needed; - } - } - } - - if line_started { - line_count += 1; - } - } - - if line_count == 0 { - line_count = 1; - current_line_width = 0; - } - - (line_count, current_line_width) -} - -// --------------------------------------------------------------------------- -// Inline markdown formatting -// --------------------------------------------------------------------------- - -/// Parse inline markdown formatting (**bold** and `code`) into styled spans. -/// Preserves all other text — list prefixes, indentation, and line structure -/// are left exactly as-is. -fn style_inline_markdown(text: &str, theme: &Theme) -> Vec<Line<'static>> { - let base_style = Style::from_crossterm(theme.as_style(Meaning::Base)); - let code_style = Style::from_crossterm(theme.as_style(Meaning::Guidance)); - let bold_style = base_style.add_modifier(Modifier::BOLD); - - text.lines() - .map(|line| { - Line::from(parse_inline_formatting( - line, base_style, bold_style, code_style, - )) - }) - .collect() -} - -/// Parse a single line for `code` and **bold** markers, returning styled spans. -fn parse_inline_formatting( - line: &str, - base: Style, - bold: Style, - code: Style, -) -> Vec<Span<'static>> { - let mut spans = Vec::new(); - let mut current = String::new(); - let mut chars = line.chars().peekable(); - - while let Some(ch) = chars.next() { - if ch == '`' { - // Flush accumulated plain text - if !current.is_empty() { - spans.push(Span::styled(std::mem::take(&mut current), base)); - } - // Collect until closing backtick - let mut code_text = String::new(); - let mut closed = false; - for next in chars.by_ref() { - if next == '`' { - closed = true; - break; - } - code_text.push(next); - } - if closed { - spans.push(Span::styled(code_text, code)); - } else { - // Unclosed backtick — render as-is - current.push('`'); - current.push_str(&code_text); - } - } else if ch == '*' && chars.peek() == Some(&'*') { - chars.next(); // consume second * - // Flush accumulated plain text - if !current.is_empty() { - spans.push(Span::styled(std::mem::take(&mut current), base)); - } - // Collect until closing ** - let mut bold_text = String::new(); - let mut closed = false; - while let Some(next) = chars.next() { - if next == '*' && chars.peek() == Some(&'*') { - chars.next(); - closed = true; - break; - } - bold_text.push(next); - } - if closed { - spans.push(Span::styled(bold_text, bold)); - } else { - // Unclosed ** — render as-is - current.push_str("**"); - current.push_str(&bold_text); - } - } else { - current.push(ch); - } - } - - if !current.is_empty() { - spans.push(Span::styled(current, base)); - } - - spans -} - -// --------------------------------------------------------------------------- -// Leaf components -// --------------------------------------------------------------------------- - -/// User input display (active textarea or static text). -pub struct InputContent { - pub text: String, - pub active: bool, -} - -impl Component for InputContent { - fn height(&self, width: u16) -> u16 { - let w = width as usize; - if self.active { - let (lines, last_width) = word_wrap_line_count_with_last_width(&self.text, w); - if last_width >= w { - lines.saturating_add(1) - } else { - lines - } - } else { - line_count_wrapped(&self.text, w) - } - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - if self.active { - if let Some(textarea) = ctx.textarea { - frame.render_widget(textarea, area); - } - } else { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - frame.render_widget( - Paragraph::new(self.text.as_str()) - .style(style) - .wrap(Wrap { trim: false }), - area, - ); - } - } -} - -/// Command suggestion ($ prefix). -pub struct CommandContent { - pub text: String, - pub faded: bool, -} - -impl Component for CommandContent { - fn height(&self, width: u16) -> u16 { - line_count_wrapped(&self.text, width as usize) - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - let mut style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - if self.faded { - style = style.add_modifier(Modifier::DIM); - } - frame.render_widget( - Paragraph::new(self.text.as_str()) - .style(style) - .wrap(Wrap { trim: false }), - area, - ); - } -} - -/// Markdown text content (indented, no symbol). -pub struct TextContent { - pub markdown: String, -} - -impl Component for TextContent { - fn height(&self, width: u16) -> u16 { - // Height uses raw text — slightly overestimates since markdown syntax - // characters (**, `) are stripped in rendering, but this is harmless - // (allocates equal or more space than needed, never less). - line_count_wrapped(&self.markdown, width as usize) - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - let lines = style_inline_markdown(&self.markdown, ctx.theme); - let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); - frame.render_widget(paragraph, area); - } -} - -/// Error message (! prefix). -pub struct ErrorContent { - pub message: String, -} - -impl Component for ErrorContent { - fn height(&self, width: u16) -> u16 { - line_count_wrapped(&self.message, width as usize) - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - frame.render_widget( - Paragraph::new(self.message.as_str()) - .style(style) - .wrap(Wrap { trim: false }), - area, - ); - } -} - -/// Warning for dangerous or low-confidence commands. -pub struct WarningContent { - pub kind: WarningKind, - pub text: String, - pub pending_confirm: bool, -} - -impl Component for WarningContent { - fn height(&self, width: u16) -> u16 { - let display_text = if self.pending_confirm { - "Press Enter again to run this dangerous command" - } else { - self.text.as_str() - }; - line_count_wrapped(display_text, width as usize) - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); - let display_text = if self.pending_confirm { - "Press Enter again to run this dangerous command" - } else { - self.text.as_str() - }; - frame.render_widget( - Paragraph::new(display_text) - .style(style) - .wrap(Wrap { trim: false }), - area, - ); - } -} - -/// Animated spinner with status text. -pub struct SpinnerContent { - pub status_text: String, -} - -impl Component for SpinnerContent { - fn height(&self, _width: u16) -> u16 { - 1 - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); - frame.render_widget(Paragraph::new(self.status_text.as_str()).style(style), area); - } -} - -/// Tool call progress (in-flight spinner or completed checkmark). -pub struct ToolStatusContent { - pub completed_count: usize, - pub current_label: Option<String>, - pub frame: usize, -} - -impl Component for ToolStatusContent { - fn height(&self, _width: u16) -> u16 { - 1 - } - - fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderContext) { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); - let text = if let Some(ref label) = self.current_label { - if self.completed_count > 0 { - format!( - "{} (used {} tool{})", - label, - self.completed_count, - if self.completed_count == 1 { "" } else { "s" } - ) - } else { - label.clone() - } - } else { - format!( - "Used {} tool{}", - self.completed_count, - if self.completed_count == 1 { "" } else { "s" } - ) - }; - frame.render_widget(Paragraph::new(text).style(style), area); - } -} - -// --------------------------------------------------------------------------- -// Factory functions -// --------------------------------------------------------------------------- - -/// Convert a view model `Content` item into a `SymbolRow`-wrapped component. -fn content_to_component(content: &Content) -> Box<dyn Component> { - match content { - Content::Input { text, active, .. } => Box::new(SymbolRow { - symbol: ">".to_string(), - symbol_meaning: Meaning::Guidance, - inner: Box::new(InputContent { - text: text.clone(), - active: *active, - }), - }), - - Content::Command { text, faded } => Box::new(SymbolRow { - symbol: "$".to_string(), - symbol_meaning: Meaning::Important, - inner: Box::new(CommandContent { - text: text.clone(), - faded: *faded, - }), - }), - - Content::Text { markdown } => Box::new(SymbolRow { - symbol: " ".to_string(), - symbol_meaning: Meaning::Base, - inner: Box::new(TextContent { - markdown: markdown.clone(), - }), - }), - - Content::Error { message } => Box::new(SymbolRow { - symbol: "!".to_string(), - symbol_meaning: Meaning::AlertError, - inner: Box::new(ErrorContent { - message: message.clone(), - }), - }), - - Content::Warning { - kind, - text, - pending_confirm, - } => { - let (symbol, meaning) = match kind { - WarningKind::Danger => ("!", Meaning::AlertError), - WarningKind::LowConfidence => ("?", Meaning::AlertWarn), - }; - Box::new(SymbolRow { - symbol: symbol.to_string(), - symbol_meaning: meaning, - inner: Box::new(WarningContent { - kind: *kind, - text: text.clone(), - pending_confirm: *pending_confirm, - }), - }) - } - - Content::Spinner { frame, status_text } => Box::new(SymbolRow { - symbol: active_frame(*frame).to_string(), - symbol_meaning: Meaning::Annotation, - inner: Box::new(SpinnerContent { - status_text: status_text.clone(), - }), - }), - - Content::ToolStatus { - completed_count, - current_label, - frame, - } => { - let symbol = if current_label.is_some() { - active_frame(*frame).to_string() - } else { - "\u{2713}".to_string() // ✓ - }; - Box::new(SymbolRow { - symbol, - symbol_meaning: Meaning::Annotation, - inner: Box::new(ToolStatusContent { - completed_count: *completed_count, - current_label: current_label.clone(), - frame: *frame, - }), - }) - } - } -} - -/// Convert a view model `Block` into a `VStack` of content components. -fn build_block_component(block: &Block) -> Box<dyn Component> { - let mut children: Vec<Box<dyn Component>> = Vec::new(); - - for (idx, content) in block.content.iter().enumerate() { - if idx > 0 { - children.push(Box::new(Spacer(1))); // blank line between items - } - children.push(content_to_component(content)); - } - - // Trailing blank line (padding after content) - children.push(Box::new(Spacer(1))); - - Box::new(VStack::new(children)) -} - -/// Build the full component tree from an ordered list of view model blocks. -/// -/// The tree is a `VStack` with blocks separated by `Separator` + `Spacer` pairs. -/// The caller sets `scroll_offset` on the returned `VStack` before rendering. -pub fn build_component_tree(items: &[&Block], card_width: u16) -> VStack { - let mut children: Vec<Box<dyn Component>> = Vec::new(); - - for (idx, block) in items.iter().enumerate() { - if idx > 0 { - children.push(Box::new(Separator { card_width })); - children.push(Box::new(Spacer(1))); // leading blank after separator - } - children.push(build_block_component(block)); - } - - VStack::new(children) -} diff --git a/crates/atuin-ai/src/tui/components/atuin_ai.rs b/crates/atuin-ai/src/tui/components/atuin_ai.rs new file mode 100644 index 00000000..680b93ed --- /dev/null +++ b/crates/atuin-ai/src/tui/components/atuin_ai.rs @@ -0,0 +1,140 @@ +//! Top-level AtuinAi component that translates key events into AiTuiEvents. +//! +//! This component wraps the entire view and handles key events that bubble up +//! from child components (or aren't consumed by them). It maps raw key events +//! to semantic `AiTuiEvent` variants based on the current `AppMode`. + +use std::sync::mpsc; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use eye_declare::{Component, EventResult, Hooks, impl_slot_children}; + +use crate::tui::events::AiTuiEvent; +use crate::tui::state::AppMode; + +/// Top-level wrapper component for the AI TUI. +/// +/// Props carry the current mode so `handle_event` can translate keys +/// into the right `AiTuiEvent`. Children are rendered via slot children. +pub struct AtuinAi { + pub mode: AppMode, + pub has_command: bool, + pub is_input_blank: bool, + pub pending_confirmation: bool, +} + +impl Default for AtuinAi { + fn default() -> Self { + Self { + mode: AppMode::Input, + has_command: false, + is_input_blank: false, + pending_confirmation: false, + } + } +} + +impl_slot_children!(AtuinAi); + +#[derive(Default)] +pub struct AtuinAiState { + tx: Option<mpsc::Sender<AiTuiEvent>>, +} + +impl Component for AtuinAi { + type State = AtuinAiState; + + fn initial_state(&self) -> Option<Self::State> { + Some(AtuinAiState::default()) + } + + fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) { + hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| { + state.tx = tx.cloned(); + }); + } + + fn render( + &self, + _area: ratatui::layout::Rect, + _buf: &mut ratatui::buffer::Buffer, + _state: &Self::State, + ) { + // Rendering is handled by slot children + } + + fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 { + 0 + } + + fn handle_event(&self, event: &Event, state: &mut Self::State) -> EventResult { + let Event::Key(KeyEvent { + code, + kind: KeyEventKind::Press, + modifiers, + .. + }) = event + else { + return EventResult::Ignored; + }; + + let Some(ref tx) = state.tx else { + return EventResult::Ignored; + }; + + // Ctrl+C always exits + if modifiers.contains(KeyModifiers::CONTROL) && *code == KeyCode::Char('c') { + let _ = tx.send(AiTuiEvent::Exit); + return EventResult::Consumed; + } + + match self.mode { + AppMode::Input => match code { + KeyCode::Esc => { + if self.pending_confirmation { + let _ = tx.send(AiTuiEvent::CancelConfirmation); + return EventResult::Consumed; + } + + let _ = tx.send(AiTuiEvent::Exit); + EventResult::Consumed + } + KeyCode::Tab => { + if self.has_command && self.is_input_blank { + let _ = tx.send(AiTuiEvent::InsertCommand); + return EventResult::Consumed; + } + + EventResult::Ignored + } + KeyCode::Enter => { + if self.has_command && self.is_input_blank { + let _ = tx.send(AiTuiEvent::ExecuteCommand); + return EventResult::Consumed; + } + + EventResult::Ignored + } + _ => EventResult::Ignored, + }, + AppMode::Generating | AppMode::Streaming => match code { + KeyCode::Esc => { + let _ = tx.send(AiTuiEvent::CancelGeneration); + EventResult::Consumed + } + _ => EventResult::Ignored, + }, + AppMode::Error => match code { + KeyCode::Esc => { + let _ = tx.send(AiTuiEvent::Exit); + EventResult::Consumed + } + KeyCode::Enter | KeyCode::Char('r') => { + let _ = tx.send(AiTuiEvent::Retry); + EventResult::Consumed + } + _ => EventResult::Ignored, + }, + } + } +} diff --git a/crates/atuin-ai/src/tui/components/input_box.rs b/crates/atuin-ai/src/tui/components/input_box.rs new file mode 100644 index 00000000..fd8132f4 --- /dev/null +++ b/crates/atuin-ai/src/tui/components/input_box.rs @@ -0,0 +1,229 @@ +//! Bordered input box component for the AI TUI. +//! +//! Wraps tui-textarea's TextArea, which handles rendering, wrapping, cursor +//! positioning, and height measurement natively. The component configures the +//! TextArea's block (border + titles) and forwards events to it. +//! +//! On Enter, sends `AiTuiEvent::SubmitInput` via the context-provided channel. + +use std::sync::{Mutex, mpsc}; + +use crossterm::event::KeyModifiers; +use eye_declare::{Component, EventResult, Hooks}; +use ratatui::widgets::{Block, Borders, Padding}; +use ratatui_core::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::Line, + widgets::Widget, +}; +use tui_textarea::TextArea; + +use crate::tui::events::AiTuiEvent; + +/// A bordered text input box backed by tui-textarea. +/// +/// Props configure the chrome (title, footer). The TextArea itself lives +/// in the component's State so it owns cursor, wrapping, and rendering. +#[derive(Default)] +pub struct InputBox { + /// Title shown in top-left border + pub title: String, + /// Right-side label in top border + pub title_right: String, + /// Footer text shown in bottom border (keybinding hints) + pub footer: String, + /// Whether the input is currently active (shows cursor, accepts input) + pub active: bool, +} + +pub struct InputBoxState { + textarea: Mutex<TextArea<'static>>, + tx: Option<mpsc::Sender<AiTuiEvent>>, +} + +impl Default for InputBoxState { + fn default() -> Self { + let mut textarea = TextArea::default(); + textarea.set_cursor_line_style(ratatui::style::Style::default()); + textarea.set_wrap_mode(tui_textarea::WrapMode::Word); + textarea.set_placeholder_text("Type a message..."); + textarea.set_placeholder_style( + ratatui::style::Style::default() + .fg(ratatui::style::Color::DarkGray) + .add_modifier(ratatui::style::Modifier::ITALIC), + ); + Self { + textarea: Mutex::new(textarea), + tx: None, + } + } +} + +impl InputBox { + /// Build the ratatui Block with current titles/footer. + fn make_block(&self) -> Block<'_> { + let border_style = Style::default().fg(Color::DarkGray); + let title_style = Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::BOLD); + + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); + + if !self.title.is_empty() { + block = block + .title_top(Line::styled(format!(" {} ", self.title), title_style).left_aligned()); + } + if !self.title_right.is_empty() { + block = block.title_top( + Line::styled(format!(" {} ", self.title_right), border_style).right_aligned(), + ); + } + if !self.footer.is_empty() { + block = block.title_bottom( + Line::styled(format!(" {} ", self.footer), border_style).right_aligned(), + ); + } + + block + } +} + +impl Component for InputBox { + type State = InputBoxState; + + fn initial_state(&self) -> Option<InputBoxState> { + Some(InputBoxState::default()) + } + + fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) { + if self.active { + hooks.use_autofocus(); + } + hooks.use_context::<mpsc::Sender<AiTuiEvent>>(|tx, state| { + state.tx = tx.cloned(); + }); + } + + fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) { + if area.height < 3 || area.width < 4 { + return; + } + // Configure the block on each render so titles/footer stay current. + // Note: set_block takes ownership, but the block is cheap to rebuild. + // We can't call set_block here since we only have &self/&state, + // so we render block + textarea separately. + let block = self.make_block(); + let inner = block.inner(area); + block.render(area, buf); + + let mut textarea = state.textarea.lock().unwrap(); + if self.active { + textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED)); + textarea.set_placeholder_text("Type a message..."); + } else { + textarea.set_cursor_style(Style::default()); + textarea.set_placeholder_text(""); + } + + // Render textarea into the inner area + textarea.render(inner, buf); + } + + fn desired_height(&self, width: u16, state: &Self::State) -> u16 { + if width < 4 { + return 3; + } + // TextArea handles scrolling internally if content overflows. + let block = self.make_block(); + let inner = block.inner(Rect::new(0, 0, width, u16::MAX)); + let chrome = (u16::MAX).saturating_sub(inner.height); + let content = state.textarea.lock().unwrap().measure(width - 4); + chrome + content.preferred_rows + } + + fn is_focusable(&self, _state: &Self::State) -> bool { + self.active + } + + fn handle_event( + &self, + event: &crossterm::event::Event, + state: &mut Self::State, + ) -> EventResult { + if !self.active { + return EventResult::Ignored; + } + + if let crossterm::event::Event::Paste(text) = event { + let mut textarea = state.textarea.lock().unwrap(); + textarea.insert_str(text); + return EventResult::Consumed; + } + + if let crossterm::event::Event::Key(key) = event { + if key.kind != crossterm::event::KeyEventKind::Press { + return EventResult::Ignored; + } + + // Let Ctrl+C bubble up to AtuinAi for exit handling + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == crossterm::event::KeyCode::Char('c') + { + return EventResult::Ignored; + } + + let mut textarea = state.textarea.lock().unwrap(); + + match key.code { + crossterm::event::KeyCode::Char('j') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + textarea.insert_newline(); + return EventResult::Consumed; + } + crossterm::event::KeyCode::Enter => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + textarea.insert_newline(); + return EventResult::Consumed; + } else { + let text = textarea.lines().join("\n"); + textarea.clear(); + + if text.trim().is_empty() { + return EventResult::Ignored; + } + + if let Some(ref tx) = state.tx { + let _ = tx.send(AiTuiEvent::SubmitInput(text)); + } + return EventResult::Consumed; + } + } + crossterm::event::KeyCode::Tab => { + return EventResult::Ignored; + } + // Esc: bubble up to app + crossterm::event::KeyCode::Esc => { + return EventResult::Ignored; + } + _ => {} + } + + // All other keys: forward to textarea. + // tui-textarea can convert crossterm events itself. + textarea.input(*key); + + if let Some(ref tx) = state.tx { + let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n"))); + } + return EventResult::Consumed; + } + + EventResult::Ignored + } +} diff --git a/crates/atuin-ai/src/tui/components/markdown.rs b/crates/atuin-ai/src/tui/components/markdown.rs new file mode 100644 index 00000000..e1551a7f --- /dev/null +++ b/crates/atuin-ai/src/tui/components/markdown.rs @@ -0,0 +1,213 @@ +//! Markdown rendering component using pulldown-cmark. +//! +//! More robust than eye-declare's built-in Markdown component: +//! uses a proper CommonMark parser rather than line-by-line regex. + +use eye_declare::Component; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use ratatui_core::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::Widget, +}; +use ratatui_widgets::paragraph::{Paragraph, Wrap}; + +/// A markdown rendering component backed by pulldown-cmark. +#[derive(Default)] +pub struct Markdown { + pub source: String, +} + +impl Markdown { + pub fn new(source: impl Into<String>) -> Self { + Self { + source: source.into(), + } + } +} + +/// Style configuration for markdown rendering. +pub struct MarkdownStyles { + pub base: Style, + pub code_inline: Style, + pub code_block: Style, + pub bold: Style, + pub italic: Style, + pub heading: Style, +} + +impl MarkdownStyles { + pub fn new() -> Self { + let base = Style::default(); + Self { + base, + code_inline: Style::default().fg(Color::Yellow), + code_block: Style::default().fg(Color::Green), + bold: base.add_modifier(Modifier::BOLD), + italic: base.add_modifier(Modifier::ITALIC), + heading: Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + } + } +} + +impl Default for MarkdownStyles { + fn default() -> Self { + Self::new() + } +} + +impl Component for Markdown { + type State = MarkdownStyles; + + fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) { + if self.source.is_empty() || area.width == 0 || area.height == 0 { + return; + } + let text = parse_markdown(&self.source, state); + Paragraph::new(text) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn desired_height(&self, width: u16, state: &Self::State) -> u16 { + if self.source.is_empty() || width == 0 { + return 0; + } + let text = parse_markdown(&self.source, state); + Paragraph::new(text) + .wrap(Wrap { trim: false }) + .line_count(width) as u16 + } + + fn initial_state(&self) -> Option<MarkdownStyles> { + Some(MarkdownStyles::new()) + } +} + +/// Parse markdown source into styled ratatui Text using pulldown-cmark. +fn parse_markdown<'a>(source: &'a str, styles: &'a MarkdownStyles) -> Text<'static> { + let parser = Parser::new(source); + let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()]; + let mut current_line = 0; + + let mut style_stack: Vec<Style> = vec![styles.base]; + let mut in_code_block = false; + + for event in parser { + match event { + Event::Start(Tag::Strong) => { + let bold = style_stack + .last() + .copied() + .unwrap_or(styles.base) + .add_modifier(Modifier::BOLD); + style_stack.push(bold); + } + Event::End(TagEnd::Strong) => { + style_stack.pop(); + } + Event::Start(Tag::Emphasis) => { + let italic = style_stack + .last() + .copied() + .unwrap_or(styles.base) + .add_modifier(Modifier::ITALIC); + style_stack.push(italic); + } + Event::End(TagEnd::Emphasis) => { + style_stack.pop(); + } + Event::Start(Tag::CodeBlock(_)) => { + in_code_block = true; + if !lines[current_line].is_empty() { + current_line += 1; + lines.push(Vec::new()); + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::CodeBlock) => { + in_code_block = false; + if !lines[current_line].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + } + Event::Code(code) => { + lines[current_line].push(Span::styled(format!("{}", code), styles.code_inline)); + } + Event::Text(text) => { + let current_style = if in_code_block { + styles.code_block + } else { + style_stack.last().copied().unwrap_or(styles.base) + }; + let prefix = if in_code_block { " " } else { "" }; + let parts: Vec<&str> = text.split('\n').collect(); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + current_line += 1; + lines.push(Vec::new()); + } + if !part.is_empty() { + lines[current_line] + .push(Span::styled(format!("{}{}", prefix, part), current_style)); + } + } + } + Event::SoftBreak => { + let current_style = style_stack.last().copied().unwrap_or(styles.base); + lines[current_line].push(Span::styled(" ", current_style)); + } + Event::HardBreak => { + current_line += 1; + lines.push(Vec::new()); + } + Event::Start(Tag::Paragraph) => { + if current_line > 0 || !lines[0].is_empty() { + // Two line advances: one to end the current line, one for a blank separator. + current_line += 1; + lines.push(Vec::new()); + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::Paragraph) => {} + Event::Start(Tag::Heading { .. }) => { + if current_line > 0 || !lines[0].is_empty() { + current_line += 1; + lines.push(Vec::new()); + current_line += 1; + lines.push(Vec::new()); + } + style_stack.push(styles.heading); + } + Event::End(TagEnd::Heading(_)) => { + style_stack.pop(); + } + Event::Start(Tag::Item) => { + if current_line > 0 || !lines[0].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + lines[current_line].push(Span::styled("- ", Style::default().fg(Color::DarkGray))); + } + Event::End(TagEnd::Item) => {} + Event::Start(Tag::List(_)) => { + if current_line > 0 || !lines[0].is_empty() { + current_line += 1; + lines.push(Vec::new()); + } + } + Event::End(TagEnd::List(_)) => {} + _ => {} + } + } + + let text_lines: Vec<Line<'static>> = lines.into_iter().map(Line::from).collect(); + Text::from(text_lines) +} diff --git a/crates/atuin-ai/src/tui/components/mod.rs b/crates/atuin-ai/src/tui/components/mod.rs new file mode 100644 index 00000000..2f684f5f --- /dev/null +++ b/crates/atuin-ai/src/tui/components/mod.rs @@ -0,0 +1,3 @@ +pub mod atuin_ai; +pub mod input_box; +pub mod markdown; diff --git a/crates/atuin-ai/src/tui/content/help.md b/crates/atuin-ai/src/tui/content/help.md new file mode 100644 index 00000000..654aea40 --- /dev/null +++ b/crates/atuin-ai/src/tui/content/help.md @@ -0,0 +1,3 @@ +Welcome to Atuin AI, an AI assistant in your terminal. You can ask it to generate a shell command for you, or ask general terminal or software questions. + +For more information, see [https://docs.atuin.sh/cli/ai/introduction/](https://docs.atuin.sh/cli/ai/introduction/) diff --git a/crates/atuin-ai/src/tui/event.rs b/crates/atuin-ai/src/tui/event.rs deleted file mode 100644 index 8efbf522..00000000 --- a/crates/atuin-ai/src/tui/event.rs +++ /dev/null @@ -1,303 +0,0 @@ -use crate::tui::App; -use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind}; -use eyre::{Result, eyre}; -use futures::StreamExt; -use std::time::Duration; -use tokio::time; - -/// Base tick interval for the event loop (fast for responsive streaming) -const BASE_TICK_INTERVAL: Duration = Duration::from_millis(50); - -/// Application events that drive the TUI state machine. -/// -/// # Event Types -/// - `Key`: Keyboard input (filtered to KeyEventKind::Press only) -/// - `Tick`: Periodic event for updates (50ms base interval) -/// - `Resize`: Terminal window resize -/// - `StreamChunk/StreamDone/StreamError`: Placeholders for Phase 3 streaming -/// -/// # Design Decisions -/// - Fast 50ms base tick for responsive streaming; spinner timing handled in AppState -/// - Stream events are placeholders - will be wired to channels in Phase 3 -/// - Resize handling enables responsive layout adjustments -#[derive(Debug, Clone)] -pub enum AppEvent { - /// Keyboard input event (filtered to Press events only) - Key(KeyEvent), - - /// Periodic tick for updates (50ms base interval; spinner timing in AppState) - Tick, - - /// Terminal resize event (width, height) - Resize(u16, u16), - - /// Stream chunk received (Phase 3 placeholder) - StreamChunk(String), - - /// Stream completed successfully (Phase 3 placeholder) - StreamDone, - - /// Stream error occurred (Phase 3 placeholder) - StreamError(String), -} - -/// Async event loop that drives the TUI with prioritized event handling. -/// -/// # Priority Model (Biased Select) -/// 1. **Stream data** - Highest priority (future Phase 3 streaming) -/// 2. **Keyboard input** - Medium priority (user responsiveness) -/// 3. **Tick events** - Lowest priority (spinner animation) -/// -/// This ensures stream data is processed immediately when available, -/// keyboard input is responsive, and spinner updates don't block higher priority events. -/// -/// # Graceful Shutdown -/// - SIGINT (Ctrl+C) sets shutdown flag and breaks the loop -/// - EventStream close (stdin EOF) triggers shutdown -/// - Shutdown flag can be checked/set externally for controlled termination -/// -/// # Example -/// ```no_run -/// use atuin_ai::tui::EventLoop; -/// -/// # async fn example() -> eyre::Result<()> { -/// let mut event_loop = EventLoop::new(); -/// loop { -/// let event = event_loop.run().await?; -/// // Handle event... -/// # break; -/// } -/// # Ok(()) -/// # } -/// ``` -pub struct EventLoop { - /// Tick interval timer (created lazily on first run) - tick_timer: Option<time::Interval>, - - /// Flag indicating a render was requested (future use in Phase 2) - #[allow(dead_code)] - render_requested: bool, - - /// Shutdown flag - when true, event loop will terminate - shutdown: bool, -} - -impl EventLoop { - /// Create a new EventLoop with default settings. - /// - /// # Defaults - /// - Tick interval: 50ms base rate (spinner timing handled separately in AppState) - /// - Render requested: false - /// - Shutdown: false - pub fn new() -> Self { - Self { - tick_timer: None, - render_requested: false, - shutdown: false, - } - } - - /// Run the event loop, returning the next application event. - /// - /// # Priority Model - /// Uses `tokio::select!` with `biased;` mode to enforce priority: - /// 1. Stream data (placeholder for Phase 3) - /// 2. Keyboard input with rapid keypress batching - /// 3. Tick for spinner animation - /// - /// # Keyboard Handling - /// - Filters to KeyEventKind::Press on all platforms for safety - /// - Batching of rapid keypresses will be implemented in Phase 2 - /// - Currently returns individual key events - /// - /// # Graceful Shutdown - /// - SIGINT (Ctrl+C) triggers shutdown and returns last event - /// - EventStream close (stdin EOF) triggers shutdown - /// - Shutdown flag can be checked after this returns - /// - /// # Errors - /// - Returns error if terminal event stream encounters an error - /// - EventStream close is handled gracefully as shutdown signal - /// - /// # Example - /// ```no_run - /// # use atuin_ai::tui::EventLoop; - /// # async fn example() -> eyre::Result<()> { - /// let mut event_loop = EventLoop::new(); - /// while !event_loop.is_shutdown() { - /// match event_loop.run().await? { - /// // Handle events... - /// # _ => break, - /// } - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn run(&mut self) -> Result<AppEvent> { - // Create async event stream for keyboard/terminal events - let mut reader = EventStream::new(); - - // Get or create the tick timer (reused across calls to maintain timing) - // Uses fast base tick for responsive streaming; spinner timing handled in AppState - let tick_timer = self.tick_timer.get_or_insert_with(|| { - let mut interval = time::interval(BASE_TICK_INTERVAL); - // Skip the first immediate tick - interval.reset(); - interval - }); - - loop { - if self.shutdown { - break; - } - - // Biased select: prioritize stream > keyboard > tick - let event = tokio::select! { - biased; - - // Priority 1: Stream data (placeholder for Phase 3) - // In Phase 3, this will be: - // Some(chunk) = stream_rx.recv() => { ... } - - // Priority 2: Keyboard input - maybe_event = reader.next() => { - match maybe_event { - Some(Ok(Event::Key(key))) => { - // Filter to Press events only for cross-platform safety - if key.kind == KeyEventKind::Press { - // Note: Rapid keypress batching will be implemented in Phase 2 - // when we integrate with the state machine. - // For now, just return individual key events. - Some(AppEvent::Key(key)) - } else { - None - } - } - Some(Ok(Event::Resize(w, h))) => { - Some(AppEvent::Resize(w, h)) - } - Some(Err(e)) => { - return Err(eyre!("terminal event error: {}", e)); - } - None => { - // EventStream closed (stdin EOF) - trigger shutdown - self.shutdown = true; - None - } - _ => { - // Ignore other event types (mouse, focus, etc.) - None - } - } - } - - // Priority 3: Tick for spinner animation - _ = tick_timer.tick() => { - Some(AppEvent::Tick) - } - - // SIGINT handling (Ctrl+C) - cross-platform - _ = tokio::signal::ctrl_c() => { - self.shutdown = true; - // Return one more event to allow graceful shutdown handling - Some(AppEvent::Tick) - } - }; - - if let Some(app_event) = event { - return Ok(app_event); - } - } - - // Loop exited due to shutdown - return final tick to allow cleanup - Ok(AppEvent::Tick) - } - - /// Check if the event loop has been signaled to shut down. - /// - /// This can be used to cleanly exit the main TUI loop after receiving - /// a shutdown signal (Ctrl+C, stdin close, etc.) - pub fn is_shutdown(&self) -> bool { - self.shutdown - } - - /// Signal the event loop to shut down. - /// - /// The shutdown will take effect on the next iteration of `run()`. - pub fn shutdown(&mut self) { - self.shutdown = true; - } - - /// Poll for next event and apply to app state. - /// - /// This is a convenience method that combines `run()` with `App` state updates. - /// Returns true if app should continue, false if should exit. - /// - /// # Example - /// ```no_run - /// # use atuin_ai::tui::{EventLoop, App}; - /// # async fn example() -> eyre::Result<()> { - /// let mut event_loop = EventLoop::new(); - /// let mut app = App::new(); - /// - /// while event_loop.poll_and_apply(&mut app).await? { - /// // Render app state... - /// } - /// # Ok(()) - /// # } - /// ``` - pub async fn poll_and_apply(&mut self, app: &mut App) -> Result<bool> { - let event = self.run().await?; - - match event { - AppEvent::Key(key) => { - app.handle_key(key); - } - AppEvent::Tick => { - app.state.tick(); - } - AppEvent::Resize(_, _) => { - // Render will be triggered anyway - } - AppEvent::StreamChunk(_) | AppEvent::StreamDone | AppEvent::StreamError(_) => { - // Placeholder for Phase 3 - } - } - - Ok(!app.state.should_exit) - } -} - -impl Default for EventLoop { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_event_loop_creation() { - let event_loop = EventLoop::new(); - assert!(!event_loop.shutdown); - } - - #[test] - fn test_shutdown_flag() { - let mut event_loop = EventLoop::new(); - assert!(!event_loop.is_shutdown()); - - event_loop.shutdown(); - assert!(event_loop.is_shutdown()); - } - - // Note: Cannot easily test run() in unit tests since it requires a TTY. - // Integration tests should verify: - // 1. Tick events are generated at 150ms intervals - // 2. Keyboard events are properly filtered to Press only - // 3. Rapid keypresses are batched - // 4. SIGINT triggers graceful shutdown - // 5. Resize events are propagated correctly -} diff --git a/crates/atuin-ai/src/tui/events.rs b/crates/atuin-ai/src/tui/events.rs new file mode 100644 index 00000000..a791bb80 --- /dev/null +++ b/crates/atuin-ai/src/tui/events.rs @@ -0,0 +1,27 @@ +/// Application-domain events emitted by UI components. +/// +/// Components translate raw key events into these semantic events, +/// which are sent via an `mpsc::Sender<AiTuiEvent>` provided through +/// eye-declare's context system. The main event loop in `inline.rs` +/// receives them and mutates `AppState` accordingly. +#[derive(Debug)] +pub enum AiTuiEvent { + /// User updated the input text + InputUpdated(String), + /// User submitted text input (Enter in Input mode) + SubmitInput(String), + /// User entered a slash command (e.g. "/help") + SlashCommand(String), + /// Cancel active generation or streaming (Esc during Generating/Streaming) + CancelGeneration, + /// Execute the suggested command + ExecuteCommand, + /// Insert command without executing + InsertCommand, + /// Cancel confirmation of dangerous command + CancelConfirmation, + /// Retry after error + Retry, + /// Exit the application + Exit, +} diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs index 6df3d08f..acb251a7 100644 --- a/crates/atuin-ai/src/tui/mod.rs +++ b/crates/atuin-ai/src/tui/mod.rs @@ -1,18 +1,6 @@ -pub mod app; -pub mod component; pub mod components; -pub mod event; -#[cfg(unix)] -pub mod popup; -pub mod render; -pub mod spinner; +pub mod events; pub mod state; -pub mod terminal; -pub mod view_model; +pub mod view; -pub use app::App; -pub use event::{AppEvent, EventLoop}; -pub use render::{RenderContext, calculate_needed_height, markdown_to_spans}; pub use state::{AppMode, AppState, ConversationEvent, ExitAction}; -pub use terminal::{TerminalGuard, install_panic_hook}; -pub use view_model::{Block, Blocks, Content}; diff --git a/crates/atuin-ai/src/tui/popup.rs b/crates/atuin-ai/src/tui/popup.rs deleted file mode 100644 index c62b0e62..00000000 --- a/crates/atuin-ai/src/tui/popup.rs +++ /dev/null @@ -1,363 +0,0 @@ -use ratatui::layout::Rect; - -/// Maximum popup height (lines). Keeps context visible around the popup. -const MAX_POPUP_HEIGHT: u16 = 24; - -/// Minimum usable popup height. -const MIN_POPUP_HEIGHT: u16 = 5; - -/// Initial popup height — just enough for input + a small response. -const INITIAL_POPUP_HEIGHT: u16 = 5; - -/// Margin around the card in popup mode. -pub(crate) const POPUP_MARGIN: u16 = 0; - -/// Screen state captured from atuin-hex's screen server. -pub struct SavedScreen { - #[allow(dead_code)] - pub rows: u16, - #[allow(dead_code)] - pub cols: u16, - pub cursor_row: u16, - pub cursor_col: u16, - /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. - pub rows_data: Vec<Vec<u8>>, -} - -/// Popup mode state: saved screen + computed placement. -pub struct PopupState { - pub saved_screen: SavedScreen, - /// Maximum rect computed from placement (the ceiling for growth). - pub max_rect: Rect, - /// Current rect — starts small, grows as content arrives. - pub current_rect: Rect, - pub scroll_offset: u16, - /// True when the popup renders above the cursor (input at bottom of card). - pub render_above: bool, -} - -impl PopupState { - /// Resize the popup to fit `needed` lines of content. - /// - /// Grows or shrinks the popup as needed (clamped to max_rect / INITIAL_POPUP_HEIGHT). - /// When growing, clears the new rect area. When shrinking, restores freed rows - /// from the saved screen data. - /// - /// Returns `Some(new_rect)` if the size changed (caller must resize terminal), - /// or `None` if no change is needed. - pub fn fit_to(&mut self, needed: u16) -> Option<Rect> { - let new_height = needed.clamp(INITIAL_POPUP_HEIGHT, self.max_rect.height); - if new_height == self.current_rect.height { - return None; - } - - let old_rect = self.current_rect; - let growing = new_height > old_rect.height; - - if self.render_above { - let new_y = self.max_rect.y + self.max_rect.height - new_height; - self.current_rect = Rect::new(old_rect.x, new_y, old_rect.width, new_height); - } else { - self.current_rect = Rect::new(old_rect.x, old_rect.y, old_rect.width, new_height); - } - - if growing { - // Clear the entire new rect so the new Terminal doesn't leave - // ghost content from the old card. - self.clear_rows( - self.current_rect.y, - self.current_rect.y + self.current_rect.height, - ); - } else { - // Shrinking: restore freed rows from saved screen data, then - // clear the new (smaller) rect for the re-rendered card. - self.restore_rows(&old_rect); - self.clear_rows( - self.current_rect.y, - self.current_rect.y + self.current_rect.height, - ); - } - - Some(self.current_rect) - } - - /// Clear a range of terminal rows within the popup width. - fn clear_rows(&self, from_row: u16, to_row: u16) { - use crossterm::cursor::MoveTo; - use crossterm::execute; - use crossterm::style::{Attribute, SetAttribute}; - use std::io::{Write, stdout}; - - let mut out = stdout(); - for row in from_row..to_row { - let _ = execute!( - out, - MoveTo(self.current_rect.x, row), - SetAttribute(Attribute::Reset) - ); - let _ = write!( - out, - "{:width$}", - "", - width = self.current_rect.width as usize - ); - } - let _ = out.flush(); - } - - /// Restore rows that were freed by shrinking — the rows in old_rect - /// that are no longer covered by current_rect. - fn restore_rows(&self, old_rect: &Rect) { - use crossterm::cursor::MoveTo; - use crossterm::execute; - use crossterm::style::{Attribute, SetAttribute}; - use std::io::{Write, stdout}; - - let mut out = stdout(); - - // Determine which rows are freed - let (freed_start, freed_end) = if self.render_above { - // Shrinking from above: freed rows are at the old top - (old_rect.y, self.current_rect.y) - } else { - // Shrinking from below: freed rows are at the old bottom - ( - self.current_rect.y + self.current_rect.height, - old_rect.y + old_rect.height, - ) - }; - - for row in freed_start..freed_end { - let source_row = (row + self.scroll_offset) as usize; - - // Clear the popup region - let _ = execute!(out, MoveTo(old_rect.x, row), SetAttribute(Attribute::Reset),); - let _ = write!(out, "{:width$}", "", width = old_rect.width as usize); - - // Write back saved row data from column 0 - let _ = execute!(out, MoveTo(0, row)); - if let Some(row_bytes) = self.saved_screen.rows_data.get(source_row) { - let _ = out.write_all(row_bytes); - } - } - let _ = out.flush(); - } -} - -/// Try to set up popup overlay mode. -/// -/// Checks for `ATUIN_HEX_SOCKET`, fetches screen state, computes placement, -/// and scrolls the terminal if needed. Returns `None` if popup mode is not -/// available (no socket, fetch failed, etc.), in which case the caller should -/// fall back to inline mode. -pub fn try_setup_popup() -> Option<PopupState> { - use std::io::Write; - - let socket_path = std::env::var("ATUIN_HEX_SOCKET").ok()?; - let saved = fetch_screen_state(&socket_path)?; - - let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((saved.cols, saved.rows)); - // Full-width popup with margin for visual separation - let popup_width = term_cols; - let (rect, scroll, render_above) = compute_popup_placement( - saved.cursor_row, - saved.cursor_col, - term_rows, - term_cols, - popup_width, - ); - - // Scroll terminal up if needed to make room for the popup - if scroll > 0 { - let mut stdout = std::io::stdout(); - let _ = crossterm::execute!(stdout, crossterm::cursor::MoveTo(0, term_rows - 1)); - for _ in 0..scroll { - let _ = writeln!(stdout); - } - let _ = stdout.flush(); - } - - // Start with a small rect that grows as content arrives - let initial_height = INITIAL_POPUP_HEIGHT.min(rect.height); - let current_rect = if render_above { - // Anchor at the bottom of max_rect (near cursor), grow upward - Rect::new( - rect.x, - rect.y + rect.height - initial_height, - rect.width, - initial_height, - ) - } else { - // Anchor at the top of max_rect (near cursor), grow downward - Rect::new(rect.x, rect.y, rect.width, initial_height) - }; - - Some(PopupState { - saved_screen: saved, - max_rect: rect, - current_rect, - scroll_offset: scroll, - render_above, - }) -} - -/// Restore the screen area that was covered by the popup. -/// -/// Clears the popup region, then writes pre-formatted per-row ANSI bytes from -/// column 0 to correctly restore wide characters, colors, and all attributes. -pub fn restore(state: &PopupState) { - use crossterm::cursor::MoveTo; - use crossterm::execute; - use crossterm::style::{Attribute, SetAttribute}; - use std::io::{Write, stdout}; - - let saved = &state.saved_screen; - let popup_rect = state.current_rect; - let scroll_offset = state.scroll_offset; - - let mut stdout = stdout(); - - for dy in 0..popup_rect.height { - let target_row = popup_rect.y + dy; - let source_row = (target_row + scroll_offset) as usize; - - // Clear only the popup region with spaces - let _ = execute!( - stdout, - MoveTo(popup_rect.x, target_row), - SetAttribute(Attribute::Reset), - ); - let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize); - - // Write back full row ANSI data from column 0 - let _ = execute!(stdout, MoveTo(0, target_row)); - if let Some(row_bytes) = saved.rows_data.get(source_row) { - let _ = stdout.write_all(row_bytes); - } - } - - // Restore cursor position (adjusted for any scrolling) - let _ = execute!( - stdout, - MoveTo( - saved.cursor_col, - saved.cursor_row.saturating_sub(scroll_offset) - ) - ); - let _ = stdout.flush(); -} - -/// Connect to atuin-hex's Unix socket and fetch the current screen state. -/// -/// The wire format is: -/// ```text -/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] -/// [row_0_len: u32 BE][row_0_bytes...] -/// [row_1_len: u32 BE][row_1_bytes...] -/// ... -/// ``` -fn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> { - use std::io::Read; - use std::os::unix::net::UnixStream; - use std::time::Duration; - - let mut stream = UnixStream::connect(socket_path).ok()?; - stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; - - let mut data = Vec::new(); - stream.read_to_end(&mut data).ok()?; - - if data.len() < 8 { - return None; - } - - let rows = u16::from_be_bytes([data[0], data[1]]); - let cols = u16::from_be_bytes([data[2], data[3]]); - let cursor_row = u16::from_be_bytes([data[4], data[5]]); - let cursor_col = u16::from_be_bytes([data[6], data[7]]); - - let mut rows_data = Vec::with_capacity(rows as usize); - let mut offset = 8; - while offset + 4 <= data.len() { - let row_len = u32::from_be_bytes([ - data[offset], - data[offset + 1], - data[offset + 2], - data[offset + 3], - ]) as usize; - offset += 4; - if offset + row_len > data.len() { - break; - } - rows_data.push(data[offset..offset + row_len].to_vec()); - offset += row_len; - } - - Some(SavedScreen { - rows, - cols, - cursor_row, - cursor_col, - rows_data, - }) -} - -/// Compute popup placement for the AI card. -/// -/// Positions the popup near the cursor: below if there's room, above otherwise. -/// Uses a capped height (MAX_POPUP_HEIGHT) so the popup doesn't fill the screen. -/// -/// Returns `(popup_rect, scroll_offset, render_above)`: -/// - `render_above`: true when popup is above cursor (input should be at bottom) -/// - `scroll_offset`: lines the caller should scroll the terminal up -fn compute_popup_placement( - cursor_row: u16, - cursor_col: u16, - term_rows: u16, - term_cols: u16, - card_width: u16, -) -> (Rect, u16, bool) { - // Horizontal: anchor card near cursor, clamp to screen - let popup_w = card_width.min(term_cols); - let preferred_x = cursor_col.saturating_sub(2); - let max_x = term_cols.saturating_sub(popup_w); - let popup_x = preferred_x.min(max_x); - - // Vertical: use a reasonable height, not the full terminal - let max_h = MAX_POPUP_HEIGHT - .min(term_rows.saturating_sub(2)) - .max(MIN_POPUP_HEIGHT); - let space_above = cursor_row; - let space_below = term_rows.saturating_sub(cursor_row); - - if max_h <= space_below { - // Fits below cursor — input at top (close to prompt) - let popup_y = cursor_row; - (Rect::new(popup_x, popup_y, popup_w, max_h), 0, false) - } else if max_h <= space_above { - // Fits above cursor — input at bottom (close to prompt) - let popup_y = cursor_row.saturating_sub(max_h); - (Rect::new(popup_x, popup_y, popup_w, max_h), 0, true) - } else { - // Neither side fits fully — use whichever side has more space, - // scrolling the terminal if needed to reach MIN_POPUP_HEIGHT. - let render_above = space_above > space_below; - let available = if render_above { - space_above - } else { - space_below - }; - let h = available.max(MIN_POPUP_HEIGHT).min(max_h); - let scroll = h.saturating_sub(available); - let popup_y = if render_above { - cursor_row.saturating_sub(h + scroll) - } else { - cursor_row.saturating_sub(scroll) - }; - ( - Rect::new(popup_x, popup_y, popup_w, h), - scroll, - render_above, - ) - } -} diff --git a/crates/atuin-ai/src/tui/render.rs b/crates/atuin-ai/src/tui/render.rs deleted file mode 100644 index e3801d6a..00000000 --- a/crates/atuin-ai/src/tui/render.rs +++ /dev/null @@ -1,234 +0,0 @@ -use atuin_client::theme::{Meaning, Theme}; -use pulldown_cmark::{Event, Parser, Tag, TagEnd}; -use ratatui::{ - Frame, - backend::FromCrossterm, - layout::{Alignment, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block as RatatuiBlock, Borders, Padding}, -}; - -use super::component::Component; -pub use super::component::RenderContext; -use super::components::build_component_tree; -use super::spinner::active_frame; -use super::state::AppState; -use super::view_model::Blocks; - -/// Fixed card width for the TUI -pub(crate) const CARD_WIDTH: u16 = 64; - -/// Calculate the height needed to render the current state. -/// Used to dynamically resize the viewport before rendering. -/// `card_width` is the outer card width (including borders); pass 0 to use CARD_WIDTH default. -pub fn calculate_needed_height(state: &AppState, card_width: u16) -> u16 { - let view = Blocks::from_state(state); - let w = if card_width > 0 { - card_width - } else { - CARD_WIDTH - }; - let content_width = w.saturating_sub(4).max(1); - - let items: Vec<_> = view.items.iter().collect(); - let tree = build_component_tree(&items, w); - - // Add borders (2) + top padding (1), minimum 5 - tree.height(content_width).saturating_add(3).max(5) -} - -/// Main render function: derives view model from state, then renders it -pub fn render(frame: &mut Frame, state: &AppState, ctx: &RenderContext) { - // PURE DERIVATION: view model is always rebuilt from state - let view = Blocks::from_state(state); - - // Render the derived view model - render_view(frame, &view, ctx); -} - -fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { - let full_area = frame.area(); - - // In popup mode, the viewport is already positioned and sized for the card. - // Clear it to prevent background bleed-through, then inset by margin for the card. - let (area, card_x, desired_width) = if ctx.popup_mode { - #[cfg(unix)] - use super::popup::POPUP_MARGIN; - #[cfg(not(unix))] - const POPUP_MARGIN: u16 = 0; - frame.render_widget(ratatui::widgets::Clear, full_area); - let inset = full_area.inner(ratatui::layout::Margin { - horizontal: POPUP_MARGIN, - vertical: POPUP_MARGIN, - }); - (inset, inset.x, inset.width) - } else { - let dw = CARD_WIDTH.min(full_area.width.saturating_sub(2)).max(32); - let max_x = full_area.x + full_area.width.saturating_sub(dw); - let preferred_x = full_area.x + ctx.anchor_col.saturating_sub(2); - (full_area, preferred_x.min(max_x), dw) - }; - - // Build ordered items list — the active content (input/LLM response) - // should always be closest to the cursor/prompt: - // - Popup below cursor (render_above=false): reverse so active is at top - // - Popup above cursor (render_above=true): normal order, active is at bottom - // - Inline mode: normal order (no reversal) - let items: Vec<&super::view_model::Block> = if ctx.popup_mode && !ctx.render_above { - view.items.iter().rev().collect() - } else { - view.items.iter().collect() - }; - - // Build component tree from view model - let mut tree = build_component_tree(&items, desired_width); - let content_width = desired_width.saturating_sub(4).max(1); - - let desired_height = tree.height(content_width).saturating_add(3).max(5); - - // Cap card height at viewport height to prevent overflow - let actual_height = desired_height.min(area.height); - - // Calculate scroll offset to keep the active content visible when overflowing. - // When render_above=false (popup below cursor), items are reversed so the active - // content (input/spinner) is at the top — scroll_offset stays 0 to show the top. - // Otherwise, scroll to show the bottom where the active content lives. - tree.scroll_offset = if ctx.popup_mode && !ctx.render_above { - 0 - } else { - desired_height.saturating_sub(actual_height) - }; - - let card = Rect { - x: card_x, - y: area.y, - width: desired_width, - height: actual_height, - }; - - // Get title from first block in ORIGINAL order (always the input block) - let title = view - .items - .first() - .and_then(|b| b.title.as_deref()) - .unwrap_or("Describe the command you'd like to generate:"); - - // Create bordered frame - // Padding: left=1, right=1, top=1, bottom=0 (blocks have trailing blanks) - let mut outer_block = RatatuiBlock::default() - .borders(Borders::ALL) - .title(title) - .title_top(Line::from("atuin").alignment(Alignment::Right)) - .title_bottom(Line::from(view.footer).alignment(Alignment::Right)) - .padding(Padding::new(1, 1, 1, 0)); - - // Status bar: transient status on the bottom border, left-aligned - if let Some(ref sb) = view.status_bar { - let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); - let spinner = active_frame(sb.frame); - let status_text = format!(" {} {} ", spinner, sb.text); - outer_block = outer_block - .title_bottom(Line::from(Span::styled(status_text, style)).alignment(Alignment::Left)); - } - - let inner_area = outer_block.inner(card); - frame.render_widget(outer_block, card); - - // Render the component tree - tree.render(frame, inner_area, ctx); -} - -/// Convert markdown to styled spans -pub fn markdown_to_spans<'a>(text: &'a str, theme: &'a Theme) -> Vec<Line<'a>> { - let parser = Parser::new(text); - let mut lines: Vec<Vec<Span<'a>>> = vec![Vec::new()]; - let mut current_line = 0; - - let base_style = Style::from_crossterm(theme.as_style(Meaning::Base)); - let code_style = Style::from_crossterm(theme.as_style(Meaning::Important)); - let mut style_stack: Vec<Style> = vec![base_style]; - let mut in_code_block = false; - - for event in parser { - match event { - Event::Start(Tag::Strong) => { - let bold_style = style_stack - .last() - .copied() - .unwrap_or(base_style) - .add_modifier(Modifier::BOLD); - style_stack.push(bold_style); - } - Event::End(TagEnd::Strong) => { - style_stack.pop(); - } - Event::Start(Tag::Emphasis) => { - let underline_style = style_stack - .last() - .copied() - .unwrap_or(base_style) - .add_modifier(Modifier::UNDERLINED); - style_stack.push(underline_style); - } - Event::End(TagEnd::Emphasis) => { - style_stack.pop(); - } - Event::Start(Tag::CodeBlock(_)) => { - in_code_block = true; - // Start new line for code block - if !lines[current_line].is_empty() { - current_line += 1; - lines.push(Vec::new()); - } - } - Event::End(TagEnd::CodeBlock) => { - in_code_block = false; - // Ensure blank line after code block - if !lines[current_line].is_empty() { - current_line += 1; - lines.push(Vec::new()); - } - } - Event::Code(code) => { - lines[current_line].push(Span::styled(format!("`{}`", code), code_style)); - } - Event::Text(text) => { - let current_style = if in_code_block { - // Use Important style for code block content - code_style - } else { - style_stack.last().copied().unwrap_or(base_style) - }; - let parts: Vec<&str> = text.split('\n').collect(); - for (i, part) in parts.iter().enumerate() { - if i > 0 { - current_line += 1; - lines.push(Vec::new()); - } - if !part.is_empty() { - lines[current_line].push(Span::styled(part.to_string(), current_style)); - } - } - } - Event::SoftBreak => { - let current_style = style_stack.last().copied().unwrap_or(base_style); - lines[current_line].push(Span::styled(" ", current_style)); - } - Event::HardBreak => { - current_line += 1; - lines.push(Vec::new()); - } - Event::Start(Tag::Paragraph) => { - if current_line > 0 || !lines[0].is_empty() { - current_line += 1; - lines.push(Vec::new()); - } - } - Event::End(TagEnd::Paragraph) => {} - _ => {} - } - } - - lines.into_iter().map(Line::from).collect() -} diff --git a/crates/atuin-ai/src/tui/spinner.rs b/crates/atuin-ai/src/tui/spinner.rs deleted file mode 100644 index 138e0269..00000000 --- a/crates/atuin-ai/src/tui/spinner.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Spinner styles and configuration for TUI animations -//! -//! To experiment with different spinners, change `ACTIVE_SPINNER` below. - -use std::time::Duration; - -/// Active spinner style - change this to experiment with different styles -pub const ACTIVE_SPINNER: SpinnerStyle = SpinnerStyle::Dots; - -/// Spinner style definitions -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SpinnerStyle { - /// Classic ASCII line spinner: / - \ | - Line, - /// Braille dots pattern - Dots, - /// Growing/shrinking dots - Pulse, - /// Simple arrow rotation - Arrow, - /// Block building - Block, -} - -impl SpinnerStyle { - /// Get the frames for this spinner style - pub const fn frames(&self) -> &'static [&'static str] { - match self { - SpinnerStyle::Line => &["/", "-", "\\", "|"], - SpinnerStyle::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], - SpinnerStyle::Pulse => &["·", "•", "●", "•"], - SpinnerStyle::Arrow => &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"], - SpinnerStyle::Block => &[ - "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", - ], - } - } - - /// Get the recommended tick interval for this spinner style - /// Faster spinners need shorter intervals to look smooth - pub const fn tick_interval(&self) -> Duration { - match self { - SpinnerStyle::Line => Duration::from_millis(150), - SpinnerStyle::Dots => Duration::from_millis(80), - SpinnerStyle::Pulse => Duration::from_millis(200), - SpinnerStyle::Arrow => Duration::from_millis(100), - SpinnerStyle::Block => Duration::from_millis(80), - } - } - - /// Get the frame at the given index (wraps around) - pub fn frame_at(&self, index: usize) -> &'static str { - let frames = self.frames(); - frames[index % frames.len()] - } - - /// Get the number of frames in this spinner - pub fn frame_count(&self) -> usize { - self.frames().len() - } -} - -/// Get the active spinner's frame at the given index -pub fn active_frame(index: usize) -> &'static str { - ACTIVE_SPINNER.frame_at(index) -} - -/// Get the active spinner's tick interval -pub fn active_tick_interval() -> Duration { - ACTIVE_SPINNER.tick_interval() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_frame_wrapping() { - let style = SpinnerStyle::Line; - assert_eq!(style.frame_at(0), "/"); - assert_eq!(style.frame_at(4), "/"); // wraps - assert_eq!(style.frame_at(5), "-"); - } - - #[test] - fn test_all_styles_have_frames() { - let styles = [ - SpinnerStyle::Line, - SpinnerStyle::Dots, - SpinnerStyle::Pulse, - SpinnerStyle::Arrow, - SpinnerStyle::Block, - ]; - for style in styles { - assert!(!style.frames().is_empty()); - assert!(style.tick_interval().as_millis() > 0); - } - } -} 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 { diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs deleted file mode 100644 index f8089323..00000000 --- a/crates/atuin-ai/src/tui/terminal.rs +++ /dev/null @@ -1,278 +0,0 @@ -use crossterm::{ - cursor, - terminal::{disable_raw_mode, enable_raw_mode}, -}; -use eyre::{Context, Result, bail}; -use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend, layout::Rect}; -use std::io::{IsTerminal, Stdout, stdout}; - -/// Install a panic hook that ensures the terminal is restored to a usable state -/// even if the application panics. -/// -/// This must be called before creating the TerminalGuard to ensure proper cleanup -/// during panics. The hook will: -/// 1. Disable raw mode (restoring normal terminal behavior) -/// 2. Call the original panic hook to display panic information -/// -/// # Implementation Note -/// This satisfies TUI-07: Terminal remains usable after panic by ensuring -/// disable_raw_mode() is called before the panic message is displayed. -pub fn install_panic_hook() { - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - // Attempt to restore terminal - ignore errors since we're already panicking - let _ = disable_raw_mode(); - // Call original hook to display panic with backtrace - original_hook(panic_info); - })); -} - -/// Minimum viewport height -const MIN_VIEWPORT_HEIGHT: u16 = 10; - -/// Margin to leave below viewport for shell prompt -const VIEWPORT_BOTTOM_MARGIN: u16 = 2; - -/// Guards terminal lifecycle, ensuring proper setup and cleanup. -/// -/// # Lifecycle -/// - **Setup** (`new()`): Captures cursor position, enables raw mode, creates inline viewport -/// - **Cleanup** (`Drop`): Clears terminal, disables raw mode -/// -/// # Dynamic Viewport Sizing -/// The viewport starts at 15 lines (enough for simple commands) and grows -/// dynamically when content requires more space. Use `ensure_height()` before -/// rendering to grow the viewport if needed. -/// -/// # Safety Features -/// - Non-TTY detection: Returns error early if stdout is not a terminal -/// - Panic recovery: Works with `install_panic_hook()` to restore terminal after panic -/// - Drop-based cleanup: Ensures terminal is restored on normal exit -/// -/// # Example -/// ```no_run -/// use atuin_ai::tui::{install_panic_hook, TerminalGuard}; -/// -/// install_panic_hook(); // Once at program start -/// let mut guard = TerminalGuard::new(true)?; -/// let terminal = guard.terminal(); -/// // ... use terminal ... -/// // Drop automatically cleans up -/// # Ok::<(), eyre::Report>(()) -/// ``` -pub struct TerminalGuard { - terminal: Terminal<CrosstermBackend<Stdout>>, - anchor_col: u16, - keep_output: bool, - viewport_height: u16, - popup_mode: bool, -} - -impl TerminalGuard { - /// Create a new TerminalGuard, initializing the terminal for inline TUI mode. - /// - /// # Arguments - /// * `keep_output` - If true, preserve TUI output on exit; if false, clear it - /// - /// # Process - /// 1. Check if stdout is a terminal (non-TTY detection) - /// 2. Capture cursor position for inline rendering anchor - /// 3. Enable raw mode for keyboard input - /// 4. Create terminal with inline viewport - /// - /// # Errors - /// - Returns error if stdout is not a terminal (e.g., piped or redirected) - /// - Returns error if terminal initialization fails - /// - /// # Implementation Note - /// Cursor position is captured BEFORE enabling raw mode because some terminals - /// may report position differently after raw mode is enabled. - pub fn new(keep_output: bool) -> Result<Self> { - // Non-TTY check: fail early if stdout is not a terminal - if !stdout().is_terminal() { - bail!( - "atuin-ai requires a terminal (TTY) but stdout is not a terminal. \ - This typically happens when output is piped or redirected." - ); - } - - // Get terminal size and calculate viewport height - let (_, term_height) = crossterm::terminal::size().unwrap_or((80, 24)); - let viewport_height = term_height - .saturating_sub(VIEWPORT_BOTTOM_MARGIN) - .max(MIN_VIEWPORT_HEIGHT); - - // Capture cursor position BEFORE raw mode for accurate anchor - let anchor_col = cursor::position().map(|(x, _)| x).unwrap_or(0); - - // Enable raw mode for keyboard input - enable_raw_mode().context("failed to enable raw mode")?; - - // Create terminal with fixed viewport based on terminal size - let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(viewport_height), - }, - ) - .context("failed to create terminal with inline viewport")?; - - Ok(Self { - terminal, - anchor_col, - keep_output, - viewport_height, - popup_mode: false, - }) - } - - /// Create a new TerminalGuard for popup overlay mode. - /// - /// In popup mode: - /// - Raw mode is not managed (atuin-hex owns it) - /// - The viewport is a fixed rect positioned over existing terminal content - /// - The popup area is pre-cleared to prevent background bleed-through - /// - Drop does not clear the viewport or disable raw mode - pub fn new_popup(popup_rect: Rect, anchor_col: u16) -> Result<Self> { - // Pre-clear the popup area before creating the ratatui terminal. - // Ratatui's diff-based rendering won't write "default" (space) cells on - // the first frame because its previous buffer is also all-default. By - // writing spaces to the terminal now, we ensure those positions are - // visually blank even if ratatui skips them. - { - use crossterm::cursor::MoveTo; - use crossterm::execute; - use crossterm::style::{Attribute, SetAttribute}; - use std::io::Write; - - let mut out = stdout(); - for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { - let _ = execute!( - out, - MoveTo(popup_rect.x, row), - SetAttribute(Attribute::Reset) - ); - let _ = write!(out, "{:width$}", "", width = popup_rect.width as usize); - } - let _ = out.flush(); - } - - let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Fixed(popup_rect), - }, - ) - .context("failed to create terminal with fixed viewport")?; - - Ok(Self { - terminal, - anchor_col, - keep_output: false, - viewport_height: popup_rect.height, - popup_mode: true, - }) - } - - /// Returns the current viewport height. - /// - /// The viewport is fixed at creation time based on terminal size. - /// Content that exceeds this height will be scrolled automatically. - /// - /// The `_needed` parameter is kept for API compatibility but ignored - - /// we no longer attempt to resize the viewport dynamically since that - /// operation can fail unpredictably with inline viewports. - pub fn ensure_height(&mut self, _needed: u16) -> Result<u16> { - Ok(self.viewport_height) - } - - /// Get the current viewport height. - pub fn viewport_height(&self) -> u16 { - self.viewport_height - } - - /// Get mutable reference to the underlying terminal. - /// - /// Use this to perform rendering operations. - pub fn terminal(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> { - &mut self.terminal - } - - /// Resize the popup viewport to a new rect. - /// - /// Creates a fresh terminal with the updated Fixed viewport. The caller - /// is responsible for pre-clearing any newly exposed rows before calling - /// this (see `PopupState::grow_to`). - pub fn resize_popup(&mut self, new_rect: Rect) -> Result<()> { - self.viewport_height = new_rect.height; - let backend = CrosstermBackend::new(stdout()); - self.terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Fixed(new_rect), - }, - ) - .context("failed to resize popup terminal")?; - Ok(()) - } - - /// Get the anchor column where the inline UI should be positioned. - /// - /// This is the column position where the cursor was located when - /// the terminal was initialized. - pub fn anchor_col(&self) -> u16 { - self.anchor_col - } -} - -/// Cleanup terminal state when TerminalGuard is dropped. -/// -/// This implements TUI-08: Terminal restores correctly after normal exit. -/// -/// # Cleanup Process -/// 1. Conditionally clear terminal content (based on keep_output flag) -/// 2. Disable raw mode (restore normal terminal behavior) -/// -/// # Error Handling -/// Errors are intentionally ignored during cleanup since: -/// - We're already exiting and can't meaningfully handle errors -/// - Best-effort restoration is better than panicking during Drop -/// - The panic hook provides a second layer of safety for abnormal exits -impl Drop for TerminalGuard { - fn drop(&mut self) { - if self.popup_mode { - // Popup mode: screen restoration handled by caller before drop. - // Raw mode is owned by atuin-hex, don't touch it. - return; - } - - // Clear terminal content only if keep_output is false - ignore errors (best-effort) - if !self.keep_output { - let _ = self.terminal.clear(); - } - - // Disable raw mode to restore normal terminal behavior - ignore errors - let _ = disable_raw_mode(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_panic_hook_installation() { - // Test that panic hook can be installed without error - install_panic_hook(); - // Installing again should work (replaces previous hook) - install_panic_hook(); - } - - // Note: Cannot easily test TerminalGuard::new() in CI since it requires a TTY. - // Manual testing required for: - // 1. Non-TTY detection: echo "" | cargo run -p atuin-ai -- inline - // 2. Drop cleanup: Run inline command, press Esc, verify terminal is normal - // 3. Panic recovery: Add panic!("test") after TerminalGuard::new(), verify terminal is usable -} diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs new file mode 100644 index 00000000..a1b32518 --- /dev/null +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -0,0 +1,342 @@ +//! View function that builds the eye-declare element tree from app state. + +use eye_declare::{ + Column, Component, Elements, HStack, Line, Span, Spinner, TextBlock, VStack, WidthConstraint, + element, impl_slot_children, +}; +use ratatui_core::style::{Color, Modifier, Style}; + +use super::components::atuin_ai::AtuinAi; +use super::components::input_box::InputBox; +use super::components::markdown::Markdown; +use super::state::{AppMode, AppState}; + +mod turn; + +#[derive(Default)] +struct Padding { + top: u16, + left: u16, + right: u16, + bottom: u16, +} + +impl Component for Padding { + type State = (); + + fn content_inset(&self, _state: &Self::State) -> eye_declare::Insets { + eye_declare::Insets::ZERO + .left(self.left) + .right(self.right) + .top(self.top) + .bottom(self.bottom) + } + + fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 { + 0 + } + + fn render( + &self, + _area: ratatui::layout::Rect, + _buf: &mut ratatui::buffer::Buffer, + _state: &(), + ) { + } +} + +impl_slot_children!(Padding); + +/// Build the element tree from current state. +/// +/// Layout (top to bottom): +/// - Conversation messages (user messages, agent responses, tool status) +/// - Streaming content (if actively streaming) +/// - Error display (if in error state) +/// - Spacer +/// - Input box (bordered, with contextual keybindings) +pub fn ai_view(state: &AppState) -> Elements { + let mut turn_builder = turn::TurnBuilder::new(); + + for event in &state.events { + turn_builder.add_event(event); + } + let turns = turn_builder.build(); + + let busy = state.mode == AppMode::Streaming || state.mode == AppMode::Generating; + let last_index = turns.len().saturating_sub(1); + + element! { + AtuinAi( + mode: state.mode.clone(), + has_command: state.has_any_command(), + is_input_blank: state.is_input_blank, + pending_confirmation: state.confirmation_pending, + ) { + #(for (index, turn) in turns.iter().enumerate() { + #(match turn { + turn::UiTurn::User { events } => { + user_turn_view(events, index == 0) + } + turn::UiTurn::Agent { events } => { + agent_turn_view(events, busy && index == last_index) + } + turn::UiTurn::OutOfBand { events } => { + out_of_band_turn_view(events) + } + }) + }) + + #(if !state.is_exiting() { + TextBlock { Line { Span(text: "") } } + InputBox( + key: "input", + title: "Generate a command or ask a question", + title_right: "Atuin AI", + footer: state.footer_text(), + active: state.mode == AppMode::Input && !state.confirmation_pending, + ) + + #(if state.is_input_blank && state.has_any_command() && state.mode == AppMode::Input { + #(if state.confirmation_pending { + TextBlock { Line { Span(text: "[Enter] Confirm dangerous command [Esc] Cancel", style: Style::default().fg(Color::Gray)) } } + } else { + TextBlock { Line { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) } } + }) + }) + }) + } + } +} + +fn user_turn_view(events: &[turn::UiEvent], first_turn: bool) -> Elements { + let label_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + + element! { + VStack { + TextBlock { + #(if !first_turn { + Line { Span() } + }) + Line { + Span(text: "You", style: label_style) + } + } + #(for event in events { + #(match event { + turn::UiEvent::Text { content } => { + element! { + Padding(left: 2u16) { + TextBlock { + Line { + Span(text: content, style: Style::default()) + } + } + } + } + }, + _ => element!{} + }) + }) + } + } +} + +fn agent_turn_view(events: &[turn::UiEvent], busy: bool) -> Elements { + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + + element! { + VStack { + Spinner( + label: "Atuin AI", + label_style: label_style, + done_label_style: label_style, + hide_checkmark: true, + label_first: true, + done: !busy, + ) + #(for event in events { + #(match event { + turn::UiEvent::Text { content } => { + element! { + Padding(left: 2u16) { + Markdown(source: content) + } + } + }, + turn::UiEvent::ToolSummary(summary) => { + tool_summary_view(summary) + }, + turn::UiEvent::SuggestedCommand(details) => { + suggested_command_view(details) + }, + _ => element!{} + }) + }) + } + } +} + +fn out_of_band_turn_view(events: &[turn::UiEvent]) -> Elements { + element! { + VStack { + TextBlock { + Line { Span(text: "System", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) } + } + #(for event in events { + #(match event { + turn::UiEvent::OutOfBandOutput(details) => { + out_of_band_output_view(details) + } + _ => element!{} + }) + }) + } + } +} + +fn out_of_band_output_view(details: &turn::OutOfBandOutputDetails) -> Elements { + element! { + Padding(left: 2u16) { + #(if details.command.is_some() { + TextBlock { + Line { + Span(text: details.command.as_ref().unwrap(), style: Style::default().fg(Color::Blue)) + } + } + }) + Markdown(source: details.content.clone()) + } + } +} + +fn tool_summary_view(summary: &turn::ToolSummary) -> Elements { + element! { + Spinner(label: summary.summary(), done: !summary.any_pending()) + } +} + +fn suggested_command_view(details: &turn::SuggestedCommandDetails) -> Elements { + let is_dangerous = matches!( + details.danger_level, + turn::DangerLevel::High(_) | turn::DangerLevel::Medium(_) + ); + let danger_notes = details.danger_level.notes(); + let danger_style = match details.danger_level { + turn::DangerLevel::High(_) => Style::default().fg(Color::Red), + turn::DangerLevel::Medium(_) => Style::default().fg(Color::Yellow), + turn::DangerLevel::Low(_) => Style::default().fg(Color::Green), + turn::DangerLevel::Unknown(_) => Style::default().fg(Color::Green), + }; + let danger_text = match details.danger_level { + turn::DangerLevel::High(_) => "High", + turn::DangerLevel::Medium(_) => "Medium", + turn::DangerLevel::Low(_) => "Low", + turn::DangerLevel::Unknown(_) => "Unknown", + }; + + let low_confidence = matches!( + details.confidence_level, + turn::ConfidenceLevel::Low(_) | turn::ConfidenceLevel::Medium(_) + ); + + let confidence_level = match details.confidence_level { + turn::ConfidenceLevel::Low(_) => "Low", + turn::ConfidenceLevel::Medium(_) => "Medium", + turn::ConfidenceLevel::High(_) => "High", + turn::ConfidenceLevel::Unknown(_) => "Unknown", + }; + + let confidence_notes = details.confidence_level.notes(); + + element! { + VStack { + TextBlock { + #(if !details.first_event_in_turn { + Line { Span() } + }) + Line { + Span(text: " Suggested command:", style: Style::default().fg(Color::Cyan)) + } + } + HStack { + Column(width: WidthConstraint::Fixed(2)) { + TextBlock { + Line { + #(if is_dangerous || low_confidence { + Span(text: "! ", style: Style::default().fg(Color::Yellow)) + } else { + Span(text: "$ ", style: Style::default().fg(Color::Blue)) + }) + } + } + } + Column { + TextBlock { + Line { + Span(text: &details.command, style: Style::default().fg(Color::Green)) + } + } + } + } + #(if is_dangerous { + Padding(left: 2u16) { + TextBlock { + Line { + Span(text: "Danger: ", style: danger_style) + Span(text: danger_text, style: danger_style.add_modifier(Modifier::BOLD)) + } + } + } + }) + #(if is_dangerous && danger_notes.is_some() { + Padding(left: 2u16) { + HStack { + Column(width: WidthConstraint::Fixed(2)) { + TextBlock { + Line { + Span(text: "└") + } + } + } + Column(width: WidthConstraint::Fill) { + Markdown(source: danger_notes.unwrap()) + } + } + } + }) + #(if low_confidence { + Padding(left: 2u16) { + TextBlock { + Line { + Span(text: "Confidence: ", style: Style::default().fg(Color::Blue)) + Span(text: confidence_level, style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) + } + } + } + }) + #(if low_confidence && confidence_notes.is_some() { + Padding(left: 2u16) { + HStack { + Column(width: WidthConstraint::Fixed(2)) { + TextBlock { + Line { + Span(text: "└") + } + } + } + Column(width: WidthConstraint::Fill) { + Markdown(source: confidence_notes.unwrap()) + } + } + } + }) + } + } +} + +// ai_view_old removed — superseded by ai_view above diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs new file mode 100644 index 00000000..861da64c --- /dev/null +++ b/crates/atuin-ai/src/tui/view/turn.rs @@ -0,0 +1,409 @@ +use crate::tui::ConversationEvent; + +#[derive(Debug)] +pub(crate) enum DangerLevel { + Low(Option<String>), + Medium(Option<String>), + High(Option<String>), + Unknown(Option<String>), +} + +impl DangerLevel { + pub(crate) fn notes(&self) -> Option<&String> { + match self { + DangerLevel::Low(notes) => notes.as_ref(), + DangerLevel::Medium(notes) => notes.as_ref(), + DangerLevel::High(notes) => notes.as_ref(), + DangerLevel::Unknown(notes) => notes.as_ref(), + } + } +} + +impl From<(&String, &String)> for DangerLevel { + fn from((danger_level, danger_notes): (&String, &String)) -> Self { + let notes = if danger_notes.is_empty() { + None + } else { + Some(danger_notes.to_string()) + }; + + match danger_level.as_str() { + "low" => DangerLevel::Low(notes), + "medium" => DangerLevel::Medium(notes), + "med" => DangerLevel::Medium(notes), + "high" => DangerLevel::High(notes), + _ => DangerLevel::Unknown(notes), + } + } +} + +#[derive(Debug)] +pub(crate) enum ConfidenceLevel { + Low(Option<String>), + Medium(Option<String>), + High(Option<String>), + Unknown(Option<String>), +} + +impl ConfidenceLevel { + pub(crate) fn notes(&self) -> Option<&String> { + match self { + ConfidenceLevel::Low(notes) => notes.as_ref(), + ConfidenceLevel::Medium(notes) => notes.as_ref(), + ConfidenceLevel::High(notes) => notes.as_ref(), + ConfidenceLevel::Unknown(notes) => notes.as_ref(), + } + } +} + +impl From<(&String, &String)> for ConfidenceLevel { + fn from((confidence_level, confidence_notes): (&String, &String)) -> Self { + let notes = if confidence_notes.is_empty() { + None + } else { + Some(confidence_notes.to_string()) + }; + + match confidence_level.as_str() { + "low" => ConfidenceLevel::Low(notes), + "medium" => ConfidenceLevel::Medium(notes), + "med" => ConfidenceLevel::Medium(notes), + "high" => ConfidenceLevel::High(notes), + _ => ConfidenceLevel::Unknown(notes), + } + } +} + +#[derive(Debug)] +pub(crate) enum UiEvent { + Text { content: String }, + ToolCall(ToolCallDetails), + ToolSummary(ToolSummary), + SuggestedCommand(SuggestedCommandDetails), + OutOfBandOutput(OutOfBandOutputDetails), +} + +#[derive(Debug)] +pub(crate) struct ToolCallDetails { + tool_use_id: String, + name: String, + status: ToolResultStatus, +} + +#[derive(Debug)] +pub(crate) struct SuggestedCommandDetails { + pub(crate) command: String, + pub(crate) danger_level: DangerLevel, + pub(crate) confidence_level: ConfidenceLevel, + pub(crate) first_event_in_turn: bool, +} + +#[derive(Debug)] +pub(crate) struct OutOfBandOutputDetails { + pub(crate) command: Option<String>, + pub(crate) content: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ToolResultStatus { + Pending, + Success, + Error, +} + +#[derive(Debug)] +pub(crate) enum UiTurn { + User { events: Vec<UiEvent> }, + Agent { events: Vec<UiEvent> }, + OutOfBand { events: Vec<UiEvent> }, +} + +pub(crate) struct TurnBuilder { + turns: Vec<UiTurn>, + current_turn: Option<UiTurn>, +} + +impl TurnBuilder { + pub(crate) fn new() -> Self { + Self { + turns: Vec::new(), + current_turn: None, + } + } + + pub(crate) fn add_event(&mut self, event: &ConversationEvent) { + match event { + ConversationEvent::UserMessage { content } => { + self.add_user_message(content); + } + ConversationEvent::Text { content } => { + self.add_agent_text(content); + } + ConversationEvent::ToolCall { id, name, input } => { + if name == "suggest_command" { + self.add_suggested_command(input); + } else { + self.add_tool_call(id, name, input); + } + } + ConversationEvent::ToolResult { + tool_use_id, + content, + is_error, + } => { + self.add_tool_result(tool_use_id, content, *is_error); + } + ConversationEvent::OutOfBandOutput { + name, + command, + content, + } => { + self.add_out_of_band_output(name, command.as_deref(), content); + } + } + } + + pub(crate) fn build(&mut self) -> Vec<UiTurn> { + self.commit_turn(); + + // Collapse consecutive tool calls within each agent turn into ToolSummary + for turn in &mut self.turns { + if let UiTurn::Agent { events } = turn { + let mut new_events: Vec<UiEvent> = Vec::new(); + let mut pending_tools: Vec<ToolCallDetails> = Vec::new(); + + for event in events.drain(..) { + match event { + UiEvent::ToolCall(details) => { + pending_tools.push(details); + } + other => { + if !pending_tools.is_empty() { + new_events.push(UiEvent::ToolSummary(ToolSummary { + tool_calls: std::mem::take(&mut pending_tools), + })); + } + new_events.push(other); + } + } + } + + if !pending_tools.is_empty() { + new_events.push(UiEvent::ToolSummary(ToolSummary { + tool_calls: pending_tools, + })); + } + + *events = new_events; + } + } + + std::mem::take(&mut self.turns) + } + + fn commit_turn(&mut self) { + if let Some(turn) = self.current_turn.take() { + self.turns.push(turn); + } + } + + fn start_user_turn(&mut self) { + if !matches!(self.current_turn, Some(UiTurn::User { .. })) { + self.commit_turn(); + self.current_turn = Some(UiTurn::User { events: vec![] }); + } + } + + fn start_agent_turn(&mut self) { + if !matches!(self.current_turn, Some(UiTurn::Agent { .. })) { + self.commit_turn(); + self.current_turn = Some(UiTurn::Agent { events: vec![] }); + } + } + + fn start_out_of_band_turn(&mut self) { + if !matches!(self.current_turn, Some(UiTurn::OutOfBand { .. })) { + self.commit_turn(); + self.current_turn = Some(UiTurn::OutOfBand { events: vec![] }); + } + } + + fn turn_mut_unsafe(&mut self) -> &mut UiTurn { + self.current_turn.as_mut().unwrap() + } + + fn add_user_message(&mut self, content: &str) { + self.start_user_turn(); + if let UiTurn::User { events } = self.turn_mut_unsafe() { + events.push(UiEvent::Text { + content: content.to_string(), + }); + } + } + + fn add_agent_text(&mut self, content: &str) { + self.start_agent_turn(); + if let UiTurn::Agent { events } = self.turn_mut_unsafe() { + events.push(UiEvent::Text { + content: content.to_string(), + }); + } + } + + fn add_suggested_command(&mut self, input: &serde_json::Value) { + let command = input + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if command.is_empty() { + return; + } + + self.start_agent_turn(); + if let UiTurn::Agent { events } = self.turn_mut_unsafe() { + let danger_level = input + .get("danger") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let confidence_level = input + .get("confidence") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let danger_notes = input + .get("danger_notes") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let confidence_notes = input + .get("confidence_notes") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let danger = DangerLevel::from((&danger_level, &danger_notes)); + let confidence = ConfidenceLevel::from((&confidence_level, &confidence_notes)); + + let first_event_in_turn = events.is_empty(); + + events.push(UiEvent::SuggestedCommand(SuggestedCommandDetails { + command: input + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + danger_level: danger, + confidence_level: confidence, + first_event_in_turn, + })); + } + } + + fn add_tool_call(&mut self, id: &str, name: &str, _input: &serde_json::Value) { + self.start_agent_turn(); + if let UiTurn::Agent { events } = self.turn_mut_unsafe() { + events.push(UiEvent::ToolCall(ToolCallDetails { + tool_use_id: id.to_string(), + name: name.to_string(), + status: ToolResultStatus::Pending, + })); + } + } + + fn add_tool_result(&mut self, tool_use_id: &str, _content: &str, is_error: bool) { + self.start_agent_turn(); + if let UiTurn::Agent { events } = self.turn_mut_unsafe() { + let event = events.iter_mut().find(|e| match e { + UiEvent::ToolCall(ToolCallDetails { + tool_use_id: id, .. + }) => id == tool_use_id, + _ => false, + }); + if let Some(UiEvent::ToolCall(ToolCallDetails { status, .. })) = event { + *status = if is_error { + ToolResultStatus::Error + } else { + ToolResultStatus::Success + }; + } + } + } + + fn add_out_of_band_output(&mut self, _name: &str, command: Option<&str>, content: &str) { + self.start_out_of_band_turn(); + if let UiTurn::OutOfBand { events } = self.turn_mut_unsafe() { + events.push(UiEvent::OutOfBandOutput(OutOfBandOutputDetails { + command: command.map(|c| c.to_string()), + content: content.to_string(), + })); + } + } +} + +#[derive(Debug)] +pub(crate) struct ToolSummary { + tool_calls: Vec<ToolCallDetails>, +} + +impl ToolSummary { + /// Determines the summary line: + /// - If any call is pending, use present tense verb with `-ing` + /// - If multiple calls are complete, say "Used n tools" + /// - If a single call is complete, use past tense verb + pub(crate) fn summary(&self) -> String { + if self.any_pending() { + // Find the last pending tool for the active verb + if let Some(pending) = self + .tool_calls + .iter() + .rev() + .find(|t| t.status == ToolResultStatus::Pending) + { + return Self::progressive_verb(&pending.name); + } + } + + if self.tool_calls.len() == 1 { + return Self::past_verb(&self.tool_calls[0].name); + } + + format!("Used {} tools", self.tool_calls.len()) + } + + /// Determines if the spinner should be spinning + pub(crate) fn any_pending(&self) -> bool { + self.tool_calls + .iter() + .any(|tool_call| tool_call.status == ToolResultStatus::Pending) + } + + /// Present-tense progressive verb for a tool name (e.g. "Searching...") + fn progressive_verb(name: &str) -> String { + match name { + "search" => "Searching...".into(), + "read" | "read_file" => "Reading file...".into(), + "write" | "write_file" => "Writing file...".into(), + "execute" | "run" | "bash" => "Running command...".into(), + "list" | "list_files" => "Listing files...".into(), + _ => format!("Running {}...", name.replace('_', " ")), + } + } + + /// Past-tense verb for a tool name (e.g. "Searched") + fn past_verb(name: &str) -> String { + match name { + "search" => "Searched".into(), + "read" | "read_file" => "Read file".into(), + "write" | "write_file" => "Wrote file".into(), + "execute" | "run" | "bash" => "Ran command".into(), + "list" | "list_files" => "Listed files".into(), + _ => format!("Ran {}", name.replace('_', " ")), + } + } +} 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", - } - } -} |
