aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-02-24 11:48:20 -0800
committerGitHub <noreply@github.com>2026-02-24 11:48:20 -0800
commit6ea760bb6b36da241961e8ecd60cb2c5e15c0a78 (patch)
tree18ebbb710cea24e30bc69b5d6bc807518a950746 /crates/atuin-ai/src/tui
parentfix: forward $PATH to tmux popup in zsh (#3198) (diff)
downloadatuin-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.rs157
-rw-r--r--crates/atuin-ai/src/tui/event.rs303
-rw-r--r--crates/atuin-ai/src/tui/mod.rs14
-rw-r--r--crates/atuin-ai/src/tui/render.rs674
-rw-r--r--crates/atuin-ai/src/tui/spinner.rs99
-rw-r--r--crates/atuin-ai/src/tui/state.rs530
-rw-r--r--crates/atuin-ai/src/tui/terminal.rs203
-rw-r--r--crates/atuin-ai/src/tui/view_model.rs400
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",
+ }
+ }
+}