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