aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-ai/Cargo.toml7
-rw-r--r--crates/atuin-ai/src/commands.rs45
-rw-r--r--crates/atuin-ai/src/commands/debug_render.rs466
-rw-r--r--crates/atuin-ai/src/commands/inline.rs680
-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
-rw-r--r--crates/atuin/src/command/client.rs2
24 files changed, 1847 insertions, 3660 deletions
diff --git a/crates/atuin-ai/Cargo.toml b/crates/atuin-ai/Cargo.toml
index 1b81646c..a62e3274 100644
--- a/crates/atuin-ai/Cargo.toml
+++ b/crates/atuin-ai/Cargo.toml
@@ -31,14 +31,17 @@ reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
crossterm = { workspace = true, features = ["use-dev-tty", "event-stream"] }
-ratatui = { workspace = true, features = ["unstable-rendered-line-info"] }
+ratatui = { workspace = true }
futures = "0.3"
eventsource-stream = "0.2"
pulldown-cmark = "0.13.0"
async-stream = "0.3"
uuid = { workspace = true }
-tui-textarea-2 = "0.9.1"
+tui-textarea-2 = "0.10.2"
unicode-width = "0.2"
+eye_declare = "0.1"
+ratatui-core = "0.1"
+ratatui-widgets = "0.3"
[dev-dependencies]
pretty_assertions = { workspace = true }
diff --git a/crates/atuin-ai/src/commands.rs b/crates/atuin-ai/src/commands.rs
index d04875ea..6e79da61 100644
--- a/crates/atuin-ai/src/commands.rs
+++ b/crates/atuin-ai/src/commands.rs
@@ -8,9 +8,6 @@ use clap::{Args, Subcommand};
use eyre::Result;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt};
-#[cfg(debug_assertions)]
-pub mod debug_render;
-
pub mod init;
pub mod inline;
@@ -47,29 +44,9 @@ pub enum Commands {
#[arg(value_name = "COMMAND")]
command: Option<String>,
- /// Keep TUI output visible after exit (default: erase)
- #[arg(long)]
- keep: bool,
-
/// Use the hook mode
#[arg(long, hide = true)]
hook: bool,
-
- /// Log state changes to file for debugging (dev tool)
- #[arg(long, value_name = "FILE", hide = true)]
- debug_state: Option<String>,
- },
-
- /// Debug render: output a single frame from JSON state (dev tool)
- #[cfg(debug_assertions)]
- DebugRender {
- /// Input file (reads from stdin if not provided)
- #[arg(short, long)]
- input: Option<String>,
-
- /// Output format: ansi (default), plain, json
- #[arg(short, long, default_value = "ansi")]
- format: String,
},
}
@@ -81,8 +58,6 @@ pub async fn run(
Commands::Init { shell } => init::run(shell).await,
Commands::Inline {
command,
- keep,
- debug_state,
hook,
args,
..
@@ -91,25 +66,7 @@ pub async fn run(
init_logging(settings, args.verbose)?;
}
- inline::run(
- command,
- args.api_endpoint,
- args.api_token,
- keep,
- debug_state,
- settings,
- hook,
- )
- .await
- }
- #[cfg(debug_assertions)]
- Commands::DebugRender { input, format } => {
- let output_format = match format.as_str() {
- "plain" => debug_render::OutputFormat::Plain,
- "json" => debug_render::OutputFormat::Json,
- _ => debug_render::OutputFormat::Ansi,
- };
- debug_render::run(input, output_format).await
+ inline::run(command, args.api_endpoint, args.api_token, settings, hook).await
}
}
}
diff --git a/crates/atuin-ai/src/commands/debug_render.rs b/crates/atuin-ai/src/commands/debug_render.rs
deleted file mode 100644
index b35d73c9..00000000
--- a/crates/atuin-ai/src/commands/debug_render.rs
+++ /dev/null
@@ -1,466 +0,0 @@
-//! Debug render command for TUI development
-//!
-//! Takes JSON state as input and outputs a single rendered frame as text.
-//! Useful for debugging view model derivation and rendering without running the full TUI.
-
-use eyre::{Context, Result};
-use ratatui::{Terminal, backend::TestBackend};
-use serde::Deserialize;
-use std::io::{self, Read};
-use std::time::Instant;
-
-use crate::tui::{
- render::{RenderContext, render},
- state::{AppMode, AppState, ConversationEvent, StreamingStatus},
- view_model::Blocks,
-};
-
-/// JSON input format for debug rendering
-#[derive(Debug, Deserialize)]
-pub struct DebugInput {
- /// Conversation events in API format
- pub events: Vec<EventInput>,
- /// Current mode: "Input", "Generating", "Streaming", "Review", "Error"
- #[serde(default = "default_mode")]
- pub mode: String,
- /// Text being streamed (for Streaming mode)
- #[serde(default)]
- pub streaming_text: String,
- /// Current input buffer
- #[serde(default)]
- pub input: String,
- /// Cursor position
- #[serde(default)]
- pub cursor_pos: usize,
- /// Spinner frame (0-3)
- #[serde(default)]
- pub spinner_frame: usize,
- /// Error message
- #[serde(default)]
- pub error: Option<String>,
- /// Session ID from server
- #[serde(default)]
- pub session_id: Option<String>,
- /// Streaming status
- #[serde(default)]
- pub streaming_status: Option<String>,
- /// Whether current turn was interrupted
- #[serde(default)]
- pub was_interrupted: bool,
- /// Terminal width for rendering
- #[serde(default = "default_width")]
- pub width: u16,
- /// Terminal height for rendering
- #[serde(default = "default_height")]
- pub height: u16,
-}
-
-fn default_mode() -> String {
- "Review".to_string()
-}
-
-fn default_width() -> u16 {
- 80
-}
-
-fn default_height() -> u16 {
- // Default to a reasonable height; state files include calculated height
- 50
-}
-
-/// Event input matching the API protocol format
-#[derive(Debug, Clone, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-pub enum EventInput {
- UserMessage {
- content: String,
- },
- Text {
- content: String,
- },
- ToolCall {
- id: String,
- name: String,
- input: serde_json::Value,
- },
- ToolResult {
- tool_use_id: String,
- content: String,
- #[serde(default)]
- is_error: bool,
- },
-}
-
-impl From<EventInput> for ConversationEvent {
- fn from(input: EventInput) -> Self {
- match input {
- EventInput::UserMessage { content } => ConversationEvent::UserMessage { content },
- EventInput::Text { content } => ConversationEvent::Text { content },
- EventInput::ToolCall { id, name, input } => {
- ConversationEvent::ToolCall { id, name, input }
- }
- EventInput::ToolResult {
- tool_use_id,
- content,
- is_error,
- } => ConversationEvent::ToolResult {
- tool_use_id,
- content,
- is_error,
- },
- }
- }
-}
-
-impl DebugInput {
- /// Parse JSON from string
- pub fn from_json(json: &str) -> Result<Self> {
- serde_json::from_str(json).context("Failed to parse debug input JSON")
- }
-
- /// Convert to AppState
- pub fn to_state(&self) -> AppState {
- let mode = match self.mode.as_str() {
- "Input" => AppMode::Input,
- "Generating" => AppMode::Generating,
- "Streaming" => AppMode::Streaming,
- "Review" => AppMode::Review,
- "Error" => AppMode::Error,
- _ => AppMode::Review,
- };
-
- let events: Vec<ConversationEvent> = self.events.iter().cloned().map(Into::into).collect();
-
- let streaming_status = self
- .streaming_status
- .as_ref()
- .map(|s| StreamingStatus::from_status_str(s));
-
- // Create textarea from input and set cursor position
- let mut textarea = tui_textarea::TextArea::from(self.input.lines());
- // Disable underline on cursor line
- textarea.set_cursor_line_style(ratatui::style::Style::default());
- // Enable word wrapping
- textarea.set_wrap_mode(tui_textarea::WrapMode::Word);
- // Note: cursor_pos from old format is character-based; new format has row/col
- // For compatibility, just move to end if we have text
- if !self.input.is_empty() {
- textarea.move_cursor(tui_textarea::CursorMove::End);
- }
-
- AppState {
- mode,
- events,
- streaming_text: self.streaming_text.clone(),
- textarea,
- error: self.error.clone(),
- should_exit: false,
- exit_action: None,
- session_id: self.session_id.clone(),
- streaming_status,
- was_interrupted: self.was_interrupted,
- spinner_frame: self.spinner_frame,
- last_spinner_tick: Instant::now(),
- streaming_started: None,
- confirmation_pending: false,
- }
- }
-}
-
-/// Output format options
-#[derive(Debug, Clone, Copy, Default)]
-pub enum OutputFormat {
- /// Raw terminal output (ANSI)
- #[default]
- Ansi,
- /// Plain text (strips ANSI codes)
- Plain,
- /// JSON with blocks structure
- Json,
-}
-
-/// Run the debug render command
-pub async fn run(input_file: Option<String>, format: OutputFormat) -> Result<()> {
- // Read input JSON
- let json = if let Some(path) = input_file {
- std::fs::read_to_string(&path).context(format!("Failed to read input file: {}", path))?
- } else {
- let mut buffer = String::new();
- io::stdin()
- .read_to_string(&mut buffer)
- .context("Failed to read from stdin")?;
- buffer
- };
-
- let debug_input = DebugInput::from_json(&json)?;
- let state = debug_input.to_state();
-
- match format {
- OutputFormat::Json => {
- // Output the derived blocks as JSON
- let blocks = Blocks::from_state(&state);
- println!(
- "{}",
- serde_json::to_string_pretty(&blocks_to_json(&blocks))?
- );
- }
- OutputFormat::Plain | OutputFormat::Ansi => {
- // Render to a test backend
- let backend = TestBackend::new(debug_input.width, debug_input.height);
- let mut terminal = Terminal::new(backend)?;
-
- // Load default theme
- let settings = atuin_client::settings::Settings::new()?;
- let mut theme_manager = atuin_client::theme::ThemeManager::new(None, None);
- let theme = theme_manager.load_theme(&settings.theme.name, None);
-
- let ctx = RenderContext {
- theme,
- anchor_col: 0,
- textarea: Some(&state.textarea),
- max_height: debug_input.height,
- popup_mode: false,
- render_above: false,
- };
-
- terminal.draw(|frame| {
- render(frame, &state, &ctx);
- })?;
-
- // Get buffer content
- let buffer = terminal.backend().buffer();
- let output = buffer_to_string(buffer, matches!(format, OutputFormat::Plain));
- print!("{}", output);
- }
- }
-
- Ok(())
-}
-
-/// Convert blocks to JSON for debugging
-fn blocks_to_json(blocks: &Blocks) -> serde_json::Value {
- serde_json::json!({
- "count": blocks.items.len(),
- "blocks": blocks.items.iter().map(|block| {
- serde_json::json!({
- "separator_above": block.separator_above,
- "title": block.title,
- "content": block.content.iter().map(content_to_json).collect::<Vec<_>>()
- })
- }).collect::<Vec<_>>(),
- "status_bar": blocks.status_bar.as_ref().map(|sb| serde_json::json!({
- "frame": sb.frame,
- "text": sb.text
- }))
- })
-}
-
-fn content_to_json(content: &crate::tui::view_model::Content) -> serde_json::Value {
- use crate::tui::view_model::Content;
- match content {
- Content::Input {
- text,
- active,
- cursor_pos,
- } => serde_json::json!({
- "type": "Input",
- "text": text,
- "active": active,
- "cursor_pos": cursor_pos
- }),
- Content::Command { text, faded } => serde_json::json!({
- "type": "Command",
- "text": text,
- "faded": faded
- }),
- Content::Text { markdown } => serde_json::json!({
- "type": "Text",
- "markdown": markdown
- }),
- Content::Error { message } => serde_json::json!({
- "type": "Error",
- "message": message
- }),
- Content::Warning {
- kind,
- text,
- pending_confirm,
- } => serde_json::json!({
- "type": "Warning",
- "kind": format!("{:?}", kind),
- "text": text,
- "pending_confirm": pending_confirm
- }),
- Content::Spinner { frame, status_text } => serde_json::json!({
- "type": "Spinner",
- "frame": frame,
- "status_text": status_text
- }),
- Content::ToolStatus {
- completed_count,
- current_label,
- frame,
- } => serde_json::json!({
- "type": "ToolStatus",
- "completed_count": completed_count,
- "current_label": current_label,
- "frame": frame
- }),
- }
-}
-
-/// Convert ratatui buffer to string
-fn buffer_to_string(buffer: &ratatui::buffer::Buffer, strip_ansi: bool) -> String {
- let area = buffer.area;
- let mut output = String::new();
-
- for y in 0..area.height {
- for x in 0..area.width {
- let cell = &buffer[(x, y)];
- if strip_ansi {
- output.push_str(cell.symbol());
- } else {
- // Include ANSI styling
- let fg = cell.fg;
- let bg = cell.bg;
- let mods = cell.modifier;
-
- // Simple ANSI encoding
- if fg != ratatui::style::Color::Reset
- || bg != ratatui::style::Color::Reset
- || !mods.is_empty()
- {
- output.push_str("\x1b[");
- let mut first = true;
-
- if mods.contains(ratatui::style::Modifier::BOLD) {
- output.push('1');
- first = false;
- }
- if mods.contains(ratatui::style::Modifier::DIM) {
- if !first {
- output.push(';');
- }
- output.push('2');
- first = false;
- }
- if mods.contains(ratatui::style::Modifier::REVERSED) {
- if !first {
- output.push(';');
- }
- output.push('7');
- first = false;
- }
- if mods.contains(ratatui::style::Modifier::UNDERLINED) {
- if !first {
- output.push(';');
- }
- output.push('4');
- first = false;
- }
-
- if let Some(code) = color_to_ansi(fg, true) {
- if !first {
- output.push(';');
- }
- output.push_str(&code);
- first = false;
- }
-
- if let Some(code) = color_to_ansi(bg, false) {
- if !first {
- output.push(';');
- }
- output.push_str(&code);
- }
-
- output.push('m');
- }
-
- output.push_str(cell.symbol());
-
- if fg != ratatui::style::Color::Reset
- || bg != ratatui::style::Color::Reset
- || !mods.is_empty()
- {
- output.push_str("\x1b[0m");
- }
- }
- }
- output.push('\n');
- }
-
- output
-}
-
-fn color_to_ansi(color: ratatui::style::Color, foreground: bool) -> Option<String> {
- use ratatui::style::Color;
- let base = if foreground { 30 } else { 40 };
-
- match color {
- Color::Reset => None,
- Color::Black => Some((base).to_string()),
- Color::Red => Some((base + 1).to_string()),
- Color::Green => Some((base + 2).to_string()),
- Color::Yellow => Some((base + 3).to_string()),
- Color::Blue => Some((base + 4).to_string()),
- Color::Magenta => Some((base + 5).to_string()),
- Color::Cyan => Some((base + 6).to_string()),
- Color::Gray | Color::White => Some((base + 7).to_string()),
- Color::DarkGray => Some((base + 60).to_string()),
- Color::LightRed => Some((base + 61).to_string()),
- Color::LightGreen => Some((base + 62).to_string()),
- Color::LightYellow => Some((base + 63).to_string()),
- Color::LightBlue => Some((base + 64).to_string()),
- Color::LightMagenta => Some((base + 65).to_string()),
- Color::LightCyan => Some((base + 66).to_string()),
- Color::Indexed(i) => Some(format!("{}8;5;{}", if foreground { 3 } else { 4 }, i)),
- Color::Rgb(r, g, b) => Some(format!(
- "{}8;2;{};{};{}",
- if foreground { 3 } else { 4 },
- r,
- g,
- b
- )),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_parse_simple_input() {
- let json = r#"{
- "events": [
- {"type": "user_message", "content": "list files"},
- {"type": "tool_call", "id": "123", "name": "suggest_command", "input": {"command": "ls -la"}}
- ],
- "mode": "Review"
- }"#;
-
- let input = DebugInput::from_json(json).unwrap();
- assert_eq!(input.events.len(), 2);
- assert_eq!(input.mode, "Review");
-
- let state = input.to_state();
- assert_eq!(state.events.len(), 2);
- assert_eq!(state.mode, AppMode::Review);
- }
-
- #[test]
- fn test_parse_streaming_state() {
- let json = r#"{
- "events": [
- {"type": "user_message", "content": "explain flags"}
- ],
- "mode": "Streaming",
- "streaming_text": "The -l flag means..."
- }"#;
-
- let input = DebugInput::from_json(json).unwrap();
- let state = input.to_state();
- assert_eq!(state.mode, AppMode::Streaming);
- assert_eq!(state.streaming_text, "The -l flag means...");
- }
-}
diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs
index 7ceaf5b5..c16e3dac 100644
--- a/crates/atuin-ai/src/commands/inline.rs
+++ b/crates/atuin-ai/src/commands/inline.rs
@@ -1,29 +1,22 @@
+use std::sync::mpsc;
+
use crate::commands::detect_shell;
-use crate::tui::render::render;
-use crate::tui::{
- App, AppEvent, AppMode, ConversationEvent, EventLoop, ExitAction, RenderContext, TerminalGuard,
- calculate_needed_height, install_panic_hook,
-};
+use crate::tui::events::AiTuiEvent;
+use crate::tui::state::{AppState, ExitAction};
+use crate::tui::view::ai_view;
use atuin_client::distro::detect_linux_distribution;
-use atuin_client::theme::ThemeManager;
use atuin_common::tls::ensure_crypto_provider;
-use crossterm::{
- event::{self, Event, KeyCode},
- terminal::{disable_raw_mode, enable_raw_mode},
-};
use eventsource_stream::Eventsource;
+use eye_declare::{Application, CtrlCBehavior, Handle};
use eyre::{Context as _, Result, bail};
use futures::StreamExt;
use reqwest::Url;
-use std::io::Write;
use tracing::{debug, error, info, trace};
pub async fn run(
initial_command: Option<String>,
api_endpoint: Option<String>,
api_token: Option<String>,
- keep_output: bool,
- debug_state_file: Option<String>,
settings: &atuin_client::settings::Settings,
output_for_hook: bool,
) -> Result<()> {
@@ -38,13 +31,6 @@ pub async fn run(
return Ok(());
}
- // Install panic hook once at entry point to ensure terminal restoration
- install_panic_hook();
-
- // Token and endpoint priority:
- // 1. Command line arguments/environment variables
- // 2. Settings file
- // 3. Default
let endpoint = api_endpoint.as_deref().unwrap_or(
settings
.ai
@@ -57,23 +43,10 @@ pub async fn run(
let token = if let Some(token) = &api_token {
token.to_string()
} else {
- // ensure_hub_session will authenticate against settings.active_hub_endpoint().unwrap_or_default(),
- // which is the default Hub endpoint if no endpoint is provided
- //
- // TODO[mkt]: Atuin AI and the Hub sync endpoint are too tightly coupled;
- // current setup means that Hub endpoint controls auth while AI endpoint controls AI conversations
ensure_hub_session(settings).await?
};
- let action = run_inline_tui(
- endpoint.to_string(),
- token,
- initial_command,
- keep_output,
- debug_state_file,
- settings,
- )
- .await?;
+ let action = run_inline_tui(endpoint.to_string(), token, initial_command, settings).await?;
emit_shell_result(action, output_for_hook);
Ok(())
@@ -86,7 +59,6 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
}
let hub_address = settings.active_hub_endpoint().unwrap_or_default();
-
let will_sync = settings.is_hub_sync();
info!("No Hub session found, prompting for authentication");
@@ -106,8 +78,8 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
}
debug!("Starting Atuin Hub authentication...");
-
println!("Authenticating with Atuin Hub...");
+
let session = atuin_client::hub::HubAuthSession::start(hub_address.as_ref()).await?;
println!("Open this URL to continue:");
println!("{}", session.auth_url);
@@ -120,17 +92,13 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
.await?;
info!("Authentication complete, saving session token");
-
atuin_client::hub::save_session(&token).await?;
- // Silently attempt to link CLI account to Hub if one exists
- // This enables unified auth - users can use their Hub token for sync
if let Ok(meta) = atuin_client::settings::Settings::meta_store().await
&& let Ok(Some(cli_token)) = meta.session_token().await
{
debug!("CLI session found, attempting to link accounts");
if let Err(e) = atuin_client::hub::link_account(hub_address.as_ref(), &cli_token).await {
- // Don't fail AI flow if linking fails - it's not critical
debug!("Could not link CLI account to Hub: {}", e);
} else {
info!("Successfully linked CLI account to Hub");
@@ -140,28 +108,27 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu
Ok(token)
}
-/// SSE event received from chat endpoint
+// ───────────────────────────────────────────────────────────────────
+// SSE streaming
+// ───────────────────────────────────────────────────────────────────
+
#[derive(Debug, Clone)]
enum ChatStreamEvent {
- /// Text chunk to display
TextChunk(String),
- /// Tool call event (need to echo back, may contain suggest_command)
ToolCall {
id: String,
name: String,
input: serde_json::Value,
},
- /// Tool result from server-side execution
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
- /// Status update from server
Status(String),
- /// Stream complete
- Done { session_id: String },
- /// Error from server
+ Done {
+ session_id: String,
+ },
Error(String),
}
@@ -170,10 +137,8 @@ fn create_chat_stream(
token: String,
session_id: Option<String>,
messages: Vec<serde_json::Value>,
- settings: &atuin_client::settings::Settings,
+ send_cwd: bool,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>> {
- let send_cwd = settings.ai.send_cwd;
-
Box::pin(async_stream::stream! {
ensure_crypto_provider();
let endpoint = match hub_url(&hub_address, "/api/cli/chat") {
@@ -201,19 +166,16 @@ fn create_chat_stream(
context["distro"] = serde_json::json!(detect_linux_distribution());
}
- // Build request body
let mut request_body = serde_json::json!({
"messages": messages,
"context": context,
});
- // Include session_id only if present (not on first request)
if let Some(ref sid) = session_id {
trace!("Including session_id in request: {sid}");
request_body["session_id"] = serde_json::json!(sid);
}
-
let client = reqwest::Client::new();
let response = match client
.post(endpoint.clone())
@@ -232,7 +194,6 @@ fn create_chat_stream(
let status = response.status();
if status == reqwest::StatusCode::UNAUTHORIZED {
- // Clear saved session on auth error
error!("SSE request failed with status: {status}, clearing session");
let _ = atuin_client::hub::delete_session().await;
yield Err(eyre::eyre!("Hub session expired. Re-run to authenticate again."));
@@ -310,9 +271,7 @@ fn create_chat_stream(
}
break;
}
- _ => {
- // Unknown event type, ignore
- }
+ _ => {}
}
}
Err(e) => {
@@ -324,404 +283,296 @@ fn create_chat_stream(
})
}
-fn hub_url(base: &str, path: &str) -> Result<Url> {
- let base_with_slash = if base.ends_with('/') {
- base.to_string()
- } else {
- format!("{base}/")
- };
- let stripped = path.strip_prefix('/').unwrap_or(path);
- Url::parse(&base_with_slash)?
- .join(stripped)
- .context("failed to build hub URL")
-}
-
-fn detect_os() -> String {
- match std::env::consts::OS {
- "macos" => "macos".to_string(),
- "linux" => "linux".to_string(),
- "windows" => "windows".to_string(),
- other => format!("Other: {other}"),
- }
-}
-
-#[derive(Clone)]
-enum Action {
- Execute(String),
- Insert(String),
- Print(String),
- Cancel,
-}
-
-/// Serialize AppState to JSON for debug logging
-fn state_to_json(state: &crate::tui::AppState) -> serde_json::Value {
- let events: Vec<serde_json::Value> = state.events.iter().map(|e| e.to_json()).collect();
-
- let mode = match state.mode {
- AppMode::Input => "Input",
- AppMode::Generating => "Generating",
- AppMode::Streaming => "Streaming",
- AppMode::Review => "Review",
- AppMode::Error => "Error",
- };
-
- // Get input and cursor from textarea
- let input = state.input();
- let cursor = state.textarea.cursor();
-
- let mut json = serde_json::json!({
- "events": events,
- "mode": mode,
- "input": input,
- "cursor_row": cursor.0,
- "cursor_col": cursor.1,
- "spinner_frame": state.spinner_frame,
- "confirmation_pending": state.confirmation_pending,
- });
+// ───────────────────────────────────────────────────────────────────
+// Async streaming task — pushes updates to app state via Handle
+// ───────────────────────────────────────────────────────────────────
- // Add streaming fields if in streaming mode
- if !state.streaming_text.is_empty() {
- json["streaming_text"] = serde_json::json!(state.streaming_text);
- }
- if let Some(ref status) = state.streaming_status {
- json["streaming_status"] = serde_json::json!(status.display_text());
- }
- if let Some(ref err) = state.error {
- json["error"] = serde_json::json!(err);
- }
-
- json
-}
-
-/// Debug logger that writes state changes to a file
-struct DebugStateLogger {
- file: std::fs::File,
- entry_count: usize,
- width: u16,
-}
-
-impl DebugStateLogger {
- fn new(path: &str) -> Result<Self> {
- let file = std::fs::File::create(path)
- .with_context(|| format!("Failed to create debug state file: {}", path))?;
- // Get terminal width, default to 80
- let (width, _) = crossterm::terminal::size().unwrap_or((80, 24));
- Ok(Self {
- file,
- entry_count: 0,
- width,
- })
- }
-
- fn log(&mut self, label: &str, state: &crate::tui::AppState) {
- use crate::tui::calculate_needed_height;
-
- self.entry_count += 1;
- let timestamp_ms = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_millis())
- .unwrap_or(0);
-
- // Calculate the actual content height needed for this state
- let content_height = calculate_needed_height(state, 0);
-
- let mut state_json = state_to_json(state);
- // Add dimensions for accurate replay
- state_json["width"] = serde_json::json!(self.width);
- state_json["height"] = serde_json::json!(content_height);
-
- let entry = serde_json::json!({
- "entry": self.entry_count,
- "label": label,
- "timestamp_ms": timestamp_ms,
- "state": state_json,
- });
+async fn run_chat_stream(
+ handle: Handle<AppState>,
+ endpoint: String,
+ token: String,
+ session_id: Option<String>,
+ messages: Vec<serde_json::Value>,
+ send_cwd: bool,
+) {
+ let stream = create_chat_stream(endpoint, token, session_id, messages, send_cwd);
+ futures::pin_mut!(stream);
- // Write as JSONL (one JSON object per line)
- if let Err(e) = writeln!(self.file, "{}", entry) {
- tracing::warn!("Failed to write debug state: {}", e);
+ while let Some(event) = stream.next().await {
+ match event {
+ Ok(ChatStreamEvent::TextChunk(text)) => {
+ trace!(text = %text, "Processing TextChunk");
+ handle.update(move |state| {
+ state.append_streaming_text(&text);
+ });
+ }
+ Ok(ChatStreamEvent::ToolCall { id, name, input }) => {
+ trace!(id = %id, name = %name, "Processing ToolCall");
+ handle.update(move |state| {
+ state.add_tool_call(id, name, input);
+ });
+ }
+ Ok(ChatStreamEvent::ToolResult {
+ tool_use_id,
+ content,
+ is_error,
+ }) => {
+ trace!(tool_use_id = %tool_use_id, "Processing ToolResult");
+ handle.update(move |state| {
+ state.add_tool_result(tool_use_id, content, is_error);
+ });
+ }
+ Ok(ChatStreamEvent::Status(status)) => {
+ trace!(status = %status, "Processing Status");
+ handle.update(move |state| {
+ state.update_streaming_status(&status);
+ });
+ }
+ Ok(ChatStreamEvent::Done { session_id }) => {
+ trace!(session_id = %session_id, "Processing Done");
+ handle.update(move |state| {
+ if !session_id.is_empty() {
+ state.store_session_id(session_id);
+ }
+ state.finalize_streaming();
+ });
+ break;
+ }
+ Ok(ChatStreamEvent::Error(msg)) => {
+ trace!(error = %msg, "Processing Error");
+ handle.update(move |state| {
+ state.streaming_error(msg);
+ });
+ break;
+ }
+ Err(e) => {
+ let msg = e.to_string();
+ handle.update(move |state| {
+ state.streaming_error(msg);
+ });
+ break;
+ }
}
- let _ = self.file.flush();
}
}
+// ───────────────────────────────────────────────────────────────────
+// Main TUI entry point
+// ───────────────────────────────────────────────────────────────────
+
async fn run_inline_tui(
endpoint: String,
token: String,
initial_prompt: Option<String>,
- keep_output: bool,
- debug_state_file: Option<String>,
settings: &atuin_client::settings::Settings,
) -> Result<Action> {
- // Detect popup mode (only on Unix where atuin-hex socket is available)
- #[cfg(unix)]
- let mut popup_state = crate::tui::popup::try_setup_popup();
- #[cfg(not(unix))]
- let popup_state: Option<()> = None;
+ let initial_state = AppState::new();
- let popup_mode = popup_state.is_some();
-
- // Initialize terminal guard: popup mode uses Fixed viewport, inline uses Inline
- #[cfg(unix)]
- let mut guard = if let Some(ref ps) = popup_state {
- TerminalGuard::new_popup(ps.current_rect, ps.saved_screen.cursor_col)?
- } else {
- TerminalGuard::new(keep_output)?
- };
- #[cfg(not(unix))]
- let mut guard = TerminalGuard::new(keep_output)?;
- let mut app = App::new();
- if let Some(prompt) = initial_prompt {
- // Set initial text in textarea
- let mut textarea = tui_textarea::TextArea::from(prompt.lines());
- // Disable underline on cursor line
- textarea.set_cursor_line_style(ratatui::style::Style::default());
- // Enable word wrapping
- textarea.set_wrap_mode(tui_textarea::WrapMode::Word);
- // Move cursor to end
- textarea.move_cursor(tui_textarea::CursorMove::End);
- app.state.textarea = textarea;
- }
+ println!();
- // Initialize debug state logger if requested
- let mut debug_logger = debug_state_file
- .map(|path| DebugStateLogger::new(&path))
- .transpose()?;
+ let (tx, rx) = mpsc::channel::<AiTuiEvent>();
- // Helper macro to log state changes
- macro_rules! log_state {
- ($label:expr) => {
- if let Some(ref mut logger) = debug_logger {
- logger.log($label, &app.state);
- }
- };
+ // If there's an initial prompt, send it as a SubmitInput event
+ // so it flows through the same path as user-typed input.
+ if let Some(prompt) = initial_prompt {
+ let _ = tx.send(AiTuiEvent::SubmitInput(prompt));
}
- // Log initial state
- log_state!("init");
-
- // Load theme
- let mut theme_manager = ThemeManager::new(None, None);
- let theme = theme_manager.load_theme(&settings.theme.name, None);
-
- // Initialize event loop
- let mut event_loop = EventLoop::new();
-
- // Track chat stream
- let mut chat_stream: Option<
- std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>>,
- > = None;
+ let (mut app, handle) = Application::builder()
+ .state(initial_state)
+ .view(ai_view)
+ .ctrl_c(CtrlCBehavior::Deliver)
+ .keyboard_protocol(eye_declare::KeyboardProtocol::Enhanced)
+ .bracketed_paste(true)
+ .with_context(tx)
+ .extra_newlines_at_exit(1)
+ .build()?;
- loop {
- // Ensure viewport is large enough for current content (capped at terminal height)
- // In popup mode, use the actual popup width for accurate height calculation
- let card_width = if popup_mode {
- #[cfg(unix)]
- {
- popup_state
- .as_ref()
- .map(|ps| {
- ps.current_rect
- .width
- .saturating_sub(crate::tui::popup::POPUP_MARGIN * 2)
- })
- .unwrap_or(0)
- }
- #[cfg(not(unix))]
- {
- 0
- }
- } else {
- 0
- };
- let needed_height = calculate_needed_height(&app.state, card_width);
-
- // Grow popup dynamically as content arrives
- #[cfg(unix)]
- if let Some(ref mut ps) = popup_state {
- // Add vertical margin for visual separation from terminal content
- let popup_height = needed_height.saturating_add(crate::tui::popup::POPUP_MARGIN * 2);
- if let Some(new_rect) = ps.fit_to(popup_height) {
- guard.resize_popup(new_rect)?;
- }
- }
+ let send_cwd = settings.ai.send_cwd;
- let actual_height = guard.ensure_height(needed_height)?;
+ // Event loop: receives AiTuiEvent from components, mutates state via Handle.
+ let h = handle.clone();
+ let ep = endpoint.clone();
+ let tk = token.clone();
+ tokio::task::spawn_blocking(move || {
+ while let Ok(event) = rx.recv() {
+ match event {
+ AiTuiEvent::InputUpdated(input) => {
+ let input_blank = input.trim().is_empty();
- // Render current state
- let anchor_col = guard.anchor_col();
- #[cfg(unix)]
- let render_above = popup_state.as_ref().is_some_and(|ps| ps.render_above);
- #[cfg(not(unix))]
- let render_above = false;
+ h.update(move |state| {
+ state.is_input_blank = input_blank;
+ });
+ }
+ AiTuiEvent::SubmitInput(input) => {
+ let input = input.trim().to_string();
+ if input.is_empty() {
+ let h2 = h.clone();
+ h.update(move |state| {
+ if state.has_any_command() {
+ state.exit_action = Some(ExitAction::Execute(
+ state.current_command().unwrap().to_string(),
+ ));
+ } else {
+ state.exit_action = Some(ExitAction::Cancel);
+ }
+ h2.exit();
+ });
+ continue;
+ }
- let ctx = RenderContext {
- theme,
- anchor_col,
- textarea: Some(&app.state.textarea),
- max_height: actual_height,
- popup_mode,
- render_above,
- };
- // Handle draw errors gracefully - cursor position reads can fail during resize
- if let Err(e) = guard.terminal().draw(|frame| {
- render(frame, &app.state, &ctx);
- }) {
- let err_msg = e.to_string();
- if err_msg.contains("cursor position") {
- // Cursor position read failed (common during terminal resize)
- // Skip this frame and continue - next frame will likely succeed
- tracing::debug!(
- "Skipping frame due to cursor position read error: {}",
- err_msg
- );
- continue;
- }
- return Err(e.into());
- }
+ if input.starts_with('/') {
+ let input_clone = input.clone();
+ h.update(move |state| {
+ state.handle_slash_command(&input_clone);
+ });
+ continue;
+ }
- // Get next event
- let event = event_loop.run().await?;
+ // Start generation and spawn streaming task
+ let ep = ep.clone();
+ let tk = tk.clone();
+ let h2 = h.clone();
+ h.update(move |state| {
+ state.start_generating(input);
+ state.start_streaming();
+ state.is_input_blank = true;
+ let messages = state.events_to_messages();
+ let sid = state.session_id.clone();
+ let task = tokio::spawn(async move {
+ run_chat_stream(h2, ep, tk, sid, messages, send_cwd).await;
+ });
+ state.stream_abort = Some(task.abort_handle());
+ });
+ }
- // Handle event based on app mode
- match event {
- AppEvent::Key(key) => {
- app.handle_key(key);
- log_state!("key");
- }
- AppEvent::Tick => {
- app.state.tick();
+ AiTuiEvent::SlashCommand(command) => {
+ h.update(move |state| {
+ state.handle_slash_command(&command);
+ });
+ }
- // Poll chat stream if active - keep polling until done regardless of mode
- // (mode may change to Review before we receive the done event with session_id)
- if let Some(stream) = &mut chat_stream {
- let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref());
- match stream.as_mut().poll_next(&mut cx) {
- std::task::Poll::Ready(Some(Ok(event))) => match event {
- ChatStreamEvent::TextChunk(text) => {
- trace!(text = %text, "Processing TextChunk");
- app.state.append_streaming_text(&text);
- log_state!("text_chunk");
- }
- ChatStreamEvent::ToolCall { id, name, input } => {
- trace!(id = %id, name = %name, "Processing ToolCall");
- app.state.add_tool_call(id, name, input);
- log_state!("tool_call");
- }
- ChatStreamEvent::ToolResult {
- tool_use_id,
- content,
- is_error,
- } => {
- trace!(tool_use_id = %tool_use_id, "Processing ToolResult");
- app.state.add_tool_result(tool_use_id, content, is_error);
- log_state!("tool_result");
- }
- ChatStreamEvent::Status(status) => {
- trace!(status = %status, "Processing Status");
- app.state.update_streaming_status(&status);
- log_state!("status");
- }
- ChatStreamEvent::Done { session_id } => {
- trace!(session_id = %session_id, "Processing Done");
- chat_stream = None;
- if !session_id.is_empty() {
- app.state.store_session_id(session_id);
- }
- app.state.finalize_streaming();
- log_state!("done");
- }
- ChatStreamEvent::Error(msg) => {
- trace!(error = %msg, "Processing Error");
- chat_stream = None;
- app.state.streaming_error(msg);
- log_state!("error");
- }
- },
- std::task::Poll::Ready(Some(Err(e))) => {
- chat_stream = None;
- app.state.streaming_error(e.to_string());
- log_state!("stream_error");
+ AiTuiEvent::CancelGeneration => {
+ h.update(|state| match state.mode {
+ crate::tui::state::AppMode::Generating => {
+ state.cancel_generation();
}
- std::task::Poll::Ready(None) => {
- chat_stream = None;
- app.state.finalize_streaming();
- log_state!("stream_end");
+ crate::tui::state::AppMode::Streaming => {
+ state.cancel_streaming();
}
- std::task::Poll::Pending => {}
- }
+ _ => {}
+ });
}
- }
- _ => {}
- }
-
- // Handle user cancellation (Esc during streaming) - drop the stream
- if app.state.was_interrupted && chat_stream.is_some() {
- debug!("User cancelled streaming, dropping chat stream");
- chat_stream = None;
- app.state.was_interrupted = false; // Reset the flag
- }
- // Check exit condition (includes Ctrl+C / SIGINT from event loop)
- if app.state.should_exit || event_loop.is_shutdown() {
- break;
- }
+ AiTuiEvent::ExecuteCommand => {
+ let h2 = h.clone();
+ h.update(move |state| {
+ let cmd = state.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ if state.is_current_command_dangerous() && !state.confirmation_pending {
+ state.confirmation_pending = true;
+ } else {
+ state.confirmation_pending = false;
+ state.exit_action = Some(ExitAction::Execute(cmd));
+ h2.exit();
+ }
+ }
+ });
+ }
- // Handle generation trigger - unified path for all turns
- if app.state.mode == AppMode::Generating && chat_stream.is_none() {
- // Get the last user message from events
- let last_user_content = app.state.events.iter().rev().find_map(|e| {
- if let ConversationEvent::UserMessage { content } = e {
- Some(content.clone())
- } else {
- None
+ AiTuiEvent::CancelConfirmation => {
+ h.update(move |state| {
+ state.confirmation_pending = false;
+ });
}
- });
- if last_user_content.is_some() {
- // Build messages in Claude API format
- let messages = app.state.events_to_messages();
+ AiTuiEvent::InsertCommand => {
+ let h2 = h.clone();
+ h.update(move |state| {
+ let cmd = state.current_command().map(|c| c.to_string());
+ if let Some(cmd) = cmd {
+ state.confirmation_pending = false;
+ state.exit_action = Some(ExitAction::Insert(cmd));
+ h2.exit();
+ }
+ });
+ }
- // Transition to streaming mode
- app.state.start_streaming();
- log_state!("start_streaming");
+ AiTuiEvent::Retry => {
+ let ep = ep.clone();
+ let tk = tk.clone();
+ let h2 = h.clone();
+ h.update(move |state| {
+ state.retry();
+ state.start_streaming();
+ let messages = state.events_to_messages();
+ let sid = state.session_id.clone();
+ let task = tokio::spawn(async move {
+ run_chat_stream(h2, ep, tk, sid, messages, send_cwd).await;
+ });
+ state.stream_abort = Some(task.abort_handle());
+ });
+ }
- // Start the chat stream
- chat_stream = Some(create_chat_stream(
- endpoint.clone(),
- token.clone(),
- app.state.session_id.clone(),
- messages,
- settings,
- ));
+ AiTuiEvent::Exit => {
+ let h2 = h.clone();
+ h.update(move |state| {
+ if let Some(abort) = state.stream_abort.take() {
+ abort.abort();
+ }
+ state.exit_action = Some(ExitAction::Cancel);
+ h2.exit();
+ });
+ }
}
}
- }
+ });
- // Restore popup area before guard drops (guard skips cleanup in popup mode)
- #[cfg(unix)]
- if let Some(ref ps) = popup_state {
- crate::tui::popup::restore(ps);
- }
+ app.run_loop().await?;
// Map exit action to return value
- let result = match app.state.exit_action {
- Some(ExitAction::Execute(cmd)) => Action::Execute(cmd),
- Some(ExitAction::Insert(cmd)) => Action::Insert(cmd),
+ let result = match app.state().exit_action {
+ Some(ExitAction::Execute(ref cmd)) => Action::Execute(cmd.clone()),
+ Some(ExitAction::Insert(ref cmd)) => Action::Insert(cmd.clone()),
_ => Action::Cancel,
};
Ok(result)
}
-struct RawModeGuard;
+// ───────────────────────────────────────────────────────────────────
+// Helpers
+// ───────────────────────────────────────────────────────────────────
-impl Drop for RawModeGuard {
- fn drop(&mut self) {
- let _ = disable_raw_mode();
+fn hub_url(base: &str, path: &str) -> Result<Url> {
+ let base_with_slash = if base.ends_with('/') {
+ base.to_string()
+ } else {
+ format!("{base}/")
+ };
+ let stripped = path.strip_prefix('/').unwrap_or(path);
+ Url::parse(&base_with_slash)?
+ .join(stripped)
+ .context("failed to build hub URL")
+}
+
+fn detect_os() -> String {
+ match std::env::consts::OS {
+ "macos" => "macos".to_string(),
+ "linux" => "linux".to_string(),
+ "windows" => "windows".to_string(),
+ other => format!("Other: {other}"),
}
}
+#[derive(Clone)]
+enum Action {
+ Execute(String),
+ Insert(String),
+ Print(String),
+ Cancel,
+}
+
fn emit_shell_result(action: Action, output_for_hook: bool) {
if output_for_hook {
match action {
@@ -741,8 +592,19 @@ fn emit_shell_result(action: Action, output_for_hook: bool) {
}
fn wait_for_login_confirmation() -> Result<bool> {
+ use crossterm::{
+ event::{self, Event, KeyCode},
+ terminal::{disable_raw_mode, enable_raw_mode},
+ };
+
enable_raw_mode().context("failed enabling raw mode for login prompt")?;
- let _guard = RawModeGuard;
+ struct Guard;
+ impl Drop for Guard {
+ fn drop(&mut self) {
+ let _ = disable_raw_mode();
+ }
+ }
+ let _guard = Guard;
loop {
let ev = event::read().context("failed to read login confirmation key")?;
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",
- }
- }
-}
diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs
index 02d64205..7a7dc153 100644
--- a/crates/atuin/src/command/client.rs
+++ b/crates/atuin/src/command/client.rs
@@ -164,7 +164,7 @@ impl Cmd {
res
}
- #[allow(clippy::too_many_lines)]
+ #[allow(clippy::too_many_lines, clippy::future_not_send)]
async fn run_inner(
self,
mut settings: Settings,