diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-02-24 11:48:20 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-24 11:48:20 -0800 |
| commit | 6ea760bb6b36da241961e8ecd60cb2c5e15c0a78 (patch) | |
| tree | 18ebbb710cea24e30bc69b5d6bc807518a950746 /crates/atuin-ai/src/tui | |
| parent | fix: forward $PATH to tmux popup in zsh (#3198) (diff) | |
| download | atuin-6ea760bb6b36da241961e8ecd60cb2c5e15c0a78.zip | |
feat: Generate commands or ask questions with `atuin ai` (#3199)
This PR refines the system created in #3178 to be suitable for a v1
release.
---
## Overview
`atuin-ai` is a separate binary that allows for generating commands and
asking questions from the command line.
It is fully opt-in.
## Usage
`atuin ai init` will output bindings for your shell. Currently, bash,
zsh, and fish are supported.
```bash
eval "$(atuin ai init)"
```
Once the hooks are installed, just press `?` on an empty prompt line to
call up the TUI.
`atuin ai` requires an account on [Atuin Hub](https://hub.atuin.sh/);
you will be prompted to log in on first use.
## Features
### Command generation
Prompt the LLM to create a command, and get one back, no fuss. Press
`enter` to run, or `tab` to insert.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
### Follow-up
You can follow-up with `f` to specify a refinement prompt to update the
command that will be inserted.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > Actually I want to get all docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps -a │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
You can also follow-up with questions to get responses in natural
language.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > Actually I want to get all docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps -a │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > What other useful flags to `docker ps` should I know? │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ Here are some handy `docker ps` flags: │
│ │
│ - `-q` — Only show container IDs (great for piping to │
│ other commands) │
│ - `-s` — Show container sizes │
│ - `-n 5` — Show the last 5 created containers │
│ - `-l` — Show only the latest created container │
│ - `--no-trunc` — Don't truncate output (shows full IDs and │
│ commands) │
│ - `-f` or `--filter` — Filter by condition, e.g.: │
│ - `-f status=exited` — only exited containers │
│ - `-f name=myapp` — filter by name │
│ - `-f ancestor=nginx` — filter by image │
│ - `--format` — Custom output using Go templates, e.g.: │
│ `--format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"` │
│ │
│ A common combo is `docker ps -aq` to get all container │
│ IDs, useful for bulk operations like `docker rm $(docker │
│ ps -aq)`. │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
You can use `enter` or `tab` at any time to run or insert the last
suggested command, even if it was suggested in a previous turn.
### Conversational and search usage
If you prompt the LLM with a question that doesn't imply you want to
generate a command, it can respond in natural language, and use web
search if necessary to fetch the data it needs.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > What is the latest version of atuin? │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ ✓ Used 2 tools │
│ │
│ The latest version of Atuin is **v18.12.0**, available on │
│ the [GitHub releases │
│ page](https://github.com/atuinsh/atuin/releases). │
│ │
└─────────────────────────────────[f]: Follow-up [Esc]: Cancel┘
```
### Dangerous or low-confidence command detection
The LLM scores its confidence in the command, as well as how dangerous
the command is. This information is shown if a threshold is exceeded,
and requires an extra confirmation step before running automatically
with `enter`.
The Atuin Hub server also monitors suggested commands for dangerous
patterns the LLM didn't catch, and appends its own assessment at the end
of the LLM's own assessment.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Delete all files from $HOME │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ rm -rf $HOME/* │
│ │
│ ! ⚠️ This will PERMANENTLY delete ALL files and directories │
│ in your home directory, including documents, downloads, │
│ configurations, SSH keys, and everything else. This is │
│ irreversible and will likely break your system. Also note │
│ this won't delete hidden (dot) files — if you want those │
│ too, that's even more destructive.; [Server] Recursive │
│ delete of critical directory │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
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/event.rs | 303 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 14 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 674 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/spinner.rs | 99 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 530 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/terminal.rs | 203 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view_model.rs | 400 |
8 files changed, 2380 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/tui/app.rs b/crates/atuin-ai/src/tui/app.rs new file mode 100644 index 00000000..ecb1eb81 --- /dev/null +++ b/crates/atuin-ai/src/tui/app.rs @@ -0,0 +1,157 @@ +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/event.rs b/crates/atuin-ai/src/tui/event.rs new file mode 100644 index 00000000..8efbf522 --- /dev/null +++ b/crates/atuin-ai/src/tui/event.rs @@ -0,0 +1,303 @@ +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/mod.rs b/crates/atuin-ai/src/tui/mod.rs new file mode 100644 index 00000000..dbf4457b --- /dev/null +++ b/crates/atuin-ai/src/tui/mod.rs @@ -0,0 +1,14 @@ +pub mod app; +pub mod event; +pub mod render; +pub mod spinner; +pub mod state; +pub mod terminal; +pub mod view_model; + +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/render.rs b/crates/atuin-ai/src/tui/render.rs new file mode 100644 index 00000000..0b6341e6 --- /dev/null +++ b/crates/atuin-ai/src/tui/render.rs @@ -0,0 +1,674 @@ +use atuin_client::theme::{Meaning, Theme}; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; +use ratatui::{ + Frame, + backend::FromCrossterm, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block as RatatuiBlock, Borders, Padding, Paragraph, Wrap}, +}; +use tui_textarea::TextArea; + +use super::spinner::active_frame; +use super::state::AppState; +use super::view_model::{Blocks, Content, WarningKind}; + +/// Fixed card width for the TUI +const CARD_WIDTH: u16 = 64; + +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, +} + +/// Calculate the height needed to render the current state. +/// Used to dynamically resize the viewport before rendering. +pub fn calculate_needed_height(state: &AppState) -> u16 { + use super::state::AppMode; + + let view = Blocks::from_state(state); + let content_width = usize::from(CARD_WIDTH.saturating_sub(4)).max(1); + + let mut total_height = 0u16; + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + total_height = total_height.saturating_add(1); // separator + total_height = total_height.saturating_add(1); // leading blank after separator + } + total_height = + total_height.saturating_add(calculate_block_height(&block.content, content_width)); + } + + // In Streaming/Generating mode, always reserve space for spinner block even during + // the 200ms delay when it's not yet shown. This prevents the UI from briefly + // shrinking and scrolling away the user message. + let has_spinner_block = view.items.iter().any(|b| { + b.content + .iter() + .any(|c| matches!(c, Content::Spinner { .. })) + }); + if matches!(state.mode, AppMode::Streaming | AppMode::Generating) && !has_spinner_block { + // Reserve space for separator (2 lines) + spinner block (1 line) + total_height = total_height.saturating_add(3); + } + + // Add borders (2) + top padding (1), minimum 5 + total_height.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 area = frame.area(); + + // Calculate frame dimensions (fixed width, min 32 if terminal is narrow) + let desired_width = CARD_WIDTH.min(area.width.saturating_sub(2)).max(32); + let content_width = usize::from(desired_width.saturating_sub(4)).max(1); + + // Position at anchor_col + let max_x = area.x + area.width.saturating_sub(desired_width); + let preferred_x = area.x + ctx.anchor_col.saturating_sub(2); + + // Calculate height from view model + let mut total_height = 0u16; + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + total_height = total_height.saturating_add(1); // separator + total_height = total_height.saturating_add(1); // leading blank after separator + } + total_height = + total_height.saturating_add(calculate_block_height(&block.content, content_width)); + } + + let desired_height = total_height + .saturating_add(3) // borders (2) + top padding (1), no bottom padding + .max(5); + + // Cap card height at viewport height to prevent overflow + let actual_height = desired_height.min(area.height); + + // Calculate scroll offset (scroll to show bottom content when overflowing) + let scroll_offset = desired_height.saturating_sub(actual_height); + + let card = Rect { + x: preferred_x.min(max_x), + y: area.y, + width: desired_width, + height: actual_height, + }; + + // Get title from first block (if any) + 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 outer_block = RatatuiBlock::default() + .borders(Borders::ALL) + .title(title) + .title_bottom(Line::from(view.footer).alignment(Alignment::Right)) + .padding(Padding::new(1, 1, 1, 0)); + + let inner_area = outer_block.inner(card); + frame.render_widget(outer_block, card); + + // Render blocks (with scroll offset for overflowing content) + render_blocks_content(frame, view, ctx, inner_area, card.width, scroll_offset); +} + +fn render_blocks_content( + frame: &mut Frame, + view: &Blocks, + ctx: &RenderContext, + area: Rect, + card_width: u16, + scroll_offset: u16, +) { + let content_width = usize::from(area.width).max(1); + + // Build layout constraints for full content + let mut constraints = Vec::new(); + let mut block_heights = Vec::new(); + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + constraints.push(Constraint::Length(1)); // separator + constraints.push(Constraint::Length(1)); // leading blank after separator + block_heights.push(1); + block_heights.push(1); + } + let height = calculate_block_height(&block.content, content_width); + constraints.push(Constraint::Length(height)); + block_heights.push(height); + } + + if constraints.is_empty() { + return; + } + + // Calculate cumulative heights to find which blocks are visible after scrolling + let mut cumulative: Vec<u16> = Vec::with_capacity(block_heights.len() + 1); + cumulative.push(0); + for h in &block_heights { + cumulative.push(cumulative.last().unwrap() + h); + } + + // Render each chunk, offsetting by scroll_offset and clipping to visible area + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let mut chunk_idx = 0; + for (idx, block) in view.items.iter().enumerate() { + if idx > 0 { + // Check if separator is visible (its position minus scroll_offset) + let sep_start = cumulative[chunk_idx]; + if sep_start >= scroll_offset && sep_start < scroll_offset + area.height { + let adjusted_chunk = Rect { + y: area.y + sep_start - scroll_offset, + ..chunks[chunk_idx] + }; + render_separator(frame, adjusted_chunk, ctx, card_width); + } + chunk_idx += 1; + chunk_idx += 1; // skip leading blank + } + + // Check if this block is at least partially visible + let block_start = cumulative[chunk_idx]; + let block_end = cumulative[chunk_idx + 1]; + + // Block is visible if it starts before viewport end and ends after viewport start + if block_start < scroll_offset + area.height && block_end > scroll_offset { + // Calculate visible portion + let visible_start = block_start.max(scroll_offset); + let visible_end = block_end.min(scroll_offset + area.height); + + let adjusted_chunk = Rect { + x: area.x, + y: area.y + visible_start - scroll_offset, + width: area.width, + height: visible_end - visible_start, + }; + + render_block_content(frame, &block.content, adjusted_chunk, ctx); + } + + chunk_idx += 1; + } +} + +/// Render all content items in a block +fn render_block_content(frame: &mut Frame, content: &[Content], area: Rect, ctx: &RenderContext) { + if content.is_empty() { + return; + } + + let content_width = usize::from(area.width).max(1); + + // Build layout constraints for each content item WITH spacing between items + let mut constraints = Vec::new(); + for (idx, c) in content.iter().enumerate() { + if idx > 0 { + constraints.push(Constraint::Length(1)); // blank line between items + } + constraints.push(Constraint::Length(calculate_single_content_height( + c, + content_width, + ))); + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let mut chunk_idx = 0; + for (idx, item) in content.iter().enumerate() { + if idx > 0 { + chunk_idx += 1; // skip the blank line chunk + } + render_single_content(frame, item, chunks[chunk_idx], ctx); + chunk_idx += 1; + } +} + +/// Render a single content item using ratatui's native wrapping. +/// Symbol is rendered at column 0, text wraps in columns 2+ (offset area). +fn render_single_content(frame: &mut Frame, content: &Content, area: Rect, ctx: &RenderContext) { + // Helper to create offset text area (2 chars for symbol column) + let text_area = Rect { + x: area.x.saturating_add(2), + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + + match content { + Content::Input { text, active, .. } => { + let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Guidance)); + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + // Render ">" symbol at column 0 + render_symbol(frame, ">", symbol_style, area); + + if *active { + // Active input: render TextArea widget (handles cursor display) + if let Some(textarea) = ctx.textarea { + frame.render_widget(textarea, text_area); + } + } else { + // Inactive input: render as plain paragraph + let paragraph = Paragraph::new(text.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + } + + Content::Command { text, faded } => { + let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Important)); + let mut text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + if *faded { + text_style = text_style.add_modifier(Modifier::DIM); + } + + render_symbol(frame, "$", symbol_style, area); + + let paragraph = Paragraph::new(text.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Text { markdown } => { + // No symbol, just indent - render directly in offset area + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + let paragraph = Paragraph::new(markdown.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Error { message } => { + let symbol_style = Style::from_crossterm(ctx.theme.as_style(Meaning::AlertError)); + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + render_symbol(frame, "!", symbol_style, area); + + let paragraph = Paragraph::new(message.as_str()) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Warning { + kind, + text, + pending_confirm, + } => { + let (symbol, meaning) = match kind { + WarningKind::Danger => ("!", Meaning::AlertError), + WarningKind::LowConfidence => ("?", Meaning::AlertWarn), + }; + let symbol_style = Style::from_crossterm(ctx.theme.as_style(meaning)); + let text_style = Style::from_crossterm(ctx.theme.as_style(Meaning::Base)); + + let display_text = if *pending_confirm { + "Press Enter again to run this dangerous command" + } else { + text.as_str() + }; + + render_symbol(frame, symbol, symbol_style, area); + + let paragraph = Paragraph::new(display_text) + .style(text_style) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, text_area); + } + + Content::Spinner { + frame: spinner_frame, + status_text, + } => { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); + let symbol = active_frame(*spinner_frame); + + render_symbol(frame, symbol, style, area); + + let paragraph = Paragraph::new(status_text.as_str()).style(style); + frame.render_widget(paragraph, text_area); + } + + Content::ToolStatus { + completed_count, + current_label, + frame: spinner_frame, + } => { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); + + let (symbol, text) = if let Some(label) = current_label { + let spinner = active_frame(*spinner_frame); + let text = if *completed_count > 0 { + format!( + "{} (used {} tool{})", + label, + completed_count, + if *completed_count == 1 { "" } else { "s" } + ) + } else { + label.clone() + }; + (spinner, text) + } else { + ( + "\u{2713}", + format!( + "Used {} tool{}", + completed_count, + if *completed_count == 1 { "" } else { "s" } + ), + ) + }; + + render_symbol(frame, symbol, style, area); + + let paragraph = Paragraph::new(text).style(style); + frame.render_widget(paragraph, text_area); + } + } +} + +/// Render a single-character symbol at the start of an area +fn render_symbol(frame: &mut Frame, symbol: &str, style: Style, area: Rect) { + let symbol_area = Rect { + x: area.x, + y: area.y, + width: 1, + height: 1, + }; + frame.render_widget(Paragraph::new(symbol).style(style), symbol_area); +} + +fn render_separator(frame: &mut Frame, area: Rect, ctx: &RenderContext, card_width: u16) { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Muted)); + + // Build separator: ├ + ─ repeated + ┤ spanning the full card width + // -2 for the ├ and ┤ characters themselves + let inner_width = card_width.saturating_sub(2) as usize; + let separator = format!( + "\u{251c}{}\u{2524}", // ├ ... ┤ + "\u{2500}".repeat(inner_width) // ─ + ); + + let paragraph = Paragraph::new(Span::styled(separator, style)); + + // Render at x offset to overlap the border (area is inside padding, border is 2 chars left) + let sep_area = Rect { + x: area.x.saturating_sub(2), // move left to overlap left border + y: area.y, + width: card_width, + height: 1, + }; + frame.render_widget(paragraph, sep_area); +} + +/// Calculate total height for all content items in a block +fn calculate_block_height(content: &[Content], width: usize) -> u16 { + let content_height: u16 = content + .iter() + .map(|c| calculate_single_content_height(c, width)) + .sum(); + + // Add spacing between items (n-1 blank lines for n items) + let spacing = if content.len() > 1 { + (content.len() - 1) as u16 + } else { + 0 + }; + + // Add 1 for trailing blank line (padding after content) + content_height.saturating_add(spacing).saturating_add(1) +} + +/// Calculate height for a single content item. +/// Uses ratatui's Paragraph::line_count for consistency with rendering. +fn calculate_single_content_height(content: &Content, width: usize) -> u16 { + // Text area is offset by 2 for symbol column + let text_width = width.saturating_sub(2); + + match content { + // Input uses word wrapping (WrapMode::Word) in TextArea, which can produce + // more lines than character wrapping since it won't break words mid-word + Content::Input { text, active, .. } => { + if *active { + // For active input, use word-wrap line counting to match TextArea behavior + let (lines, last_line_width) = + word_wrap_line_count_with_last_width(text, text_width); + // Only add extra line for cursor if the last line is full + if last_line_width >= text_width { + lines.saturating_add(1) + } else { + lines + } + } else { + line_count_wrapped(text, text_width) + } + } + Content::Command { text, .. } => line_count_wrapped(text, text_width), + Content::Text { markdown } => line_count_wrapped(markdown, text_width), + Content::Error { message } => line_count_wrapped(message, text_width), + Content::Warning { + text, + pending_confirm, + .. + } => { + let display_text = if *pending_confirm { + "Press Enter again to run this dangerous command" + } else { + text.as_str() + }; + line_count_wrapped(display_text, text_width) + } + Content::Spinner { .. } => 1, + Content::ToolStatus { .. } => 1, + } +} + +/// Count lines when text is wrapped at given width. +/// Uses ratatui's Paragraph::line_count for accurate wrapping calculation. +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. +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 { + // First word on line + if word_width > width { + // Word is longer than width, it will be split by character + // Count how many lines it takes + 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 { + // Subsequent word - need space before it + let needed = current_line_width + 1 + word_width; + if needed > width { + // Word doesn't fit, start new line + line_count += 1; + if word_width > width { + // Word itself is too long, will be split + 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; + } + } + } + + // Count the last line of this logical line + if line_started { + line_count += 1; + } + } + + // Handle case where text has no lines() output (empty or just whitespace) + if line_count == 0 { + line_count = 1; + current_line_width = 0; + } + + (line_count, current_line_width) +} + +/// Convert markdown to styled spans (existing function, kept as-is) +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 new file mode 100644 index 00000000..138e0269 --- /dev/null +++ b/crates/atuin-ai/src/tui/spinner.rs @@ -0,0 +1,99 @@ +//! 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 new file mode 100644 index 00000000..ba9c8ac6 --- /dev/null +++ b/crates/atuin-ai/src/tui/state.rs @@ -0,0 +1,530 @@ +//! Domain state types for the TUI application +//! +//! 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}; + +/// Streaming status indicators from server +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamingStatus { + Processing, + Searching, + Thinking, + WaitingForTools, +} + +impl StreamingStatus { + pub fn from_status_str(s: &str) -> Self { + match s { + "processing" => Self::Processing, + "searching" => Self::Searching, + "waiting_for_tools" => Self::WaitingForTools, + _ => Self::Thinking, // Default to thinking for "thinking" and unknown + } + } + + pub fn display_text(&self) -> &'static str { + match self { + Self::Processing => "Processing...", + Self::Searching => "Searching...", + Self::Thinking => "Thinking...", + Self::WaitingForTools => "Waiting for tools...", + } + } +} + +/// Conversation event types matching the API protocol +#[derive(Debug, Clone)] +pub enum ConversationEvent { + /// User message (what the user typed) + UserMessage { content: String }, + /// Text content from assistant (streamed or complete) + Text { content: String }, + /// Tool call from assistant + ToolCall { + id: String, + name: String, + input: serde_json::Value, + }, + /// Tool result (usually from server-side execution) + ToolResult { + tool_use_id: String, + content: String, + is_error: bool, + }, +} + +impl ConversationEvent { + /// Convert to JSON for API calls + pub fn to_json(&self) -> serde_json::Value { + match self { + ConversationEvent::UserMessage { content } => serde_json::json!({ + "type": "user_message", + "content": content + }), + ConversationEvent::Text { content } => serde_json::json!({ + "type": "text", + "content": content + }), + ConversationEvent::ToolCall { id, name, input } => serde_json::json!({ + "type": "tool_call", + "id": id, + "name": name, + "input": input + }), + ConversationEvent::ToolResult { + tool_use_id, + content, + is_error, + } => serde_json::json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + "is_error": is_error + }), + } + } + + /// Extract command from a suggest_command tool call + pub fn as_command(&self) -> Option<&str> { + 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 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppMode { + /// User is typing input + Input, + /// Waiting for generation (showing spinner) + Generating, + /// Streaming SSE response + Streaming, + /// Reviewing generated command + Review, + /// Error state, can retry + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExitAction { + /// Run the command + Execute(String), + /// Insert command without running + Insert(String), + /// User canceled + Cancel, +} + +/// 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()`. +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) + 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) + pub session_id: Option<String>, + /// Current streaming status (for spinner text) + pub streaming_status: Option<StreamingStatus>, + /// 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 +} + +impl AppState { + pub fn new() -> Self { + 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, + was_interrupted: false, + spinner_frame: 0, + last_spinner_tick: Instant::now(), + streaming_started: None, + confirmation_pending: false, + } + } + + /// 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; + let events = &self.events; + + while i < events.len() { + match &events[i] { + ConversationEvent::UserMessage { content } => { + messages.push(serde_json::json!({ + "role": "user", + "content": content + })); + i += 1; + } + ConversationEvent::Text { content } => { + messages.push(serde_json::json!({ + "role": "assistant", + "content": content + })); + 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] { + tool_uses.push(serde_json::json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input + })); + i += 1; + } else { + break; + } + } + messages.push(serde_json::json!({ + "role": "assistant", + "content": tool_uses + })); + } + ConversationEvent::ToolResult { + tool_use_id, + content, + is_error, + } => { + messages.push(serde_json::json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + "is_error": is_error + }] + })); + i += 1; + } + } + } + + messages + } + + // ===== 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(); + 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); + self.mode = AppMode::Error; + } + + /// Cancel during generation + pub fn cancel_generation(&mut self) { + // Remove the last user message since generation was cancelled + if let Some(ConversationEvent::UserMessage { .. }) = self.events.last() { + self.events.pop(); + } + self.mode = AppMode::Input; + self.clear_input(); + } + + // ===== Streaming lifecycle methods ===== + + /// Start streaming response + pub fn start_streaming(&mut self) { + self.streaming_text.clear(); + self.streaming_status = None; + self.was_interrupted = false; + self.streaming_started = Some(Instant::now()); + self.mode = AppMode::Streaming; + } + + /// Store session ID from server response + pub fn store_session_id(&mut self, session_id: String) { + self.session_id = Some(session_id); + } + + /// Update streaming status from SSE event + pub fn update_streaming_status(&mut self, status: &str) { + self.streaming_status = Some(StreamingStatus::from_status_str(status)); + } + + /// Cancel streaming with context preservation + pub fn cancel_streaming(&mut self) { + // Mark as interrupted + 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, + }); + } + + // 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 + 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); + } + } 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 + 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(), + }); + } + + // 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; + } + } + + /// Add a tool result event during streaming + pub fn add_tool_result(&mut self, tool_use_id: String, content: String, is_error: bool) { + self.events.push(ConversationEvent::ToolResult { + tool_use_id, + content, + is_error, + }); + } + + /// Finalize streaming - flush accumulated text to event + 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(), + }); + } + self.streaming_status = None; + self.streaming_started = None; + self.mode = AppMode::Review; + } + + /// Streaming error + pub fn streaming_error(&mut self, error: String) { + // Discard any partial streaming text + self.streaming_text.clear(); + self.streaming_started = None; + self.error = Some(error); + self.mode = AppMode::Error; + } + + // ===== 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 ===== + + /// 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(); + } + } + + /// 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 + pub fn is_current_command_dangerous(&self) -> bool { + self.events + .iter() + .rev() + .find_map(|e| { + if let ConversationEvent::ToolCall { name, input, .. } = e + && name == "suggest_command" + { + let danger_level = input + .get("danger") + .and_then(|v| v.as_str()) + .unwrap_or("low"); + return Some( + danger_level == "high" || danger_level == "medium" || danger_level == "med", + ); + } + None + }) + .unwrap_or(false) + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs new file mode 100644 index 00000000..2e0bcbaa --- /dev/null +++ b/crates/atuin-ai/src/tui/terminal.rs @@ -0,0 +1,203 @@ +use crossterm::{ + cursor, + terminal::{disable_raw_mode, enable_raw_mode}, +}; +use eyre::{Context, Result, bail}; +use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend}; +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()?; +/// 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, +} + +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, + }) + } + + /// 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 + } + + /// 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) { + // 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_model.rs b/crates/atuin-ai/src/tui/view_model.rs new file mode 100644 index 00000000..e89932d9 --- /dev/null +++ b/crates/atuin-ai/src/tui/view_model.rs @@ -0,0 +1,400 @@ +//! 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>, +} + +/// Complete view model - the rendering specification +#[derive(Debug, Clone)] +pub struct Blocks { + pub items: Vec<Block>, + pub footer: &'static str, +} + +/// 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(); + + // 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 (tool status + streaming text) - shown during Streaming only + // 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); + let mut response_content = Vec::new(); + + // Add tool status if there are any non-suggest_command tools + if completed > 0 || in_flight.is_some() { + response_content.push(Content::ToolStatus { + completed_count: completed, + current_label: in_flight.clone(), + frame: state.spinner_frame, + }); + } + + // Add streaming text or spinner + if state.streaming_text.is_empty() { + // Check if enough time has passed to show spinner (200ms delay) + // Show spinner immediately if status event has arrived + 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() { + // Only show generating spinner if no tool is in-flight + let status_text = state + .streaming_status + .as_ref() + .map(|s| s.display_text().to_string()) + .unwrap_or_else(|| "Generating...".to_string()); + + response_content.push(Content::Spinner { + frame: state.spinner_frame, + status_text, + }); + } + } else { + // Show streaming text + response_content.push(Content::Text { + markdown: state.streaming_text.clone(), + }); + } + + // Add the response block if there's any content + if !response_content.is_empty() { + items.push(Block { + content: response_content, + 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()); + + items.push(Block { + content: vec![Content::Spinner { + frame: state.spinner_frame, + status_text, + }], + separator_above: false, + title: None, + }); + } + 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 } + } + + /// 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", + } + } +} |
