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