aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/commands/debug_render.rs
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/debug_render.rs
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/debug_render.rs')
-rw-r--r--crates/atuin-ai/src/commands/debug_render.rs466
1 files changed, 0 insertions, 466 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...");
- }
-}