diff options
31 files changed, 1885 insertions, 3773 deletions
@@ -277,11 +277,14 @@ dependencies = [ "crossterm", "directories", "eventsource-stream", + "eye_declare", "eyre", "futures", "pretty_assertions", "pulldown-cmark", "ratatui", + "ratatui-core", + "ratatui-widgets", "reqwest", "serde", "serde_json", @@ -1494,6 +1497,32 @@ dependencies = [ ] [[package]] +name = "eye_declare" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f7a306e9da6182b45de1c5e29ca9d469f6aa4cd0bdd6ff0da4bdaf67bec4ff" +dependencies = [ + "crossterm", + "eye_declare_macros", + "futures", + "ratatui-core", + "ratatui-widgets", + "tokio", + "unicode-width 0.2.2", +] + +[[package]] +name = "eye_declare_macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c552b6cb631a6826de9c793d7f1b620e4007c6f8ee584f2bc428d193c14b3381" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] name = "eyre" version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5462,9 +5491,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui-textarea-2" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae981fdb654241cb325bf15b78adba3ce21ad85972ebe4820ad7dc7f2884e49" +checksum = "74a31ca0965e3ff6a7ac5ecb02b20a88b4f68ebf138d8ae438e8510b27a1f00f" dependencies = [ "crossterm", "portable-atomic", 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, diff --git a/docs/docs/ai/images/basic-followup-questions.png b/docs/docs/ai/images/basic-followup-questions.png Binary files differnew file mode 100644 index 00000000..d0c1d12d --- /dev/null +++ b/docs/docs/ai/images/basic-followup-questions.png diff --git a/docs/docs/ai/images/basic-refine.png b/docs/docs/ai/images/basic-refine.png Binary files differnew file mode 100644 index 00000000..e2404bc5 --- /dev/null +++ b/docs/docs/ai/images/basic-refine.png diff --git a/docs/docs/ai/images/basic.png b/docs/docs/ai/images/basic.png Binary files differnew file mode 100644 index 00000000..a58e510a --- /dev/null +++ b/docs/docs/ai/images/basic.png diff --git a/docs/docs/ai/images/danger.png b/docs/docs/ai/images/danger.png Binary files differnew file mode 100644 index 00000000..9c762f19 --- /dev/null +++ b/docs/docs/ai/images/danger.png diff --git a/docs/docs/ai/images/question.png b/docs/docs/ai/images/question.png Binary files differnew file mode 100644 index 00000000..a1dc5690 --- /dev/null +++ b/docs/docs/ai/images/question.png diff --git a/docs/docs/ai/introduction.md b/docs/docs/ai/introduction.md index 1c154992..b14634b3 100644 --- a/docs/docs/ai/introduction.md +++ b/docs/docs/ai/introduction.md @@ -22,111 +22,25 @@ For a list of settings that control the behavior of Atuin AI, see [its dedicated Prompt the LLM to create a command, and get one back, no fuss. Press `enter` to run, or `tab` to insert. -``` -┌Ask questions or generate a command:──────────────────────────┐ -│ │ -│ > Get a list of running docker containers │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ $ docker ps │ -│ │ -└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ -``` +[](./images/basic.png) ### Follow-up -You can follow-up with `f` to specify a refinement prompt to update the command that will be inserted. +You can follow-up with a refinement prompt to update the command that will be inserted. -``` -┌Ask questions or generate a command:──────────────────────────┐ -│ │ -│ > Get a list of running docker containers │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ $ docker ps │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ > Actually I want to get all docker containers │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ $ docker ps -a │ -│ │ -└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ -``` +[](./images/basic-refine.png) You can also follow-up with questions to get responses in natural language. -``` -┌Ask questions or generate a command:──────────────────────────┐ -│ │ -│ > Get a list of running docker containers │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ $ docker ps │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ > Actually I want to get all docker containers │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ $ docker ps -a │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ > What other useful flags to `docker ps` should I know? │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ Here are some handy `docker ps` flags: │ -│ │ -│ - `-q` — Only show container IDs (great for piping to │ -│ other commands) │ -│ - `-s` — Show container sizes │ -│ - `-n 5` — Show the last 5 created containers │ -│ - `-l` — Show only the latest created container │ -│ - `--no-trunc` — Don't truncate output (shows full IDs and │ -│ commands) │ -│ - `-f` or `--filter` — Filter by condition, e.g.: │ -│ - `-f status=exited` — only exited containers │ -│ - `-f name=myapp` — filter by name │ -│ - `-f ancestor=nginx` — filter by image │ -│ - `--format` — Custom output using Go templates, e.g.: │ -│ `--format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"` │ -│ │ -│ A common combo is `docker ps -aq` to get all container │ -│ IDs, useful for bulk operations like `docker rm $(docker │ -│ ps -aq)`. │ -│ │ -└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ -``` +[](./images/basic-followup-questions.png) -You can use `enter` or `tab` at any time to run or insert the last suggested command, even if it was suggested in a previous turn. +You can still use `enter` or `tab` to run or insert the last suggested command, even if it was suggested in a previous turn. ### Conversational and search usage If you prompt the LLM with a question that doesn't imply you want to generate a command, it can respond in natural language, and use web search if necessary to fetch the data it needs. -``` -┌Ask questions or generate a command:──────────────────────────┐ -│ │ -│ > What is the latest version of atuin? │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ ✓ Used 2 tools │ -│ │ -│ The latest version of Atuin is **v18.12.0**, available on │ -│ the [GitHub releases │ -│ page](https://github.com/atuinsh/atuin/releases). │ -│ │ -└─────────────────────────────────[f]: Follow-up [Esc]: Cancel┘ -``` +[](./images/question.png) ### Dangerous or low-confidence command detection @@ -134,22 +48,4 @@ The LLM scores its confidence in the command, as well as how dangerous the comma The Atuin Hub server also monitors suggested commands for dangerous patterns the LLM didn't catch, and appends its own assessment at the end of the LLM's own assessment. -``` -┌Ask questions or generate a command:──────────────────────────┐ -│ │ -│ > Delete all files from $HOME │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ $ rm -rf $HOME/* │ -│ │ -│ ! This will PERMANENTLY delete ALL files and directories in │ -│ your home directory, including documents, downloads, │ -│ configurations, SSH keys, and everything else. This is │ -│ irreversible and will likely break your system. Also note │ -│ this won't delete hidden (dot) files — if you want those │ -│ too, that's even more destructive.; [Server] Recursive │ -│ delete of critical directory │ -│ │ -└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘ -``` +[](./images/danger.png) |
