aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/commands
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/commands')
-rw-r--r--crates/atuin-ai/src/commands/debug_render.rs460
-rw-r--r--crates/atuin-ai/src/commands/init.rs155
-rw-r--r--crates/atuin-ai/src/commands/inline.rs924
3 files changed, 1084 insertions, 455 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...");
+ }
+}
diff --git a/crates/atuin-ai/src/commands/init.rs b/crates/atuin-ai/src/commands/init.rs
index bf5c6256..8174b583 100644
--- a/crates/atuin-ai/src/commands/init.rs
+++ b/crates/atuin-ai/src/commands/init.rs
@@ -1,9 +1,29 @@
-pub async fn run() -> eyre::Result<()> {
- let zsh_function = generate_zsh_integration();
- println!("{}", zsh_function);
+use crate::commands::detect_shell;
+
+pub async fn run(shell: String) -> eyre::Result<()> {
+ let integration = match shell.as_str() {
+ "zsh" => generate_zsh_integration(),
+ "bash" => generate_bash_integration(),
+ "fish" => generate_fish_integration(),
+ "auto" => generate_auto_integration()?,
+ _ => eyre::bail!("Unsupported shell: {}", shell),
+ };
+
+ println!("{}", integration);
Ok(())
}
+fn generate_auto_integration() -> eyre::Result<&'static str> {
+ let shell = detect_shell();
+ match shell.as_deref() {
+ Some("zsh") => Ok(generate_zsh_integration()),
+ Some("bash") => Ok(generate_bash_integration()),
+ Some("fish") => Ok(generate_fish_integration()),
+ Some(s) => eyre::bail!("Unsupported shell: {}", s),
+ None => eyre::bail!("Could not detect shell"),
+ }
+}
+
/// Generate the zsh integration function - pure function for easy testing
pub fn generate_zsh_integration() -> &'static str {
r#"
@@ -53,6 +73,111 @@ bindkey '?' _atuin_ai_question_mark # Question mark
.trim()
}
+/// Generate the bash integration function - pure function for easy testing
+pub fn generate_bash_integration() -> &'static str {
+ r#"
+# Question mark at start of line - natural language mode
+_atuin_ai_question_mark() {
+ # If buffer is empty or just contains '?', trigger natural language mode
+ if [[ -z "$READLINE_LINE" || "$READLINE_LINE" == "?" ]]; then
+ READLINE_LINE=""
+ READLINE_POINT=0
+
+ local output
+ output=$(atuin-ai inline --natural-language 3>&1 1>&2 2>&3)
+
+ if [[ $output == __atuin_ai_cancel__ ]]; then
+ # User cancelled, do nothing
+ READLINE_LINE=""
+ READLINE_POINT=0
+ elif [[ $output == __atuin_ai_execute__:* ]]; then
+ # Execute the command immediately
+ READLINE_LINE=${output#__atuin_ai_execute__:}
+ READLINE_POINT=${#READLINE_LINE}
+ # Note: We can't directly execute in bash bind -x, but we can
+ # use a workaround by binding to a macro that accepts the line
+ bind '"\C-x\C-a": accept-line'
+ bind -x '"\C-x\C-e": _atuin_ai_question_mark'
+ elif [[ $output == __atuin_ai_insert__:* ]]; then
+ # Insert the command for editing
+ READLINE_LINE=${output#__atuin_ai_insert__:}
+ READLINE_POINT=${#READLINE_LINE}
+ elif [[ -n $output ]]; then
+ # Default: insert for editing
+ READLINE_LINE=$output
+ READLINE_POINT=${#READLINE_LINE}
+ fi
+ else
+ # Not at empty prompt, just insert the question mark
+ READLINE_LINE="${READLINE_LINE:0:READLINE_POINT}?${READLINE_LINE:READLINE_POINT}"
+ ((READLINE_POINT++))
+ fi
+}
+
+# Set up keybindings
+# Bash requires special handling: we use bind -x for the function,
+# but need a two-step approach for execute mode
+__atuin_ai_accept_line=""
+
+_atuin_ai_question_mark_wrapper() {
+ _atuin_ai_question_mark
+ if [[ -n "$__atuin_ai_accept_line" ]]; then
+ __atuin_ai_accept_line=""
+ fi
+}
+
+bind -x '"?": _atuin_ai_question_mark'
+"#
+ .trim()
+}
+
+/// Generate the fish integration function - pure function for easy testing
+pub fn generate_fish_integration() -> &'static str {
+ r#"
+# Question mark at start of line - natural language mode
+function _atuin_ai_question_mark
+ set -l buf (commandline -b)
+
+ # If buffer is empty or just contains '?', trigger natural language mode
+ if test -z "$buf" -o "$buf" = "?"
+ commandline -r ""
+
+ # Run atuin-ai inline, swapping stdout and stderr
+ set -l output (atuin-ai inline --natural-language 3>&1 1>&2 2>&3 | string collect)
+
+ if test "$output" = "__atuin_ai_cancel__"
+ # User cancelled, do nothing
+ commandline -f repaint
+ else if string match --quiet '__atuin_ai_execute__:*' "$output"
+ # Execute the command immediately
+ set -l cmd (string replace "__atuin_ai_execute__:" "" -- "$output" | string collect)
+ commandline -r "$cmd"
+ commandline -f repaint
+ commandline -f execute
+ else if string match --quiet '__atuin_ai_insert__:*' "$output"
+ # Insert the command for editing
+ set -l cmd (string replace "__atuin_ai_insert__:" "" -- "$output" | string collect)
+ commandline -r "$cmd"
+ commandline -f repaint
+ else if test -n "$output"
+ # Default: insert for editing
+ commandline -r "$output"
+ commandline -f repaint
+ else
+ commandline -f repaint
+ end
+ else
+ # Not at empty prompt, just insert the question mark
+ commandline -i "?"
+ end
+end
+
+# Set up keybindings
+bind "?" _atuin_ai_question_mark
+"#
+ .trim()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -67,4 +192,28 @@ mod tests {
assert!(result.contains("__atuin_ai_execute__"));
assert!(result.contains("__atuin_ai_insert__"));
}
+
+ #[test]
+ fn test_generate_bash_integration() {
+ let result = generate_bash_integration();
+ assert!(result.contains("_atuin_ai_question_mark"));
+ assert!(result.contains("bind"));
+ assert!(result.contains("READLINE_LINE"));
+ assert!(result.contains("atuin-ai inline"));
+ assert!(result.contains("__atuin_ai_cancel__"));
+ assert!(result.contains("__atuin_ai_execute__"));
+ assert!(result.contains("__atuin_ai_insert__"));
+ }
+
+ #[test]
+ fn test_generate_fish_integration() {
+ let result = generate_fish_integration();
+ assert!(result.contains("_atuin_ai_question_mark"));
+ assert!(result.contains("bind"));
+ assert!(result.contains("commandline"));
+ assert!(result.contains("atuin-ai inline"));
+ assert!(result.contains("__atuin_ai_cancel__"));
+ assert!(result.contains("__atuin_ai_execute__"));
+ assert!(result.contains("__atuin_ai_insert__"));
+ }
}
diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs
index cfa27db4..3f9278a2 100644
--- a/crates/atuin-ai/src/commands/inline.rs
+++ b/crates/atuin-ai/src/commands/inline.rs
@@ -1,52 +1,52 @@
+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 atuin_client::theme::ThemeManager;
use atuin_common::tls::ensure_crypto_provider;
use crossterm::{
- cursor,
event::{self, Event, KeyCode},
terminal::{disable_raw_mode, enable_raw_mode},
};
+use eventsource_stream::Eventsource;
use eyre::{Context as _, Result, bail};
-use ratatui::{
- Frame, Terminal, TerminalOptions, Viewport,
- backend::CrosstermBackend,
- layout::{Alignment, Rect},
- text::Line,
- widgets::{Block, Borders, Paragraph, Wrap},
-};
+use futures::StreamExt;
use reqwest::Url;
-use serde::{Deserialize, Serialize};
-use std::time::Duration;
-
-#[derive(Debug, Serialize)]
-struct GenerateRequest {
- query: String,
- description: String,
- context: GenerateContext,
-}
-
-#[derive(Debug, Serialize)]
-struct GenerateContext {
- os: String,
- shell: String,
- pwd: Option<String>,
-}
-
-#[derive(Debug, Deserialize)]
-struct GenerateResponse {
- command: String,
- #[serde(default)]
- explanation: Option<String>,
-}
+use std::io::Write;
pub async fn run(
initial_command: Option<String>,
natural_language: bool,
api_endpoint: Option<String>,
+ api_token: Option<String>,
+ keep_output: bool,
+ debug_state_file: Option<String>,
) -> Result<()> {
+ // 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 settings = atuin_client::settings::Settings::new()?;
- let endpoint = api_endpoint
- .as_deref()
- .unwrap_or(settings.hub_address.as_str());
- let token = ensure_hub_session(&settings, endpoint).await?;
+ let endpoint = api_endpoint.as_deref().unwrap_or(
+ settings
+ .ai
+ .ai_endpoint
+ .as_deref()
+ .unwrap_or("https://hub.atuin.sh"),
+ );
+ let api_token = api_token.as_deref().or(settings.ai.ai_api_token.as_deref());
+
+ let token = if let Some(token) = &api_token {
+ token.to_string()
+ } else {
+ ensure_hub_session(&settings, endpoint).await?
+ };
+
let action = run_inline_tui(
endpoint.to_string(),
token,
@@ -55,6 +55,8 @@ pub async fn run(
} else {
initial_command
},
+ keep_output,
+ debug_state_file,
)
.await?;
emit_shell_result(action.0, &action.1);
@@ -95,55 +97,172 @@ async fn ensure_hub_session(
Ok(token)
}
-async fn generate_command(
- hub_address: &str,
- token: &str,
- description: &str,
-) -> Result<GenerateResponse> {
- ensure_crypto_provider();
- let endpoint = hub_url(hub_address, "/api/cli/generate")?;
- let request = GenerateRequest {
- query: description.to_string(),
- description: description.to_string(),
- context: GenerateContext {
- os: detect_os(),
- shell: detect_shell(),
- pwd: std::env::current_dir()
- .ok()
- .map(|path| path.to_string_lossy().into_owned()),
- },
- };
+/// SSE event received from chat endpoint
+#[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
+ Error(String),
+}
+
+fn create_chat_stream(
+ hub_address: String,
+ token: String,
+ session_id: Option<String>,
+ messages: Vec<serde_json::Value>,
+ settings: &atuin_client::settings::Settings,
+) -> 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") {
+ Ok(url) => url,
+ Err(e) => {
+ yield Err(e);
+ return;
+ }
+ };
- let client = reqwest::Client::new();
- let response = client
- .post(endpoint)
- .bearer_auth(token)
- .json(&request)
- .send()
- .await
- .context("failed to call Atuin Hub generate endpoint")?;
+ // Build request body
+ let mut request_body = serde_json::json!({
+ "messages": messages,
+ "context": {
+ "os": detect_os(),
+ "shell": detect_shell(),
+ "pwd": if send_cwd { std::env::current_dir()
+ .ok()
+ .map(|path| path.to_string_lossy().into_owned()) } else { None },
+ }
+ });
+
+ // Include session_id only if present (not on first request)
+ if let Some(ref sid) = session_id {
+ request_body["session_id"] = serde_json::json!(sid);
+ }
- if response.status().is_success() {
- let generated = response
- .json::<GenerateResponse>()
+
+ let client = reqwest::Client::new();
+ let response = match client
+ .post(endpoint.clone())
+ .header("Accept", "text/event-stream")
+ .bearer_auth(&token)
+ .json(&request_body)
+ .send()
.await
- .context("failed to decode generate response")?;
+ {
+ Ok(resp) => resp,
+ Err(e) => {
+ yield Err(eyre::eyre!("Failed to send SSE request: {}", e));
+ return;
+ }
+ };
- if generated.command.trim().is_empty() {
- bail!("Hub returned an empty command. Please try again with a more specific request.");
+ let status = response.status();
+ if status == reqwest::StatusCode::UNAUTHORIZED {
+ // Clear saved session on auth error
+ let _ = atuin_client::hub::delete_session().await;
+ yield Err(eyre::eyre!("Hub session expired. Re-run to authenticate again."));
+ return;
+ }
+ if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
+ yield Err(eyre::eyre!("SSE request failed ({}): {}", status, body));
+ return;
}
- return Ok(generated);
- }
+ let byte_stream = response.bytes_stream();
+ let mut stream = byte_stream.eventsource();
- if response.status() == reqwest::StatusCode::UNAUTHORIZED {
- atuin_client::hub::delete_session().await?;
- bail!("Hub session expired. Re-run to authenticate again.");
- }
+ while let Some(event) = stream.next().await {
+ match event {
+ Ok(sse_event) => {
+ let event_type = sse_event.event.as_str();
+ let data = sse_event.data.clone();
+
+ tracing::debug!(event_type = %event_type, data = %data, "SSE event received");
- let status = response.status();
- let body = response.text().await.unwrap_or_default();
- bail!("Hub request failed ({status}): {body}");
+ match event_type {
+ "text" => {
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data)
+ && let Some(content) = json.get("content").and_then(|v| v.as_str())
+ {
+ yield Ok(ChatStreamEvent::TextChunk(content.to_string()));
+ }
+ }
+ "tool_call" => {
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {
+ let id = json.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let name = json.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let input = json.get("input").cloned().unwrap_or(serde_json::json!({}));
+ yield Ok(ChatStreamEvent::ToolCall { id, name, input });
+ }
+ }
+ "tool_result" => {
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {
+ let tool_use_id = json.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let content = json.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let is_error = json.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false);
+ yield Ok(ChatStreamEvent::ToolResult { tool_use_id, content, is_error });
+ }
+ }
+ "status" => {
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data)
+ && let Some(state) = json.get("state").and_then(|v| v.as_str())
+ {
+ yield Ok(ChatStreamEvent::Status(state.to_string()));
+ }
+ }
+ "done" => {
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {
+ let session_id = json.get("session_id")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ yield Ok(ChatStreamEvent::Done { session_id });
+ } else {
+ yield Ok(ChatStreamEvent::Done { session_id: String::new() });
+ }
+ break;
+ }
+ "error" => {
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) {
+ let message = json.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error").to_string();
+ yield Ok(ChatStreamEvent::Error(message));
+ } else {
+ yield Ok(ChatStreamEvent::Error(data));
+ }
+ break;
+ }
+ _ => {
+ // Unknown event type, ignore
+ }
+ }
+ }
+ Err(e) => {
+ yield Err(eyre::eyre!("SSE error: {}", e));
+ break;
+ }
+ }
+ }
+ })
}
fn hub_url(base: &str, path: &str) -> Result<Url> {
@@ -162,35 +281,11 @@ fn detect_os() -> String {
match std::env::consts::OS {
"macos" => "macos".to_string(),
"linux" => "linux".to_string(),
+ "windows" => "windows".to_string(),
_ => "linux".to_string(),
}
}
-fn detect_shell() -> String {
- if let Ok(shell) = std::env::var("ATUIN_SHELL")
- && !shell.trim().is_empty()
- {
- return shell;
- }
-
- let shell = std::env::var("SHELL")
- .ok()
- .and_then(|value| {
- std::path::Path::new(&value)
- .file_name()
- .map(std::ffi::OsStr::to_string_lossy)
- .map(std::borrow::Cow::into_owned)
- })
- .filter(|value| !value.trim().is_empty());
-
- match shell.as_deref() {
- Some("zsh") => "zsh".to_string(),
- Some("fish") => "fish".to_string(),
- Some("bash") => "bash".to_string(),
- _ => "bash".to_string(),
- }
-}
-
#[derive(Clone, Copy)]
enum Action {
Execute,
@@ -198,105 +293,306 @@ enum Action {
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,
+ });
+
+ // 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);
+
+ 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,
+ });
+
+ // Write as JSONL (one JSON object per line)
+ if let Err(e) = writeln!(self.file, "{}", entry) {
+ tracing::warn!("Failed to write debug state: {}", e);
+ }
+ let _ = self.file.flush();
+ }
+}
+
async fn run_inline_tui(
endpoint: String,
token: String,
initial_prompt: Option<String>,
+ keep_output: bool,
+ debug_state_file: Option<String>,
) -> Result<(Action, String)> {
- let mut ui = InlineUi::new()?;
- let mut prompt = initial_prompt.unwrap_or_default();
- let mut spinner_idx = 0usize;
+ // Initialize terminal guard and app state
+ 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;
+ }
- loop {
- ui.render_prompt(&prompt)?;
- if !event::poll(Duration::from_millis(250)).context("failed to poll for input")? {
- continue;
- }
+ // Initialize debug state logger if requested
+ let mut debug_logger = debug_state_file
+ .map(|path| DebugStateLogger::new(&path))
+ .transpose()?;
- let ev = event::read().context("failed to read terminal event")?;
- let Event::Key(key) = ev else {
- continue;
+ // 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);
+ }
};
+ }
- match key.code {
- KeyCode::Esc => return Ok((Action::Cancel, String::new())),
- KeyCode::Backspace => {
- prompt.pop();
- }
- KeyCode::Enter => {
- let query = prompt.trim().to_string();
- if query.is_empty() {
- return Ok((Action::Cancel, String::new()));
- }
+ // Log initial state
+ log_state!("init");
- let response = loop {
- let endpoint_clone = endpoint.clone();
- let token_clone = token.clone();
- let query_clone = query.clone();
- let task = tokio::spawn(async move {
- generate_command(&endpoint_clone, &token_clone, &query_clone).await
- });
+ // Load theme
+ let settings = atuin_client::settings::Settings::new()?;
+ let mut theme_manager = ThemeManager::new(None, None);
+ let theme = theme_manager.load_theme(&settings.theme.name, None);
- let generated = loop {
- if task.is_finished() {
- break task.await.context("generate task join failed")?;
- }
+ // Initialize event loop
+ let mut event_loop = EventLoop::new();
- ui.render_generating(&prompt, spinner_idx)?;
- spinner_idx = (spinner_idx + 1) % SPINNER_FRAMES.len();
+ // Track chat stream
+ let mut chat_stream: Option<
+ std::pin::Pin<Box<dyn futures::Stream<Item = Result<ChatStreamEvent>> + Send>>,
+ > = None;
- if event::poll(Duration::from_millis(100))
- .context("failed to poll while generating")?
- {
- let ev = event::read().context("failed reading generate event")?;
- if let Event::Key(key) = ev
- && key.code == KeyCode::Esc
- {
- task.abort();
- return Ok((Action::Cancel, String::new()));
- }
- }
- };
+ loop {
+ // Ensure viewport is large enough for current content (capped at terminal height)
+ let needed_height = calculate_needed_height(&app.state);
+ let actual_height = guard.ensure_height(needed_height)?;
+
+ // Render current state
+ let anchor_col = guard.anchor_col();
+ let ctx = RenderContext {
+ theme,
+ anchor_col,
+ textarea: Some(&app.state.textarea),
+ max_height: actual_height,
+ };
+ // 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());
+ }
+
+ // Get next event
+ let event = event_loop.run().await?;
+
+ // Handle event based on app mode
+ match event {
+ AppEvent::Key(key) => {
+ app.handle_key(key);
+ log_state!("key");
+ }
+ AppEvent::Tick => {
+ app.state.tick();
- match generated {
- Ok(value) => break value,
- Err(err) => {
- ui.render_error(&prompt, &err.to_string())?;
- if !wait_for_retry_or_cancel()? {
- return Ok((Action::Cancel, String::new()));
+ // 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) => {
+ tracing::debug!(text = %text, "Processing TextChunk");
+ app.state.append_streaming_text(&text);
+ log_state!("text_chunk");
}
+ ChatStreamEvent::ToolCall { id, name, input } => {
+ tracing::debug!(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,
+ } => {
+ tracing::debug!(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) => {
+ tracing::debug!(status = %status, "Processing Status");
+ app.state.update_streaming_status(&status);
+ log_state!("status");
+ }
+ ChatStreamEvent::Done { session_id } => {
+ tracing::debug!(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) => {
+ tracing::debug!(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");
+ }
+ std::task::Poll::Ready(None) => {
+ chat_stream = None;
+ app.state.finalize_streaming();
+ log_state!("stream_end");
}
+ std::task::Poll::Pending => {}
}
- };
+ }
+ }
+ _ => {}
+ }
- loop {
- ui.render_review(&prompt, &response)?;
- if !event::poll(Duration::from_millis(250))
- .context("failed to poll in review")?
- {
- continue;
- }
+ // Handle user cancellation (Esc during streaming) - drop the stream
+ if app.state.was_interrupted && chat_stream.is_some() {
+ tracing::debug!("User cancelled streaming, dropping chat stream");
+ chat_stream = None;
+ app.state.was_interrupted = false; // Reset the flag
+ }
- let ev = event::read().context("failed to read review event")?;
- let Event::Key(key) = ev else {
- continue;
- };
+ // Check exit condition
+ if app.state.should_exit {
+ break;
+ }
- match key.code {
- KeyCode::Enter => return Ok((Action::Execute, response.command)),
- KeyCode::Tab => return Ok((Action::Insert, response.command)),
- KeyCode::Esc => return Ok((Action::Cancel, String::new())),
- KeyCode::Char('e') => break,
- _ => {}
- }
+ // 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
}
+ });
+
+ if last_user_content.is_some() {
+ // Build messages in Claude API format
+ let messages = app.state.events_to_messages();
+
+ // Transition to streaming mode
+ app.state.start_streaming();
+ log_state!("start_streaming");
+
+ // Start the chat stream
+ chat_stream = Some(create_chat_stream(
+ endpoint.clone(),
+ token.clone(),
+ app.state.session_id.clone(),
+ messages,
+ &settings,
+ ));
}
- KeyCode::Char(c) => {
- prompt.push(c);
- }
- _ => {}
}
}
+
+ // 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),
+ _ => (Action::Cancel, String::new()),
+ };
+
+ Ok(result)
}
struct RawModeGuard;
@@ -330,279 +626,3 @@ fn wait_for_login_confirmation() -> Result<bool> {
}
}
}
-
-fn wait_for_retry_or_cancel() -> Result<bool> {
- loop {
- let ev = event::read().context("failed to read retry/cancel key")?;
- if let Event::Key(key) = ev {
- match key.code {
- KeyCode::Enter | KeyCode::Char('r') => return Ok(true),
- KeyCode::Esc => return Ok(false),
- _ => {}
- }
- }
- }
-}
-
-const SPINNER_FRAMES: [&str; 4] = ["/", "-", "\\", "|"];
-
-struct InlineUi {
- terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
- anchor_col: u16,
-}
-
-impl InlineUi {
- fn new() -> Result<Self> {
- let anchor_col = cursor::position().map(|(x, _)| x).unwrap_or(0);
- enable_raw_mode().context("failed to enable raw mode for inline UI")?;
- let backend = CrosstermBackend::new(std::io::stdout());
- let terminal = Terminal::with_options(
- backend,
- TerminalOptions {
- viewport: Viewport::Inline(16),
- },
- )
- .context("failed to initialize inline UI")?;
- Ok(Self {
- terminal,
- anchor_col,
- })
- }
-
- fn render_prompt(&mut self, prompt: &str) -> Result<()> {
- self.render(Screen::Prompt {
- prompt,
- footer: "[Enter]: Accept [Esc]: Cancel",
- })
- }
-
- fn render_generating(&mut self, prompt: &str, spinner_idx: usize) -> Result<()> {
- self.render(Screen::Generating {
- prompt,
- footer: "[Esc]: Cancel",
- spinner_idx,
- })
- }
-
- fn render_review(&mut self, prompt: &str, response: &GenerateResponse) -> Result<()> {
- self.render(Screen::Review {
- prompt,
- response,
- footer: "[Enter]: Run [Tab]: Insert [e]: Edit [Esc]: Cancel",
- })
- }
-
- fn render_error(&mut self, prompt: &str, err: &str) -> Result<()> {
- self.render(Screen::Error {
- prompt,
- err,
- footer: "[Enter]/[r]: Retry [Esc]: Cancel",
- })
- }
-
- fn render(&mut self, screen: Screen<'_>) -> Result<()> {
- self.terminal
- .draw(|f| draw_screen(f, screen, self.anchor_col))
- .context("failed rendering inline UI")?;
- Ok(())
- }
-}
-
-impl Drop for InlineUi {
- fn drop(&mut self) {
- let _ = self.terminal.clear();
- let _ = disable_raw_mode();
- }
-}
-
-enum Screen<'a> {
- Prompt {
- prompt: &'a str,
- footer: &'a str,
- },
- Generating {
- prompt: &'a str,
- footer: &'a str,
- spinner_idx: usize,
- },
- Review {
- prompt: &'a str,
- response: &'a GenerateResponse,
- footer: &'a str,
- },
- Error {
- prompt: &'a str,
- err: &'a str,
- footer: &'a str,
- },
-}
-
-fn draw_screen(frame: &mut Frame, screen: Screen<'_>, anchor_col: u16) {
- let area = frame.area();
- let desired_width = 64u16.min(area.width.saturating_sub(2)).max(32);
- let content_width = usize::from(desired_width.saturating_sub(2)).max(1);
- let (content_preview, _, _) = build_screen_content(&screen, content_width);
- let desired_height = (wrapped_line_count(&content_preview, content_width) as u16)
- .saturating_add(2)
- .min(area.height.max(1))
- .max(3);
-
- let max_x = area.x + area.width.saturating_sub(desired_width);
- let preferred_x = area.x + anchor_col.saturating_sub(2);
- let card = Rect {
- x: preferred_x.min(max_x),
- y: area.y,
- width: desired_width,
- height: desired_height,
- };
-
- let footer = match &screen {
- Screen::Prompt { footer, .. }
- | Screen::Generating { footer, .. }
- | Screen::Review { footer, .. }
- | Screen::Error { footer, .. } => *footer,
- };
-
- let block = Block::default()
- .borders(Borders::ALL)
- .title("Describe the command you'd like to generate:")
- .title_bottom(Line::from(footer).alignment(Alignment::Right));
-
- let content_area = block.inner(card);
- frame.render_widget(block, card);
-
- let (content, show_cursor, cursor_prompt) =
- build_screen_content(&screen, usize::from(content_area.width).max(1));
-
- let paragraph = Paragraph::new(content).wrap(Wrap { trim: false });
- frame.render_widget(paragraph, content_area);
-
- if show_cursor {
- let width = usize::from(content_area.width).max(1);
- let (cursor_row, cursor_col) =
- prompt_cursor_position(cursor_prompt.as_deref().unwrap_or_default(), width);
- let cursor_x = content_area.x.saturating_add(cursor_col);
- let cursor_y = content_area.y.saturating_add(cursor_row);
- frame.set_cursor_position((cursor_x, cursor_y));
- }
-}
-
-fn format_prompt(prompt: &str) -> String {
- if prompt.is_empty() {
- return "> ".to_string();
- }
- format!("> {prompt}")
-}
-
-fn wrapped_line_count(text: &str, width: usize) -> usize {
- if width == 0 {
- return 1;
- }
-
- text.split('\n')
- .map(|line| {
- let len = line.chars().count();
- len.max(1).div_ceil(width)
- })
- .sum::<usize>()
- .max(1)
-}
-
-fn build_screen_content(
- screen: &Screen<'_>,
- content_width: usize,
-) -> (String, bool, Option<String>) {
- match screen {
- Screen::Prompt { prompt, .. } => {
- let formatted = format_prompt(prompt);
- (formatted, true, Some((*prompt).to_string()))
- }
- Screen::Generating {
- prompt,
- spinner_idx,
- ..
- } => (
- format!(
- "{}\n\n{} Generating...",
- format_prompt(prompt),
- SPINNER_FRAMES[*spinner_idx]
- ),
- false,
- None,
- ),
- Screen::Review {
- prompt, response, ..
- } => {
- let separator = "─".repeat(content_width.max(1));
- let mut text = format!(
- "{}\n\n{}\n\n$ {}\n",
- format_prompt(prompt),
- separator,
- response.command
- );
- if let Some(explanation) = &response.explanation {
- text.push('\n');
- text.push_str(explanation);
- }
- (text, false, None)
- }
- Screen::Error { prompt, err, .. } => (
- format!("{}\n\nRequest failed:\n{}", format_prompt(prompt), err),
- false,
- None,
- ),
- }
-}
-
-fn prompt_cursor_position(prompt: &str, width: usize) -> (u16, u16) {
- if width == 0 {
- return (0, 0);
- }
-
- // The visible prompt line is always `> {prompt}`.
- // We mimic word-wrapping so cursor tracking matches visual layout.
- let mut row = 0usize;
- let mut col = 2usize; // "> "
-
- let mut saw_any_word = false;
- for word in prompt.split_whitespace() {
- let word_len = word.chars().count();
- if !saw_any_word {
- saw_any_word = true;
- if col + word_len <= width {
- col += word_len;
- } else if word_len >= width {
- let used = width.saturating_sub(col);
- let remaining = word_len.saturating_sub(used);
- row += 1 + (remaining / width);
- col = remaining % width;
- } else {
- row += 1;
- col = word_len;
- }
- continue;
- }
-
- if col + 1 + word_len <= width {
- col += 1 + word_len;
- } else if word_len >= width {
- row += 1 + (word_len / width);
- col = word_len % width;
- } else {
- row += 1;
- col = word_len;
- }
- }
-
- // Keep trailing spaces user typed.
- let trailing_spaces = prompt.chars().rev().take_while(|c| *c == ' ').count();
- for _ in 0..trailing_spaces {
- if col >= width {
- row += 1;
- col = 0;
- }
- col += 1;
- }
-
- (row as u16, col as u16)
-}