diff options
Diffstat (limited to 'crates/atuin-ai/src/commands/debug_render.rs')
| -rw-r--r-- | crates/atuin-ai/src/commands/debug_render.rs | 466 |
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..."); - } -} |
