diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-03-26 19:19:47 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-27 02:19:47 +0000 |
| commit | b649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch) | |
| tree | ca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/commands | |
| parent | fix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff) | |
| download | atuin-b649a7ab8de6488c1341e94c37d032c07d5b3f13.zip | |
feat: Use eye-declare for more performant and flexible AI TUI (#3343)
This PR replaces the mess of custom rendering code in Atuin AI with
[eye-declare](https://github.com/BinaryMuse/eye-declare), and updates
the TUI to feel more terminal-native: output appears inline and persists
in scrollback, so you can scroll up and look at previous conversations
for reference.
The "review" state — which used to exist between the LLM generating a
response and the user either executing or following up — has been
removed; just start typing to follow up with the LLM, or press `enter`
at the empty input box to execute the suggested command.
<img width="1203" height="633" alt="image"
src="https://github.com/user-attachments/assets/159ee447-9a2a-4edd-b56e-a79bf1aaaa94"
/>
Diffstat (limited to 'crates/atuin-ai/src/commands')
| -rw-r--r-- | crates/atuin-ai/src/commands/debug_render.rs | 466 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/inline.rs | 680 |
2 files changed, 271 insertions, 875 deletions
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")?; |
