aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/commands
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-03-26 19:19:47 -0700
committerGitHub <noreply@github.com>2026-03-27 02:19:47 +0000
commitb649a7ab8de6488c1341e94c37d032c07d5b3f13 (patch)
treeca9aadc1175b8439dd85de135f3804681b755776 /crates/atuin-ai/src/commands
parentfix: set WorkingDirectory in PowerShell Invoke-AtuinSearch (#3351) (diff)
downloadatuin-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.rs466
-rw-r--r--crates/atuin-ai/src/commands/inline.rs680
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")?;