diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-02-24 11:48:20 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-24 11:48:20 -0800 |
| commit | 6ea760bb6b36da241961e8ecd60cb2c5e15c0a78 (patch) | |
| tree | 18ebbb710cea24e30bc69b5d6bc807518a950746 /crates/atuin-ai/src/commands/debug_render.rs | |
| parent | fix: forward $PATH to tmux popup in zsh (#3198) (diff) | |
| download | atuin-6ea760bb6b36da241961e8ecd60cb2c5e15c0a78.zip | |
feat: Generate commands or ask questions with `atuin ai` (#3199)
This PR refines the system created in #3178 to be suitable for a v1
release.
---
## Overview
`atuin-ai` is a separate binary that allows for generating commands and
asking questions from the command line.
It is fully opt-in.
## Usage
`atuin ai init` will output bindings for your shell. Currently, bash,
zsh, and fish are supported.
```bash
eval "$(atuin ai init)"
```
Once the hooks are installed, just press `?` on an empty prompt line to
call up the TUI.
`atuin ai` requires an account on [Atuin Hub](https://hub.atuin.sh/);
you will be prompted to log in on first use.
## Features
### Command generation
Prompt the LLM to create a command, and get one back, no fuss. Press
`enter` to run, or `tab` to insert.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
### Follow-up
You can follow-up with `f` to specify a refinement prompt to update the
command that will be inserted.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > Actually I want to get all docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps -a │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
You can also follow-up with questions to get responses in natural
language.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Get a list of running docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > Actually I want to get all docker containers │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ docker ps -a │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ > What other useful flags to `docker ps` should I know? │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ Here are some handy `docker ps` flags: │
│ │
│ - `-q` — Only show container IDs (great for piping to │
│ other commands) │
│ - `-s` — Show container sizes │
│ - `-n 5` — Show the last 5 created containers │
│ - `-l` — Show only the latest created container │
│ - `--no-trunc` — Don't truncate output (shows full IDs and │
│ commands) │
│ - `-f` or `--filter` — Filter by condition, e.g.: │
│ - `-f status=exited` — only exited containers │
│ - `-f name=myapp` — filter by name │
│ - `-f ancestor=nginx` — filter by image │
│ - `--format` — Custom output using Go templates, e.g.: │
│ `--format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"` │
│ │
│ A common combo is `docker ps -aq` to get all container │
│ IDs, useful for bulk operations like `docker rm $(docker │
│ ps -aq)`. │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
You can use `enter` or `tab` at any time to run or insert the last
suggested command, even if it was suggested in a previous turn.
### Conversational and search usage
If you prompt the LLM with a question that doesn't imply you want to
generate a command, it can respond in natural language, and use web
search if necessary to fetch the data it needs.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > What is the latest version of atuin? │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ ✓ Used 2 tools │
│ │
│ The latest version of Atuin is **v18.12.0**, available on │
│ the [GitHub releases │
│ page](https://github.com/atuinsh/atuin/releases). │
│ │
└─────────────────────────────────[f]: Follow-up [Esc]: Cancel┘
```
### Dangerous or low-confidence command detection
The LLM scores its confidence in the command, as well as how dangerous
the command is. This information is shown if a threshold is exceeded,
and requires an extra confirmation step before running automatically
with `enter`.
The Atuin Hub server also monitors suggested commands for dangerous
patterns the LLM didn't catch, and appends its own assessment at the end
of the LLM's own assessment.
```
┌Ask questions or generate a command:──────────────────────────┐
│ │
│ > Delete all files from $HOME │
│ │
├──────────────────────────────────────────────────────────────┤
│ │
│ $ rm -rf $HOME/* │
│ │
│ ! ⚠️ This will PERMANENTLY delete ALL files and directories │
│ in your home directory, including documents, downloads, │
│ configurations, SSH keys, and everything else. This is │
│ irreversible and will likely break your system. Also note │
│ this won't delete hidden (dot) files — if you want those │
│ too, that's even more destructive.; [Server] Recursive │
│ delete of critical directory │
│ │
└────[Enter]: Run [Tab]: Insert [f]: Follow-up [Esc]: Cancel┘
```
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'crates/atuin-ai/src/commands/debug_render.rs')
| -rw-r--r-- | crates/atuin-ai/src/commands/debug_render.rs | 460 |
1 files changed, 460 insertions, 0 deletions
diff --git a/crates/atuin-ai/src/commands/debug_render.rs b/crates/atuin-ai/src/commands/debug_render.rs new file mode 100644 index 00000000..e78a418a --- /dev/null +++ b/crates/atuin-ai/src/commands/debug_render.rs @@ -0,0 +1,460 @@ +//! 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, + }; + + 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<_>>() + }) +} + +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..."); + } +} |
