From b649a7ab8de6488c1341e94c37d032c07d5b3f13 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Thu, 26 Mar 2026 19:19:47 -0700 Subject: feat: Use eye-declare for more performant and flexible AI TUI (#3343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR replaces the mess of custom rendering code in Atuin AI with [eye-declare](https://github.com/BinaryMuse/eye-declare), and updates the TUI to feel more terminal-native: output appears inline and persists in scrollback, so you can scroll up and look at previous conversations for reference. The "review" state — which used to exist between the LLM generating a response and the user either executing or following up — has been removed; just start typing to follow up with the LLM, or press `enter` at the empty input box to execute the suggested command. image --- Cargo.lock | 33 +- crates/atuin-ai/Cargo.toml | 7 +- crates/atuin-ai/src/commands.rs | 45 +- crates/atuin-ai/src/commands/debug_render.rs | 466 --------------- crates/atuin-ai/src/commands/inline.rs | 686 +++++++++-------------- crates/atuin-ai/src/tui/app.rs | 157 ------ crates/atuin-ai/src/tui/component.rs | 186 ------ crates/atuin-ai/src/tui/components.rs | 510 ----------------- crates/atuin-ai/src/tui/components/atuin_ai.rs | 140 +++++ crates/atuin-ai/src/tui/components/input_box.rs | 229 ++++++++ crates/atuin-ai/src/tui/components/markdown.rs | 213 +++++++ crates/atuin-ai/src/tui/components/mod.rs | 3 + crates/atuin-ai/src/tui/content/help.md | 3 + crates/atuin-ai/src/tui/event.rs | 303 ---------- crates/atuin-ai/src/tui/events.rs | 27 + crates/atuin-ai/src/tui/mod.rs | 16 +- crates/atuin-ai/src/tui/popup.rs | 363 ------------ crates/atuin-ai/src/tui/render.rs | 234 -------- crates/atuin-ai/src/tui/spinner.rs | 99 ---- crates/atuin-ai/src/tui/state.rs | 384 +++++++------ crates/atuin-ai/src/tui/terminal.rs | 278 --------- crates/atuin-ai/src/tui/view/mod.rs | 342 +++++++++++ crates/atuin-ai/src/tui/view/turn.rs | 409 ++++++++++++++ crates/atuin-ai/src/tui/view_model.rs | 413 -------------- crates/atuin/src/command/client.rs | 2 +- docs/docs/ai/images/basic-followup-questions.png | Bin 0 -> 504535 bytes docs/docs/ai/images/basic-refine.png | Bin 0 -> 410378 bytes docs/docs/ai/images/basic.png | Bin 0 -> 371724 bytes docs/docs/ai/images/danger.png | Bin 0 -> 409263 bytes docs/docs/ai/images/question.png | Bin 0 -> 372884 bytes docs/docs/ai/introduction.md | 122 +--- 31 files changed, 1891 insertions(+), 3779 deletions(-) delete mode 100644 crates/atuin-ai/src/commands/debug_render.rs delete mode 100644 crates/atuin-ai/src/tui/app.rs delete mode 100644 crates/atuin-ai/src/tui/component.rs delete mode 100644 crates/atuin-ai/src/tui/components.rs create mode 100644 crates/atuin-ai/src/tui/components/atuin_ai.rs create mode 100644 crates/atuin-ai/src/tui/components/input_box.rs create mode 100644 crates/atuin-ai/src/tui/components/markdown.rs create mode 100644 crates/atuin-ai/src/tui/components/mod.rs create mode 100644 crates/atuin-ai/src/tui/content/help.md delete mode 100644 crates/atuin-ai/src/tui/event.rs create mode 100644 crates/atuin-ai/src/tui/events.rs delete mode 100644 crates/atuin-ai/src/tui/popup.rs delete mode 100644 crates/atuin-ai/src/tui/render.rs delete mode 100644 crates/atuin-ai/src/tui/spinner.rs delete mode 100644 crates/atuin-ai/src/tui/terminal.rs create mode 100644 crates/atuin-ai/src/tui/view/mod.rs create mode 100644 crates/atuin-ai/src/tui/view/turn.rs delete mode 100644 crates/atuin-ai/src/tui/view_model.rs create mode 100644 docs/docs/ai/images/basic-followup-questions.png create mode 100644 docs/docs/ai/images/basic-refine.png create mode 100644 docs/docs/ai/images/basic.png create mode 100644 docs/docs/ai/images/danger.png create mode 100644 docs/docs/ai/images/question.png diff --git a/Cargo.lock b/Cargo.lock index 6ca79192..03e78c09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,11 +277,14 @@ dependencies = [ "crossterm", "directories", "eventsource-stream", + "eye_declare", "eyre", "futures", "pretty_assertions", "pulldown-cmark", "ratatui", + "ratatui-core", + "ratatui-widgets", "reqwest", "serde", "serde_json", @@ -1493,6 +1496,32 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eye_declare" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f7a306e9da6182b45de1c5e29ca9d469f6aa4cd0bdd6ff0da4bdaf67bec4ff" +dependencies = [ + "crossterm", + "eye_declare_macros", + "futures", + "ratatui-core", + "ratatui-widgets", + "tokio", + "unicode-width 0.2.2", +] + +[[package]] +name = "eye_declare_macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c552b6cb631a6826de9c793d7f1b620e4007c6f8ee584f2bc428d193c14b3381" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "eyre" version = "0.6.12" @@ -5462,9 +5491,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui-textarea-2" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae981fdb654241cb325bf15b78adba3ce21ad85972ebe4820ad7dc7f2884e49" +checksum = "74a31ca0965e3ff6a7ac5ecb02b20a88b4f68ebf138d8ae438e8510b27a1f00f" dependencies = [ "crossterm", "portable-atomic", diff --git a/crates/atuin-ai/Cargo.toml b/crates/atuin-ai/Cargo.toml index 1b81646c..a62e3274 100644 --- a/crates/atuin-ai/Cargo.toml +++ b/crates/atuin-ai/Cargo.toml @@ -31,14 +31,17 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } crossterm = { workspace = true, features = ["use-dev-tty", "event-stream"] } -ratatui = { workspace = true, features = ["unstable-rendered-line-info"] } +ratatui = { workspace = true } futures = "0.3" eventsource-stream = "0.2" pulldown-cmark = "0.13.0" async-stream = "0.3" uuid = { workspace = true } -tui-textarea-2 = "0.9.1" +tui-textarea-2 = "0.10.2" unicode-width = "0.2" +eye_declare = "0.1" +ratatui-core = "0.1" +ratatui-widgets = "0.3" [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/atuin-ai/src/commands.rs b/crates/atuin-ai/src/commands.rs index d04875ea..6e79da61 100644 --- a/crates/atuin-ai/src/commands.rs +++ b/crates/atuin-ai/src/commands.rs @@ -8,9 +8,6 @@ use clap::{Args, Subcommand}; use eyre::Result; use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt}; -#[cfg(debug_assertions)] -pub mod debug_render; - pub mod init; pub mod inline; @@ -47,29 +44,9 @@ pub enum Commands { #[arg(value_name = "COMMAND")] command: Option, - /// 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, - }, - - /// 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, - - /// 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, - /// 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, - /// Session ID from server - #[serde(default)] - pub session_id: Option, - /// Streaming status - #[serde(default)] - pub streaming_status: Option, - /// 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 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 { - 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 = 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, 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::>() - }) - }).collect::>(), - "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 { - 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, api_endpoint: Option, api_token: Option, - keep_output: bool, - debug_state_file: Option, 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, messages: Vec, - settings: &atuin_client::settings::Settings, + send_cwd: bool, ) -> std::pin::Pin> + 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 { - 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 = 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, - }); - - // 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 { - 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); +// ─────────────────────────────────────────────────────────────────── +// Async streaming task — pushes updates to app state via Handle +// ─────────────────────────────────────────────────────────────────── - 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, + endpoint: String, + token: String, + session_id: Option, + messages: Vec, + 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, - keep_output: bool, - debug_state_file: Option, settings: &atuin_client::settings::Settings, ) -> Result { - // 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 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; - } + let initial_state = AppState::new(); - // Initialize debug state logger if requested - let mut debug_logger = debug_state_file - .map(|path| DebugStateLogger::new(&path)) - .transpose()?; + println!(); - // 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); - } - }; + let (tx, rx) = mpsc::channel::(); + + // 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"); + 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()?; - // Load theme - let mut theme_manager = ThemeManager::new(None, None); - let theme = theme_manager.load_theme(&settings.theme.name, None); + let send_cwd = settings.ai.send_cwd; - // Initialize event loop - let mut event_loop = EventLoop::new(); + // 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(); - // Track chat stream - let mut chat_stream: Option< - std::pin::Pin> + Send>>, - > = None; + 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; + } - 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)?; - } - } + if input.starts_with('/') { + let input_clone = input.clone(); + h.update(move |state| { + state.handle_slash_command(&input_clone); + }); + continue; + } - let actual_height = guard.ensure_height(needed_height)?; - - // 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; - - 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()); - } + // 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()); + }); + } - // Get next event - let event = event_loop.run().await?; + AiTuiEvent::SlashCommand(command) => { + h.update(move |state| { + state.handle_slash_command(&command); + }); + } - // Handle event based on app mode - match event { - AppEvent::Key(key) => { - app.handle_key(key); - log_state!("key"); - } - AppEvent::Tick => { - app.state.tick(); - - // 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 - } + 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(); + } + } + }); + } - // Check exit condition (includes Ctrl+C / SIGINT from event loop) - if app.state.should_exit || event_loop.is_shutdown() { - break; - } + AiTuiEvent::CancelConfirmation => { + h.update(move |state| { + state.confirmation_pending = false; + }); + } + + 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(); + } + }); + } + + 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()); + }); + } - // 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::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(); + }); } - }); - - if last_user_content.is_some() { - // Build messages in Claude API format - let messages = app.state.events_to_messages(); - - // Transition to streaming mode - app.state.start_streaming(); - log_state!("start_streaming"); - - // Start the chat stream - chat_stream = Some(create_chat_stream( - endpoint.clone(), - token.clone(), - app.state.session_id.clone(), - messages, - settings, - )); } } - } + }); - // 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 +// ─────────────────────────────────────────────────────────────────── + +fn hub_url(base: &str, path: &str) -> Result { + 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") +} -impl Drop for RawModeGuard { - fn drop(&mut self) { - let _ = disable_raw_mode(); +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 { + 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>, - pub spacing: u16, - pub scroll_offset: u16, -} - -impl VStack { - pub fn new(children: Vec>) -> 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 = 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, -} - -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> { - 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> { - 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, - 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 { - 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 { - let mut children: Vec> = 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> = 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>, +} + +impl Component for AtuinAi { + type State = AtuinAiState; + + fn initial_state(&self) -> Option { + Some(AtuinAiState::default()) + } + + fn lifecycle(&self, hooks: &mut Hooks, _state: &Self::State) { + hooks.use_context::>(|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>, + tx: Option>, +} + +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 { + Some(InputBoxState::default()) + } + + fn lifecycle(&self, hooks: &mut Hooks, _state: &Self::State) { + if self.active { + hooks.use_autofocus(); + } + hooks.use_context::>(|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) -> 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 { + 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![Vec::new()]; + let mut current_line = 0; + + let mut style_stack: Vec