Skip to main content

oxi/
interactive.rs

1//! Interactive mode for the oxi coding agent.
2//!
3//! Manages the TUI display loop, input handling, command dispatch,
4//! agent event processing, and state machine transitions.
5//!
6//! Modes: `Input → Thinking → ToolExecution → Display → Input`
7//!
8//! # Commands
9//!
10//! `/model`, `/clear`, `/compact`, `/undo`, `/redo`, `/branch`,
11//! `/session`, `/export`, `/settings`, `/help`
12
13use crate::InteractiveSession;
14use anyhow::Result;
15use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
16use oxi_agent::{Agent, AgentEvent};
17use oxi_tui::{
18    ChatMessageDisplay, ChatView, Component, ContentBlockDisplay, Input, MessageRole, Rect, Surface, Theme,
19};
20use std::collections::HashMap;
21use std::fs::{self, File, OpenOptions};
22use std::io::{BufRead, BufReader, Write};
23use std::os::unix::process::ExitStatusExt;
24use std::path::PathBuf;
25use std::sync::{Arc, RwLock};
26use std::time::{Duration, Instant};
27use tokio::sync::mpsc;
28
29// ── UI events from agent → TUI ─────────────────────────────────────────────
30
31/// Image content for message attachment
32#[derive(Debug, Clone)]
33pub struct ImageAttachment {
34    pub mime_type: String,
35    pub base64_data: String,
36    pub width: Option<u32>,
37    pub height: Option<u32>,
38}
39
40impl ImageAttachment {
41    /// Parse a base64-encoded image data URI
42    pub fn from_data_uri(uri: &str) -> Option<Self> {
43        if !uri.starts_with("data:") {
44            return None;
45        }
46        let (mime_part, data_part) = uri.split_once(',')?;
47        let mime_type = mime_part
48            .strip_prefix("data:")
49            .and_then(|s| s.split(';').next())
50            .unwrap_or("image/png")
51            .to_string();
52        let base64_data = data_part.trim().to_string();
53        if BASE64.decode(&base64_data).is_err() {
54            return None;
55        }
56        Some(Self { mime_type, base64_data, width: None, height: None })
57    }
58
59    /// Get the file extension for the mime type
60    pub fn extension(&self) -> &'static str {
61        match self.mime_type.as_str() {
62            "image/png" => "png",
63            "image/jpeg" | "image/jpg" => "jpg",
64            "image/gif" => "gif",
65            "image/webp" => "webp",
66            _ => "png",
67        }
68    }
69
70    /// Detect mime type from magic bytes
71    pub fn detect_mime_type(data: &[u8]) -> &'static str {
72        if data.len() >= 8 {
73            if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) { return "image/png"; }
74            if data.starts_with(&[0xFF, 0xD8, 0xFF]) { return "image/jpeg"; }
75            if data.starts_with(&[0x47, 0x49, 0x46]) { return "image/gif"; }
76            if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" { return "image/webp"; }
77        }
78        "image/png"
79    }
80
81    /// Create from raw bytes
82    pub fn from_bytes(data: Vec<u8>) -> Option<Self> {
83        let mime_type = Self::detect_mime_type(&data);
84        let base64_data = BASE64.encode(&data);
85        Some(Self { mime_type: mime_type.to_string(), base64_data, width: None, height: None })
86    }
87}
88
89/// Session persistence for auto-save/load
90pub struct SessionPersistence {
91    session_dir: PathBuf,
92    last_save: RwLock<Instant>,
93    last_user_message: RwLock<String>,
94}
95
96impl SessionPersistence {
97    /// Create new session persistence manager
98    pub fn new() -> Option<Self> {
99        let home = std::env::var("HOME").ok()?;
100        let session_dir = PathBuf::from(home).join(".oxi").join("sessions");
101        fs::create_dir_all(&session_dir).ok()?;
102        Some(Self {
103            session_dir,
104            last_save: RwLock::new(Instant::now()),
105            last_user_message: RwLock::new(String::new()),
106        })
107    }
108
109    fn session_file_path(&self, session_id: &str) -> PathBuf {
110        self.session_dir.join(format!("{}.jsonl", session_id))
111    }
112
113    /// Save a user message to the session file
114    pub fn save_user_message(&self, session_id: &str, content: &str, timestamp: i64) -> Result<(), std::io::Error> {
115        use std::io::Write;
116        let path = self.session_file_path(session_id);
117        let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
118        let entry = serde_json::json!({"type": "user", "content": content, "timestamp": timestamp });
119        writeln!(file, "{}", entry)?;
120        *self.last_save.write().unwrap() = Instant::now();
121        Ok(())
122    }
123
124    /// Save an assistant message to the session file
125    pub fn save_assistant_message(&self, session_id: &str, content: &str, timestamp: i64) -> Result<(), std::io::Error> {
126        use std::io::Write;
127        let path = self.session_file_path(session_id);
128        let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
129        let entry = serde_json::json!({"type": "assistant", "content": content, "timestamp": timestamp });
130        writeln!(file, "{}", entry)?;
131        *self.last_save.write().unwrap() = Instant::now();
132        Ok(())
133    }
134
135    /// Load a session from the session file
136    pub fn load_session(&self, session_id: &str) -> Result<Vec<SessionEntry>, std::io::Error> {
137        let path = self.session_file_path(session_id);
138        let file = File::open(&path)?;
139        let reader = BufReader::new(file);
140        let mut entries = Vec::new();
141        for line in reader.lines() {
142            if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line?) {
143                entries.push(entry);
144            }
145        }
146        Ok(entries)
147    }
148
149    /// Check if a session file exists
150    pub fn session_exists(&self, session_id: &str) -> bool {
151        self.session_file_path(session_id).exists()
152    }
153
154    /// Check if it's time to auto-save
155    pub fn should_auto_save(&self) -> bool {
156        self.last_save.read().unwrap().elapsed() >= Duration::from_secs(AUTO_SAVE_INTERVAL_SECS)
157    }
158
159    /// Update the last user message
160    pub fn set_last_user_message(&self, msg: String) {
161        *self.last_user_message.write().unwrap() = msg;
162    }
163
164    /// Get the last user message
165    pub fn get_last_user_message(&self) -> String {
166        self.last_user_message.read().unwrap().clone()
167    }
168}
169
170/// Session entry from JSONL file
171#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
172pub struct SessionEntry {
173    #[serde(rename = "type")]
174    pub entry_type: String,
175    pub content: String,
176    pub timestamp: i64,
177}
178
179/// Keybinding hints display
180pub struct KeybindingHints {
181    expanded: bool,
182}
183
184impl KeybindingHints {
185    pub fn new() -> Self {
186        Self { expanded: false }
187    }
188
189    /// Get the compact hints string (shown at startup)
190    pub fn compact_display(&self) -> String {
191        let hints = vec![
192            ("Ctrl+C", "quit"), ("/clear", "clear"), ("/", "commands"), ("!", "bash"),
193        ];
194        hints.iter().map(|(key, desc)| format!("[{}] {}", key, desc)).collect::<Vec<_>>().join(" • ")
195    }
196
197    /// Get the expanded hints string (shown on demand)
198    pub fn expanded_display(&self) -> String {
199        let hints = vec![
200            ("Ctrl+C", "quit"), ("Ctrl+L", "clear screen"), ("Ctrl+U", "clear line"),
201            ("Ctrl+A", "go to line start"), ("Ctrl+E", "go to line end"),
202            ("/model", "select model"), ("/clear", "clear chat"), ("/compact", "compact context"),
203            ("/undo", "undo"), ("/redo", "redo"), ("/session", "session info"),
204            ("/export", "export session"), ("/settings", "show settings"),
205            ("/help", "show help"), ("/new", "new session"),
206            ("!", "bash command"), ("!!", "bash (excluded)"),
207            ("PageUp/Down", "scroll chat"), ("Mouse", "scroll chat"),
208        ];
209        hints.iter().map(|(key, desc)| format!("  {:20} {}", key, desc)).collect::<Vec<_>>().join("\n")
210    }
211
212    /// Toggle expanded state
213    pub fn toggle(&mut self) { self.expanded = !self.expanded; }
214
215    /// Check if expanded
216    pub fn is_expanded(&self) -> bool { self.expanded }
217}
218
219impl Default for KeybindingHints {
220    fn default() -> Self { Self::new() }
221}
222
223/// Auto-save interval in seconds
224const AUTO_SAVE_INTERVAL_SECS: u64 = 30;
225
226#[derive(Debug)]
227enum UiEvent {
228    Start,
229    Thinking,
230    TextDelta(String),
231    ToolCall {
232        id: String,
233        name: String,
234        arguments: String,
235    },
236    ToolStart {
237        tool_name: String,
238    },
239    ToolResult {
240        tool_name: String,
241        content: String,
242        is_error: bool,
243    },
244    Complete,
245    Error(String),
246}
247
248// ── Interactive mode state machine ─────────────────────────────────────────
249
250/// State of the interactive loop.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum InteractiveState {
253    /// Waiting for user input.
254    Input,
255    /// Agent is thinking / streaming text.
256    Thinking,
257    /// A tool is executing.
258    ToolExecution,
259    /// Final display before returning to input.
260    Display,
261}
262
263/// Parsed slash command.
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum SlashCommand {
266    /// `/model [search]`
267    Model { search: Option<String> },
268    /// `/clear` — reset conversation.
269    Clear,
270    /// `/compact [custom_instructions]`
271    Compact { custom_instructions: Option<String> },
272    /// `/undo` — undo last exchange.
273    Undo,
274    /// `/redo` — redo last undone exchange.
275    Redo,
276    /// `/branch` — show branch / tree selector.
277    Branch,
278    /// `/session` — show session info.
279    Session,
280    /// `/export [path]`
281    Export { path: Option<String> },
282    /// `/settings` — open settings.
283    Settings,
284    /// `/help` — show help.
285    Help,
286    /// `/quit` — exit.
287    Quit,
288    /// `/name <name>` — set session name.
289    Name { name: String },
290    /// `/copy` — copy last assistant message.
291    Copy,
292    /// `/new` — start a new session.
293    New,
294    /// Unknown command.
295    Unknown { raw: String },
296}
297
298impl SlashCommand {
299    /// Parse a user-input line starting with `/` into a `SlashCommand`.
300    pub fn parse(input: &str) -> Self {
301        let trimmed = input.trim();
302        // Split into command and argument
303        let (cmd, arg) = if let Some(space) = trimmed.find(' ') {
304            (&trimmed[..space], Some(trimmed[space + 1..].trim()))
305        } else {
306            (trimmed, None)
307        };
308        let cmd_lower = cmd.to_lowercase();
309
310        match cmd_lower.as_str() {
311            "/model" => SlashCommand::Model {
312                search: arg.map(|s| s.to_string()),
313            },
314            "/clear" => SlashCommand::Clear,
315            "/compact" => SlashCommand::Compact {
316                custom_instructions: arg.map(|s| s.to_string()),
317            },
318            "/undo" => SlashCommand::Undo,
319            "/redo" => SlashCommand::Redo,
320            "/branch" | "/fork" | "/tree" => SlashCommand::Branch,
321            "/session" | "/resume" => SlashCommand::Session,
322            "/export" => SlashCommand::Export {
323                path: arg.map(|s| s.to_string()),
324            },
325            "/settings" => SlashCommand::Settings,
326            "/help" | "/?" => SlashCommand::Help,
327            "/quit" | "/exit" | "/q" => SlashCommand::Quit,
328            "/name" => SlashCommand::Name {
329                name: arg.unwrap_or("").to_string(),
330            },
331            "/copy" => SlashCommand::Copy,
332            "/new" => SlashCommand::New,
333            _ => SlashCommand::Unknown {
334                raw: trimmed.to_string(),
335            },
336        }
337    }
338
339    /// Human-readable description of the command.
340    pub fn description(&self) -> &'static str {
341        match self {
342            SlashCommand::Model { .. } => "Select model",
343            SlashCommand::Clear => "Clear conversation history",
344            SlashCommand::Compact { .. } => "Compact context",
345            SlashCommand::Undo => "Undo last exchange",
346            SlashCommand::Redo => "Redo last undone exchange",
347            SlashCommand::Branch => "Navigate session tree",
348            SlashCommand::Session => "Show session info",
349            SlashCommand::Export { .. } => "Export session",
350            SlashCommand::Settings => "Open settings",
351            SlashCommand::Help => "Show help",
352            SlashCommand::Quit => "Quit oxi",
353            SlashCommand::Name { .. } => "Set session name",
354            SlashCommand::Copy => "Copy last response",
355            SlashCommand::New => "Start new session",
356            SlashCommand::Unknown { .. } => "Unknown command",
357        }
358    }
359}
360
361// ── Interactive mode runner ─────────────────────────────────────────────────
362
363/// Run the full interactive mode loop.
364pub async fn run_interactive(app: crate::App) -> Result<()> {
365    let theme = Theme::dark();
366    let agent: Arc<Agent> = app.agent();
367
368    // Channels
369    let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
370    let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
371
372    // Agent worker thread (non-Send futures need a LocalSet)
373    let agent_for_thread: Arc<Agent> = Arc::clone(&agent);
374    let agent_handle = std::thread::spawn(move || {
375        let rt = tokio::runtime::Builder::new_current_thread()
376            .enable_all()
377            .build()
378            .expect("failed to build agent runtime");
379        rt.block_on(async {
380            let local = tokio::task::LocalSet::new();
381            local
382                .run_until(async {
383                    while let Some(prompt) = prompt_rx.recv().await {
384                        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(256);
385                        let ui_fwd = ui_tx.clone();
386                        let forwarder = tokio::task::spawn_local(async move {
387                            while let Some(event) = event_rx.recv().await {
388                                let ui_event = match event {
389                                    AgentEvent::Start { .. } => UiEvent::Start,
390                                    AgentEvent::Thinking => UiEvent::Thinking,
391                                    AgentEvent::TextChunk { text } => UiEvent::TextDelta(text),
392                                    AgentEvent::ToolCall { tool_call } => UiEvent::ToolCall {
393                                        id: tool_call.id,
394                                        name: tool_call.name,
395                                        arguments: tool_call.arguments.to_string(),
396                                    },
397                                    AgentEvent::ToolStart { tool_name, .. } => {
398                                        UiEvent::ToolStart { tool_name }
399                                    }
400                                    AgentEvent::ToolComplete { result } => UiEvent::ToolResult {
401                                        tool_name: String::new(),
402                                        content: result.content.chars().take(500).collect(),
403                                        is_error: false,
404                                    },
405                                    AgentEvent::ToolError { error, .. } => UiEvent::ToolResult {
406                                        tool_name: String::new(),
407                                        content: error.clone(),
408                                        is_error: true,
409                                    },
410                                    AgentEvent::Complete { .. } => UiEvent::Complete,
411                                    AgentEvent::Error { message } => UiEvent::Error(message),
412                                    _ => continue,
413                                };
414                                if ui_fwd.send(ui_event).await.is_err() {
415                                    break;
416                                }
417                            }
418                        });
419                        let a = Arc::clone(&agent_for_thread);
420                        let _ = a.run_with_channel(prompt, event_tx).await;
421                        let _ = forwarder.await;
422                    }
423                })
424                .await;
425        });
426    });
427
428    // TUI state
429    let mut chat_view = ChatView::new(theme.clone());
430    let mut input = Input::with_placeholder("Type a message... (Ctrl+C to quit)");
431    input.on_focus();
432    let mut state = InteractiveState::Input;
433    let mut session = InteractiveSession::new();
434
435    // Track undo/redo stacks
436    let mut undo_stack: Vec<crate::ChatMessage> = Vec::new();
437
438    // Terminal setup
439    use std::io::{self, Write};
440    crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
441    crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
442    crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture)?;
443
444    let mut running = true;
445
446    while running {
447        let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
448        let input_height: u16 = 3;
449        let chat_height = height.saturating_sub(input_height);
450
451        // ── Render ──────────────────────────────────────────────────────
452        let mut surface = Surface::new(width, height);
453
454        // Chat area
455        let chat_area = Rect::new(0, 0, width, chat_height);
456        chat_view.render(&mut surface, chat_area);
457
458        // Separator line
459        if chat_height < height {
460            for col in 0..width {
461                surface.set(
462                    chat_height,
463                    col,
464                    oxi_tui::Cell::new('\u{2500}').with_fg(theme.colors.border),
465                );
466            }
467
468            // Prompt indicator
469            surface.set(
470                chat_height + 1,
471                0,
472                oxi_tui::Cell::new('\u{276F}').with_fg(theme.colors.primary),
473            );
474
475            // Input area
476            let input_area = Rect::new(2, chat_height + 1, width.saturating_sub(4), 1);
477            input.render(&mut surface, input_area);
478
479            // Status indicator (bottom-right)
480            let status_text = match state {
481                InteractiveState::Thinking => "\u{25CF} thinking...",
482                InteractiveState::ToolExecution => "\u{2699} executing...",
483                InteractiveState::Display | InteractiveState::Input => "",
484            };
485            let status_fg = if state == InteractiveState::Thinking || state == InteractiveState::ToolExecution {
486                theme.colors.warning
487            } else {
488                theme.colors.muted
489            };
490            for (i, ch) in status_text.chars().enumerate() {
491                let col = width as usize - status_text.len() + i;
492                if col < width as usize {
493                    surface.set(
494                        chat_height + 2,
495                        col as u16,
496                        oxi_tui::Cell::new(ch).with_fg(status_fg),
497                    );
498                }
499            }
500        }
501
502        render_surface_to_terminal(&surface, width, height);
503        io::stdout().flush()?;
504
505        // ── Poll terminal events (~30 fps) ──────────────────────────────
506        let timeout = std::time::Duration::from_millis(33);
507
508        if crossterm::event::poll(timeout)? {
509            let event = crossterm::event::read()?;
510            match event {
511                crossterm::event::Event::Key(key) => {
512                    match key.code {
513                        crossterm::event::KeyCode::Enter => {
514                            if state == InteractiveState::Input {
515                                let value = input.value().to_string();
516                                if !value.is_empty() {
517                                    // ── Command handling ───────────────────
518                                    if value.starts_with('/') {
519                                        let cmd = SlashCommand::parse(&value);
520                                        match cmd {
521                                            SlashCommand::Clear => {
522                                                chat_view = ChatView::new(theme.clone());
523                                                session = InteractiveSession::new();
524                                                undo_stack.clear();
525                                                input.clear();
526                                                continue;
527                                            }
528                                            SlashCommand::Quit => {
529                                                running = false;
530                                                input.clear();
531                                                continue;
532                                            }
533                                            SlashCommand::Help => {
534                                                let help_text = format_help();
535                                                chat_view.add_message(ChatMessageDisplay {
536                                                    role: MessageRole::Assistant,
537                                                    content_blocks: vec![ContentBlockDisplay::Text {
538                                                        content: help_text,
539                                                    }],
540                                                    timestamp: now_millis(),
541                                                });
542                                                input.clear();
543                                                continue;
544                                            }
545                                            SlashCommand::Model { search } => {
546                                                let model_info = format!(
547                                                    "Current model: {}\n\
548                                                     Use /model <provider/model> to switch.",
549                                                    app.model_id(),
550                                                );
551                                                if let Some(query) = search {
552                                                    // Attempt to switch model directly
553                                                    match app.switch_model(&query) {
554                                                        Ok(()) => {
555                                                            chat_view.add_message(ChatMessageDisplay {
556                                                                role: MessageRole::Assistant,
557                                                                content_blocks: vec![
558                                                                    ContentBlockDisplay::Text {
559                                                                        content: format!(
560                                                                            "Switched to model: {}",
561                                                                            query
562                                                                        ),
563                                                                    },
564                                                                ],
565                                                                timestamp: now_millis(),
566                                                            });
567                                                        }
568                                                        Err(e) => {
569                                                            chat_view.add_message(ChatMessageDisplay {
570                                                                role: MessageRole::Assistant,
571                                                                content_blocks: vec![
572                                                                    ContentBlockDisplay::Text {
573                                                                        content: format!(
574                                                                            "Error switching model: {}",
575                                                                            e
576                                                                        ),
577                                                                    },
578                                                                ],
579                                                                timestamp: now_millis(),
580                                                            });
581                                                        }
582                                                    }
583                                                } else {
584                                                    chat_view.add_message(ChatMessageDisplay {
585                                                        role: MessageRole::Assistant,
586                                                        content_blocks: vec![
587                                                            ContentBlockDisplay::Text {
588                                                                content: model_info,
589                                                            },
590                                                        ],
591                                                        timestamp: now_millis(),
592                                                    });
593                                                }
594                                                input.clear();
595                                                continue;
596                                            }
597                                            SlashCommand::Session => {
598                                                let info = format_session_info(&session);
599                                                chat_view.add_message(ChatMessageDisplay {
600                                                    role: MessageRole::Assistant,
601                                                    content_blocks: vec![
602                                                        ContentBlockDisplay::Text { content: info },
603                                                    ],
604                                                    timestamp: now_millis(),
605                                                });
606                                                input.clear();
607                                                continue;
608                                            }
609                                            SlashCommand::Compact { custom_instructions } => {
610                                                // Compact is a hint; show message
611                                                let msg = if let Some(ci) = &custom_instructions {
612                                                    format!(
613                                                        "Compaction requested with instructions: {}\n\
614                                                         (Compaction is automatic when context exceeds threshold.)",
615                                                        ci
616                                                    )
617                                                } else {
618                                                    "Compaction requested.\n\
619                                                     (Compaction is automatic when context exceeds threshold.)"
620                                                        .to_string()
621                                                };
622                                                chat_view.add_message(ChatMessageDisplay {
623                                                    role: MessageRole::Assistant,
624                                                    content_blocks: vec![
625                                                        ContentBlockDisplay::Text { content: msg },
626                                                    ],
627                                                    timestamp: now_millis(),
628                                                });
629                                                input.clear();
630                                                continue;
631                                            }
632                                            SlashCommand::Undo => {
633                                                // Undo: remove last two messages (user + assistant)
634                                                if session.messages.len() >= 2 {
635                                                    let last_assistant = session.messages.pop();
636                                                    let last_user = session.messages.pop();
637                                                    if let (Some(u), Some(a)) = (last_user, last_assistant) {
638                                                        undo_stack.push(u);
639                                                        undo_stack.push(a);
640                                                    }
641                                                    // Rebuild chat view from remaining messages
642                                                    rebuild_chat_view(&mut chat_view, &session, &theme);
643                                                }
644                                                input.clear();
645                                                continue;
646                                            }
647                                            SlashCommand::Redo => {
648                                                if undo_stack.len() >= 2 {
649                                                    let user_msg = undo_stack.pop();
650                                                    let assistant_msg = undo_stack.pop();
651                                                    // Push in correct order: user first, then assistant
652                                                    if let (Some(a), Some(u)) = (assistant_msg, user_msg) {
653                                                        session.messages.push(u);
654                                                        session.messages.push(a);
655                                                    }
656                                                    rebuild_chat_view(&mut chat_view, &session, &theme);
657                                                }
658                                                input.clear();
659                                                continue;
660                                            }
661                                            SlashCommand::Branch => {
662                                                let msg = format!(
663                                                    "Session has {} messages.\n\
664                                                     Branch navigation coming soon.",
665                                                    session.messages.len()
666                                                );
667                                                chat_view.add_message(ChatMessageDisplay {
668                                                    role: MessageRole::Assistant,
669                                                    content_blocks: vec![
670                                                        ContentBlockDisplay::Text { content: msg },
671                                                    ],
672                                                    timestamp: now_millis(),
673                                                });
674                                                input.clear();
675                                                continue;
676                                            }
677                                            SlashCommand::Export { path } => {
678                                                let json = export_session_json(&session);
679                                                let export_path = path
680                                                    .clone()
681                                                    .unwrap_or_else(|| "oxi-session.json".to_string());
682                                                match std::fs::write(&export_path, &json) {
683                                                    Ok(()) => {
684                                                        chat_view.add_message(ChatMessageDisplay {
685                                                            role: MessageRole::Assistant,
686                                                            content_blocks: vec![
687                                                                ContentBlockDisplay::Text {
688                                                                    content: format!(
689                                                                        "Session exported to {}",
690                                                                        export_path
691                                                                    ),
692                                                                },
693                                                            ],
694                                                            timestamp: now_millis(),
695                                                        });
696                                                    }
697                                                    Err(e) => {
698                                                        chat_view.add_message(ChatMessageDisplay {
699                                                            role: MessageRole::Assistant,
700                                                            content_blocks: vec![
701                                                                ContentBlockDisplay::Text {
702                                                                    content: format!(
703                                                                        "Export failed: {}",
704                                                                        e
705                                                                    ),
706                                                                },
707                                                            ],
708                                                            timestamp: now_millis(),
709                                                        });
710                                                    }
711                                                }
712                                                input.clear();
713                                                continue;
714                                            }
715                                            SlashCommand::Settings => {
716                                                let settings_info = format!(
717                                                    "Model: {}\n\
718                                                     Thinking Level: {:?}\n\
719                                                     Temperature: {}\n\
720                                                     Max Tokens: {}\n\
721                                                     Auto-compaction: {}\n\
722                                                     Tool Timeout: {}s",
723                                                    app.settings().effective_model(None),
724                                                    app.settings().thinking_level,
725                                                    app.settings().effective_temperature()
726                                                        .map(|t| t.to_string())
727                                                        .unwrap_or_else(|| "default".to_string()),
728                                                    app.settings()
729                                                        .effective_max_tokens()
730                                                        .map(|t| t.to_string())
731                                                        .unwrap_or_else(|| "default".to_string()),
732                                                    app.settings().auto_compaction,
733                                                    app.settings().tool_timeout_seconds,
734                                                );
735                                                chat_view.add_message(ChatMessageDisplay {
736                                                    role: MessageRole::Assistant,
737                                                    content_blocks: vec![
738                                                        ContentBlockDisplay::Text {
739                                                            content: settings_info,
740                                                        },
741                                                    ],
742                                                    timestamp: now_millis(),
743                                                });
744                                                input.clear();
745                                                continue;
746                                            }
747                                            SlashCommand::Copy => {
748                                                // Get last assistant message text
749                                                let last_text = session
750                                                    .messages
751                                                    .iter()
752                                                    .rev()
753                                                    .find(|m| m.role == "assistant")
754                                                    .map(|m| m.content.clone())
755                                                    .unwrap_or_default();
756                                                // Copy to clipboard (best-effort)
757                                                let _ = copy_to_clipboard(&last_text);
758                                                input.clear();
759                                                continue;
760                                            }
761                                            SlashCommand::New => {
762                                                chat_view = ChatView::new(theme.clone());
763                                                session = InteractiveSession::new();
764                                                undo_stack.clear();
765                                                app.reset();
766                                                input.clear();
767                                                continue;
768                                            }
769                                            SlashCommand::Name { name } => {
770                                                if !name.is_empty() {
771                                                    session.session_id = Some(uuid::Uuid::new_v4());
772                                                    chat_view.add_message(ChatMessageDisplay {
773                                                        role: MessageRole::Assistant,
774                                                        content_blocks: vec![
775                                                            ContentBlockDisplay::Text {
776                                                                content: format!(
777                                                                    "Session named: {}",
778                                                                    name
779                                                                ),
780                                                            },
781                                                        ],
782                                                        timestamp: now_millis(),
783                                                    });
784                                                }
785                                                input.clear();
786                                                continue;
787                                            }
788                                            SlashCommand::Unknown { raw } => {
789                                                chat_view.add_message(ChatMessageDisplay {
790                                                    role: MessageRole::Assistant,
791                                                    content_blocks: vec![
792                                                        ContentBlockDisplay::Text {
793                                                            content: format!(
794                                                                "Unknown command: {}\n\
795                                                                 Type /help for available commands.",
796                                                                raw
797                                                            ),
798                                                        },
799                                                    ],
800                                                    timestamp: now_millis(),
801                                                });
802                                                input.clear();
803                                                continue;
804                                            }
805                                        }
806                                    } else if value.starts_with('!') {
807                                        // ── Bash command ─────────────────
808                                        let is_excluded = value.starts_with("!!");
809                                        let command = if is_excluded {
810                                            value[2..].trim().to_string()
811                                        } else {
812                                            value[1..].trim().to_string()
813                                        };
814                                        if !command.is_empty() {
815                                            // Run bash command inline, show output
816                                            let output = run_bash_command(&command);
817                                            chat_view.add_message(ChatMessageDisplay {
818                                                role: MessageRole::Assistant,
819                                                content_blocks: vec![ContentBlockDisplay::Text {
820                                                    content: format!("$ {}\n{}", command, output),
821                                                }],
822                                                timestamp: now_millis(),
823                                            });
824                                        }
825                                        input.clear();
826                                        continue;
827                                    } else {
828                                        // ── Normal user message → agent ──
829                                        session.add_user_message(value.clone());
830                                        chat_view.add_message(ChatMessageDisplay {
831                                            role: MessageRole::User,
832                                            content_blocks: vec![ContentBlockDisplay::Text {
833                                                content: value.clone(),
834                                            }],
835                                            timestamp: now_millis(),
836                                        });
837
838                                        // Transition to thinking
839                                        chat_view.start_streaming();
840                                        state = InteractiveState::Thinking;
841
842                                        let _ = prompt_tx.send(value).await;
843                                        input.clear();
844                                    }
845                                }
846                            }
847                        }
848                        crossterm::event::KeyCode::Char('c')
849                            if key
850                                .modifiers
851                                .contains(crossterm::event::KeyModifiers::CONTROL) =>
852                        {
853                            // Double Ctrl+C to exit, single Ctrl+C interrupts
854                            running = false;
855                        }
856                        crossterm::event::KeyCode::PageUp => {
857                            chat_view.scroll_up(10);
858                        }
859                        crossterm::event::KeyCode::PageDown => {
860                            chat_view.scroll_down(10);
861                        }
862                        _ => {
863                            if let Some(tui_event) = convert_key_event(key) {
864                                input.handle_event(&tui_event);
865                            }
866                        }
867                    }
868                }
869                crossterm::event::Event::Mouse(mouse) => match mouse.kind {
870                    crossterm::event::MouseEventKind::ScrollUp => {
871                        if mouse.row < chat_height {
872                            chat_view.scroll_up(3);
873                        }
874                    }
875                    crossterm::event::MouseEventKind::ScrollDown => {
876                        if mouse.row < chat_height {
877                            chat_view.scroll_down(3);
878                        }
879                    }
880                    _ => {}
881                },
882                crossterm::event::Event::Resize(_, _) => {}
883                _ => {}
884            }
885        }
886
887        // ── Drain agent events ──────────────────────────────────────────
888        while let Ok(ui_event) = ui_rx.try_recv() {
889            match ui_event {
890                UiEvent::Start => {}
891                UiEvent::Thinking => {
892                    chat_view.stream_thinking_start();
893                    state = InteractiveState::Thinking;
894                }
895                UiEvent::TextDelta(text) => {
896                    chat_view.stream_text_delta(&text);
897                }
898                UiEvent::ToolCall { id, name, arguments } => {
899                    chat_view.stream_thinking_end();
900                    chat_view.stream_tool_call(id, name, arguments);
901                    state = InteractiveState::ToolExecution;
902                }
903                UiEvent::ToolStart { tool_name } => {
904                    chat_view.stream_tool_call(
905                        format!("tool-{}", tool_name),
906                        tool_name,
907                        String::new(),
908                    );
909                    state = InteractiveState::ToolExecution;
910                }
911                UiEvent::ToolResult {
912                    tool_name,
913                    content,
914                    is_error,
915                } => {
916                    chat_view.stream_tool_result(tool_name, content, is_error);
917                }
918                UiEvent::Complete => {
919                    chat_view.stream_thinking_end();
920                    chat_view.finish_streaming();
921                    let _display_state = InteractiveState::Display;
922                    state = InteractiveState::Input;
923
924                    // Capture the response text into session
925                    let st = app.agent_state();
926                    for msg in st.messages.iter().rev() {
927                        if let oxi_ai::Message::Assistant(a) = msg {
928                            session.add_assistant_message(a.text_content());
929                            break;
930                        }
931                    }
932
933                    // Brief display then return to input
934                    state = InteractiveState::Input;
935                }
936                UiEvent::Error(msg) => {
937                    chat_view.finish_streaming_error(&msg);
938                    state = InteractiveState::Input;
939                }
940            }
941        }
942
943        // Auto-scroll
944        chat_view.scroll_to_bottom();
945    }
946
947    // ── Cleanup ────────────────────────────────────────────────────────
948    drop(prompt_tx);
949    let _ = agent_handle.join();
950    crossterm::execute!(io::stdout(), crossterm::cursor::Show)?;
951    crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture)?;
952    crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
953    io::stdout().flush()?;
954
955    Ok(())
956}
957
958// ── Helpers ─────────────────────────────────────────────────────────────────
959
960/// Render a surface to the terminal using efficient SGR sequences.
961fn render_surface_to_terminal(surface: &Surface, width: u16, height: u16) {
962    print!("\x1b[?2026h"); // Begin synchronized update
963    print!("\x1b[H"); // Move to home
964
965    let mut last_fg = oxi_tui::Color::Default;
966    let mut last_bg = oxi_tui::Color::Default;
967    let mut last_bold = false;
968    let mut last_italic = false;
969    let mut last_underline = false;
970    let mut last_strike = false;
971
972    for row in 0..height {
973        if row > 0 {
974            print!("\r\n");
975        }
976        for col in 0..width {
977            if let Some(cell) = surface.get(row, col) {
978                let fg_changed = cell.fg != last_fg;
979                let bg_changed = cell.bg != last_bg;
980                let attrs_changed = cell.attrs.bold != last_bold
981                    || cell.attrs.italic != last_italic
982                    || cell.attrs.underline != last_underline
983                    || cell.attrs.strikethrough != last_strike;
984
985                if fg_changed || bg_changed || attrs_changed {
986                    print!("\x1b[0m");
987                    match cell.fg {
988                        oxi_tui::Color::Default => {}
989                        oxi_tui::Color::Black => print!("\x1b[30m"),
990                        oxi_tui::Color::Red => print!("\x1b[31m"),
991                        oxi_tui::Color::Green => print!("\x1b[32m"),
992                        oxi_tui::Color::Yellow => print!("\x1b[33m"),
993                        oxi_tui::Color::Blue => print!("\x1b[34m"),
994                        oxi_tui::Color::Magenta => print!("\x1b[35m"),
995                        oxi_tui::Color::Cyan => print!("\x1b[36m"),
996                        oxi_tui::Color::White => print!("\x1b[37m"),
997                        oxi_tui::Color::Indexed(n) => print!("\x1b[38;5;{}m", n),
998                        oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[38;2;{};{};{}m", r, g, b),
999                    }
1000                    match cell.bg {
1001                        oxi_tui::Color::Default => {}
1002                        oxi_tui::Color::Black => print!("\x1b[40m"),
1003                        oxi_tui::Color::Red => print!("\x1b[41m"),
1004                        oxi_tui::Color::Green => print!("\x1b[42m"),
1005                        oxi_tui::Color::Yellow => print!("\x1b[43m"),
1006                        oxi_tui::Color::Blue => print!("\x1b[44m"),
1007                        oxi_tui::Color::Magenta => print!("\x1b[45m"),
1008                        oxi_tui::Color::Cyan => print!("\x1b[46m"),
1009                        oxi_tui::Color::White => print!("\x1b[47m"),
1010                        oxi_tui::Color::Indexed(n) => print!("\x1b[48;5;{}m", n),
1011                        oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[48;2;{};{};{}m", r, g, b),
1012                    }
1013                    if cell.attrs.bold {
1014                        print!("\x1b[1m");
1015                    }
1016                    if cell.attrs.italic {
1017                        print!("\x1b[3m");
1018                    }
1019                    if cell.attrs.underline {
1020                        print!("\x1b[4m");
1021                    }
1022                    if cell.attrs.strikethrough {
1023                        print!("\x1b[9m");
1024                    }
1025                    last_fg = cell.fg;
1026                    last_bg = cell.bg;
1027                    last_bold = cell.attrs.bold;
1028                    last_italic = cell.attrs.italic;
1029                    last_underline = cell.attrs.underline;
1030                    last_strike = cell.attrs.strikethrough;
1031                }
1032                print!("{}", cell.char);
1033            } else {
1034                print!(" ");
1035            }
1036        }
1037    }
1038
1039    print!("\x1b[0m");
1040    print!("\x1b[?2026l"); // End synchronized update
1041}
1042
1043/// Convert a crossterm key event to an oxi-tui Event.
1044fn convert_key_event(key: crossterm::event::KeyEvent) -> Option<oxi_tui::Event> {
1045    use oxi_tui::event::KeyCode as KC;
1046
1047    let code = match key.code {
1048        crossterm::event::KeyCode::Enter => return None,
1049        crossterm::event::KeyCode::Char('c')
1050            if key
1051                .modifiers
1052                .contains(crossterm::event::KeyModifiers::CONTROL) =>
1053        {
1054            return None;
1055        }
1056        crossterm::event::KeyCode::Esc => KC::Escape,
1057        crossterm::event::KeyCode::Tab => KC::Tab,
1058        crossterm::event::KeyCode::Backspace => KC::Backspace,
1059        crossterm::event::KeyCode::Delete => KC::Delete,
1060        crossterm::event::KeyCode::Up => KC::Up,
1061        crossterm::event::KeyCode::Down => KC::Down,
1062        crossterm::event::KeyCode::Left => KC::Left,
1063        crossterm::event::KeyCode::Right => KC::Right,
1064        crossterm::event::KeyCode::Home => KC::Home,
1065        crossterm::event::KeyCode::End => KC::End,
1066        crossterm::event::KeyCode::Char(c) => KC::Char(c),
1067        crossterm::event::KeyCode::F(n) => KC::F(n),
1068        _ => return None,
1069    };
1070
1071    let modifiers = oxi_tui::KeyModifiers {
1072        shift: key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT),
1073        ctrl: key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL),
1074        alt: key.modifiers.contains(crossterm::event::KeyModifiers::ALT),
1075        meta: key.modifiers.contains(crossterm::event::KeyModifiers::META),
1076    };
1077
1078    Some(oxi_tui::Event::Key(oxi_tui::KeyEvent::with_modifiers(
1079        code, modifiers,
1080    )))
1081}
1082
1083/// Format the help text.
1084fn format_help() -> String {
1085    r#"oxi — AI Coding Assistant
1086
1087Commands:
1088  /model [search]    Select or switch model
1089  /clear             Clear conversation history
1090  /compact [instr]   Compact context with optional instructions
1091  /undo              Undo last exchange
1092  /redo              Redo last undone exchange
1093  /branch            Navigate session tree
1094  /session           Show session info and stats
1095  /export [path]     Export session to JSON
1096  /settings          Show current settings
1097  /name <name>       Set session display name
1098  /copy              Copy last assistant response
1099  /new               Start a new session
1100  /help              Show this help message
1101  /quit              Quit oxi
1102
1103Bash:
1104  !<command>         Run a bash command
1105  !!<command>        Run bash (excluded from context)
1106
1107Keybindings:
1108  Enter              Send message or command
1109  Ctrl+C             Quit
1110  PageUp/PageDown    Scroll chat history
1111  Mouse scroll       Scroll chat history
1112"#.to_string()
1113}
1114
1115/// Format session info.
1116fn format_session_info(session: &InteractiveSession) -> String {
1117    let msg_count = session.messages.len();
1118    let user_count = session.messages.iter().filter(|m| m.role == "user").count();
1119    let assistant_count = session
1120        .messages
1121        .iter()
1122        .filter(|m| m.role == "assistant")
1123        .count();
1124    let entry_count = session.entries.len();
1125
1126    format!(
1127        "Session Info:\n\
1128         Messages: {} total ({} user, {} assistant)\n\
1129         Entries: {}\n\
1130         ID: {}",
1131        msg_count,
1132        user_count,
1133        assistant_count,
1134        entry_count,
1135        session
1136            .session_id
1137            .map(|u| u.to_string())
1138            .unwrap_or_else(|| "none".to_string()),
1139    )
1140}
1141
1142/// Export session to a JSON string.
1143fn export_session_json(session: &InteractiveSession) -> String {
1144    let messages: Vec<serde_json::Value> = session
1145        .messages
1146        .iter()
1147        .map(|m| {
1148            serde_json::json!({
1149                "role": m.role,
1150                "content": m.content,
1151                "timestamp": m.timestamp.to_rfc3339(),
1152            })
1153        })
1154        .collect();
1155
1156    serde_json::to_string_pretty(&serde_json::json!({
1157        "session_id": session.session_id.map(|u| u.to_string()),
1158        "messages": messages,
1159        "entry_count": session.entries.len(),
1160    }))
1161    .unwrap_or_else(|_| "{}".to_string())
1162}
1163
1164/// Rebuild the chat view from session messages (used after undo/redo).
1165fn rebuild_chat_view(chat_view: &mut ChatView, session: &InteractiveSession, theme: &Theme) {
1166    *chat_view = ChatView::new(theme.clone());
1167    for msg in &session.messages {
1168        let role = if msg.role == "user" {
1169            MessageRole::User
1170        } else {
1171            MessageRole::Assistant
1172        };
1173        chat_view.add_message(ChatMessageDisplay {
1174            role,
1175            content_blocks: vec![ContentBlockDisplay::Text {
1176                content: msg.content.clone(),
1177            }],
1178            timestamp: msg.timestamp.timestamp_millis(),
1179        });
1180    }
1181}
1182
1183/// Run a bash command and return its output.
1184fn run_bash_command(command: &str) -> String {
1185    use std::process::Command;
1186    let output = Command::new("sh")
1187        .arg("-c")
1188        .arg(command)
1189        .output()
1190        .unwrap_or_else(|e| std::process::Output {
1191            stdout: Vec::new(),
1192            stderr: format!("Failed to execute: {}", e).into_bytes(),
1193            status: std::process::ExitStatus::from_raw(1),
1194        });
1195
1196    let mut result = String::new();
1197    if !output.stdout.is_empty() {
1198        result.push_str(&String::from_utf8_lossy(&output.stdout));
1199    }
1200    if !output.stderr.is_empty() {
1201        if !result.is_empty() {
1202            result.push('\n');
1203        }
1204        result.push_str(&String::from_utf8_lossy(&output.stderr));
1205    }
1206    if !output.status.success() {
1207        result.push_str(&format!("\nExit code: {}", output.status.code().unwrap_or(-1)));
1208    }
1209    result
1210}
1211
1212// ── Diff viewer with intra-line highlighting ────────────────────────────────
1213
1214/// Compute word-level diff between two strings
1215pub fn compute_word_diff(old: &str, new: &str) -> DiffResult {
1216    let old_words: Vec<&str> = old.split_whitespace().collect();
1217    let new_words: Vec<&str> = new.split_whitespace().collect();
1218    let lcs = longest_common_subsequence(&old_words, &new_words);
1219
1220    let mut changes = Vec::new();
1221    let mut old_idx = 0usize;
1222    let mut new_idx = 0usize;
1223
1224    for (matched_old, matched_new) in lcs {
1225        while old_idx < matched_old {
1226            changes.push(DiffChange::Removed(old_words[old_idx].to_string()));
1227            old_idx += 1;
1228        }
1229        while new_idx < matched_new {
1230            changes.push(DiffChange::Added(new_words[new_idx].to_string()));
1231            new_idx += 1;
1232        }
1233        changes.push(DiffChange::Unchanged(new_words[new_idx].to_string()));
1234        old_idx += 1;
1235        new_idx += 1;
1236    }
1237    while old_idx < old_words.len() {
1238        changes.push(DiffChange::Removed(old_words[old_idx].to_string()));
1239        old_idx += 1;
1240    }
1241    while new_idx < new_words.len() {
1242        changes.push(DiffChange::Added(new_words[new_idx].to_string()));
1243        new_idx += 1;
1244    }
1245
1246    DiffResult { changes }
1247}
1248
1249/// Longest common subsequence for word arrays
1250fn longest_common_subsequence<'a>(a: &[&'a str], b: &[&'a str]) -> Vec<(usize, usize)> {
1251    let m = a.len();
1252    let n = b.len();
1253    let mut dp = vec![vec![0usize; n + 1]; m + 1];
1254
1255    for i in 1..=m {
1256        for j in 1..=n {
1257            if a[i - 1] == b[j - 1] { dp[i][j] = dp[i - 1][j - 1] + 1; }
1258            else { dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]); }
1259        }
1260    }
1261
1262    let mut lcs = Vec::new();
1263    let mut i = m;
1264    let mut j = n;
1265    while i > 0 && j > 0 {
1266        if a[i - 1] == b[j - 1] {
1267            lcs.push((i - 1, j - 1));
1268            i -= 1;
1269            j -= 1;
1270        } else if dp[i - 1][j] > dp[i][j - 1] { i -= 1; }
1271        else { j -= 1; }
1272    }
1273    lcs.reverse();
1274    lcs
1275}
1276
1277/// Result of word-level diff computation
1278#[derive(Debug, Clone)]
1279pub struct DiffResult {
1280    pub changes: Vec<DiffChange>,
1281}
1282
1283/// Individual change in a diff
1284#[derive(Debug, Clone, PartialEq, Eq)]
1285pub enum DiffChange {
1286    Unchanged(String),
1287    Added(String),
1288    Removed(String),
1289}
1290
1291impl DiffResult {
1292    /// Format the diff result with ANSI colors
1293    pub fn format_ansi(&self) -> String {
1294        use std::fmt::Write;
1295        let mut result = String::new();
1296        for change in &self.changes {
1297            match change {
1298                DiffChange::Unchanged(s) => { write!(&mut result, "{} ", s).unwrap(); }
1299                DiffChange::Added(s) => { write!(&mut result, "\x1b[32m{}\x1b[0m ", s).unwrap(); }
1300                DiffChange::Removed(s) => { write!(&mut result, "\x1b[31m{}\x1b[0m ", s).unwrap(); }
1301            }
1302        }
1303        result.trim_end().to_string()
1304    }
1305
1306    /// Get summary statistics
1307    pub fn summary(&self) -> (usize, usize, usize) {
1308        let mut added = 0usize;
1309        let mut removed = 0usize;
1310        let mut unchanged = 0usize;
1311        for change in &self.changes {
1312            match change {
1313                DiffChange::Unchanged(_) => unchanged += 1,
1314                DiffChange::Added(_) => added += 1,
1315                DiffChange::Removed(_) => removed += 1,
1316            }
1317        }
1318        (added, removed, unchanged)
1319    }
1320}
1321
1322/// Copy text to clipboard (best-effort, uses pbcopy/xclip/wl-copy).
1323fn copy_to_clipboard(text: &str) -> Result<()> {
1324    use std::io::Write;
1325    use std::process::{Command, Stdio};
1326
1327    let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
1328        ("pbcopy", &[])
1329    } else if cfg!(target_os = "linux") {
1330        // Try wl-copy first (Wayland), fall back to xclip (X11)
1331        if std::path::Path::new("/usr/bin/wl-copy").exists()
1332            || std::path::Path::new("/usr/local/bin/wl-copy").exists()
1333        {
1334            ("wl-copy", &[])
1335        } else {
1336            ("xclip", &["-selection", "clipboard"])
1337        }
1338    } else {
1339        return Err(anyhow::anyhow!("Clipboard not supported on this platform"));
1340    };
1341
1342    let mut child = Command::new(cmd)
1343        .args(args)
1344        .stdin(Stdio::piped())
1345        .spawn()
1346        .map_err(|e| anyhow::anyhow!("Failed to spawn clipboard command: {}", e))?;
1347
1348    if let Some(mut stdin) = child.stdin.take() {
1349        let _ = stdin.write_all(text.as_bytes());
1350    }
1351
1352    let _ = child.wait();
1353    Ok(())
1354}
1355
1356/// Current timestamp in milliseconds.
1357fn now_millis() -> i64 {
1358    std::time::SystemTime::now()
1359        .duration_since(std::time::UNIX_EPOCH)
1360        .unwrap_or_default()
1361        .as_millis() as i64
1362}
1363
1364// ── Tests ───────────────────────────────────────────────────────────────────
1365
1366#[cfg(test)]
1367mod tests {
1368    use super::*;
1369
1370    // ── SlashCommand parsing tests ────────────────────────────────────
1371
1372    #[test]
1373    fn test_parse_model_no_arg() {
1374        let cmd = SlashCommand::parse("/model");
1375        assert_eq!(cmd, SlashCommand::Model { search: None });
1376    }
1377
1378    #[test]
1379    fn test_parse_model_with_search() {
1380        let cmd = SlashCommand::parse("/model claude-sonnet");
1381        assert_eq!(
1382            cmd,
1383            SlashCommand::Model {
1384                search: Some("claude-sonnet".to_string()),
1385            }
1386        );
1387    }
1388
1389    #[test]
1390    fn test_parse_clear() {
1391        assert_eq!(SlashCommand::parse("/clear"), SlashCommand::Clear);
1392    }
1393
1394    #[test]
1395    fn test_parse_compact_no_arg() {
1396        assert_eq!(
1397            SlashCommand::parse("/compact"),
1398            SlashCommand::Compact {
1399                custom_instructions: None
1400            }
1401        );
1402    }
1403
1404    #[test]
1405    fn test_parse_compact_with_instructions() {
1406        assert_eq!(
1407            SlashCommand::parse("/compact focus on error handling"),
1408            SlashCommand::Compact {
1409                custom_instructions: Some("focus on error handling".to_string()),
1410            }
1411        );
1412    }
1413
1414    #[test]
1415    fn test_parse_undo_redo() {
1416        assert_eq!(SlashCommand::parse("/undo"), SlashCommand::Undo);
1417        assert_eq!(SlashCommand::parse("/redo"), SlashCommand::Redo);
1418    }
1419
1420    #[test]
1421    fn test_parse_aliases() {
1422        // /? is an alias for /help
1423        assert_eq!(SlashCommand::parse("/?"), SlashCommand::Help);
1424        // /exit and /q are aliases for /quit
1425        assert_eq!(SlashCommand::parse("/exit"), SlashCommand::Quit);
1426        assert_eq!(SlashCommand::parse("/q"), SlashCommand::Quit);
1427        // /fork and /tree are aliases for /branch
1428        assert_eq!(SlashCommand::parse("/fork"), SlashCommand::Branch);
1429        assert_eq!(SlashCommand::parse("/tree"), SlashCommand::Branch);
1430        // /resume is alias for /session
1431        assert_eq!(SlashCommand::parse("/resume"), SlashCommand::Session);
1432    }
1433
1434    #[test]
1435    fn test_parse_unknown() {
1436        let cmd = SlashCommand::parse("/foobar");
1437        assert_eq!(
1438            cmd,
1439            SlashCommand::Unknown {
1440                raw: "/foobar".to_string()
1441            }
1442        );
1443    }
1444
1445    // ── State machine tests ───────────────────────────────────────────
1446
1447    #[test]
1448    fn test_state_ordering() {
1449        // Verify that states exist and are distinct
1450        let states = [
1451            InteractiveState::Input,
1452            InteractiveState::Thinking,
1453            InteractiveState::ToolExecution,
1454            InteractiveState::Display,
1455        ];
1456        // All unique
1457        for i in 0..states.len() {
1458            for j in (i + 1)..states.len() {
1459                assert_ne!(states[i], states[j]);
1460            }
1461        }
1462    }
1463
1464    #[test]
1465    fn test_state_transitions_input_to_thinking() {
1466        let state = InteractiveState::Input;
1467        // On user submit: Input -> Thinking
1468        let next = InteractiveState::Thinking;
1469        assert_eq!(next, InteractiveState::Thinking);
1470        assert_ne!(state, next);
1471    }
1472
1473    #[test]
1474    fn test_state_transitions_thinking_to_tool_execution() {
1475        // On tool call: Thinking -> ToolExecution
1476        let state = InteractiveState::Thinking;
1477        let next = InteractiveState::ToolExecution;
1478        assert_ne!(state, next);
1479    }
1480
1481    #[test]
1482    fn test_state_transitions_tool_execution_to_display() {
1483        // On complete: ToolExecution -> Display -> Input
1484        let state = InteractiveState::ToolExecution;
1485        let display = InteractiveState::Display;
1486        let input = InteractiveState::Input;
1487        assert_ne!(state, display);
1488        assert_ne!(display, input);
1489    }
1490
1491    // ── Bash execution tests ──────────────────────────────────────────
1492
1493    #[test]
1494    fn test_bash_command_execution() {
1495        let output = run_bash_command("echo hello");
1496        assert!(output.contains("hello"));
1497    }
1498
1499    #[test]
1500    fn test_bash_command_failure() {
1501        let output = run_bash_command("false");
1502        assert!(output.contains("Exit code:"));
1503    }
1504
1505    // ── Export tests ──────────────────────────────────────────────────
1506
1507    #[test]
1508    fn test_export_empty_session() {
1509        let session = InteractiveSession::new();
1510        let json = export_session_json(&session);
1511        assert!(json.contains("\"messages\": []"));
1512        assert!(json.contains("\"entry_count\": 0"));
1513    }
1514
1515    #[test]
1516    fn test_export_session_with_messages() {
1517        let mut session = InteractiveSession::new();
1518        session.add_user_message("Hello".to_string());
1519        session.add_assistant_message("Hi there!".to_string());
1520        let json = export_session_json(&session);
1521        assert!(json.contains("\"role\": \"user\""));
1522        assert!(json.contains("\"content\": \"Hello\""));
1523        assert!(json.contains("\"role\": \"assistant\""));
1524    }
1525
1526    // ── Session info tests ────────────────────────────────────────────
1527
1528    #[test]
1529    fn test_session_info_empty() {
1530        let session = InteractiveSession::new();
1531        let info = format_session_info(&session);
1532        assert!(info.contains("Messages: 0 total"));
1533        assert!(info.contains("ID: none"));
1534    }
1535
1536    #[test]
1537    fn test_session_info_with_messages() {
1538        let mut session = InteractiveSession::new();
1539        session.add_user_message("Hello".to_string());
1540        session.add_assistant_message("Hi".to_string());
1541        let info = format_session_info(&session);
1542        assert!(info.contains("Messages: 2 total"));
1543        assert!(info.contains("1 user"));
1544        assert!(info.contains("1 assistant"));
1545    }
1546
1547    // ── Help text test ────────────────────────────────────────────────
1548
1549    #[test]
1550    fn test_help_text_contains_all_commands() {
1551        let help = format_help();
1552        assert!(help.contains("/model"));
1553        assert!(help.contains("/clear"));
1554        assert!(help.contains("/compact"));
1555        assert!(help.contains("/undo"));
1556        assert!(help.contains("/redo"));
1557        assert!(help.contains("/branch"));
1558        assert!(help.contains("/session"));
1559        assert!(help.contains("/export"));
1560        assert!(help.contains("/settings"));
1561        assert!(help.contains("/help"));
1562        assert!(help.contains("/quit"));
1563    }
1564
1565    // ── Command description tests ─────────────────────────────────────
1566
1567    #[test]
1568    fn test_command_descriptions() {
1569        assert_eq!(
1570            SlashCommand::Model { search: None }.description(),
1571            "Select model"
1572        );
1573        assert_eq!(SlashCommand::Clear.description(), "Clear conversation history");
1574        assert_eq!(SlashCommand::Undo.description(), "Undo last exchange");
1575        assert_eq!(SlashCommand::Redo.description(), "Redo last undone exchange");
1576        assert_eq!(SlashCommand::Quit.description(), "Quit oxi");
1577        assert_eq!(
1578            SlashCommand::Unknown { raw: "/x".to_string() }.description(),
1579            "Unknown command"
1580        );
1581    }
1582
1583    // ── Image attachment tests ─────────────────────────────────────────
1584
1585    #[test]
1586    fn test_image_attachment_from_data_uri() {
1587        // Valid PNG data URI
1588        let uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
1589        let img = ImageAttachment::from_data_uri(uri);
1590        assert!(img.is_some());
1591        let img = img.unwrap();
1592        assert_eq!(img.mime_type, "image/png");
1593    }
1594
1595    #[test]
1596    fn test_image_attachment_invalid_uri() {
1597        // Not a data URI
1598        let img = ImageAttachment::from_data_uri("not a data uri");
1599        assert!(img.is_none());
1600    }
1601
1602    #[test]
1603    fn test_image_attachment_extension() {
1604        let img = ImageAttachment {
1605            mime_type: "image/png".to_string(),
1606            base64_data: String::new(),
1607            width: None,
1608            height: None,
1609        };
1610        assert_eq!(img.extension(), "png");
1611
1612        let img_jpeg = ImageAttachment {
1613            mime_type: "image/jpeg".to_string(),
1614            base64_data: String::new(),
1615            width: None,
1616            height: None,
1617        };
1618        assert_eq!(img_jpeg.extension(), "jpg");
1619    }
1620
1621    #[test]
1622    fn test_image_attachment_detect_mime_type() {
1623        // PNG magic bytes
1624        let png_bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
1625        assert_eq!(ImageAttachment::detect_mime_type(&png_bytes), "image/png");
1626
1627        // JPEG magic bytes
1628        let jpeg_bytes: Vec<u8> = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46];
1629        assert_eq!(ImageAttachment::detect_mime_type(&jpeg_bytes), "image/jpeg");
1630
1631        // Unknown
1632        let unknown: Vec<u8> = vec![0x00, 0x00, 0x00, 0x00];
1633        assert_eq!(ImageAttachment::detect_mime_type(&unknown), "image/png"); // fallback
1634    }
1635
1636    #[test]
1637    fn test_image_attachment_from_bytes() {
1638        // PNG magic bytes
1639        let png_data: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
1640        let img = ImageAttachment::from_bytes(png_data);
1641        assert!(img.is_some());
1642        let img = img.unwrap();
1643        assert_eq!(img.mime_type, "image/png");
1644        assert!(!img.base64_data.is_empty());
1645    }
1646
1647    // ── Session persistence tests ─────────────────────────────────────
1648
1649    #[test]
1650    fn test_session_persistence_new() {
1651        let persistence = SessionPersistence::new();
1652        // May be None if HOME not set or dir creation fails
1653        assert!(persistence.is_some() || persistence.is_none());
1654    }
1655
1656    // ── Keybinding hints tests ─────────────────────────────────────────
1657
1658    #[test]
1659    fn test_keybinding_hints_compact() {
1660        let hints = KeybindingHints::new();
1661        let compact = hints.compact_display();
1662        assert!(compact.contains("Ctrl+C"));
1663        assert!(compact.contains("quit"));
1664    }
1665
1666    #[test]
1667    fn test_keybinding_hints_expanded() {
1668        let hints = KeybindingHints::new();
1669        let expanded = hints.expanded_display();
1670        assert!(expanded.contains("Ctrl+C"));
1671        assert!(expanded.contains("Ctrl+L"));
1672        assert!(expanded.contains("Ctrl+U"));
1673    }
1674
1675    #[test]
1676    fn test_keybinding_hints_toggle() {
1677        let mut hints = KeybindingHints::new();
1678        assert!(!hints.is_expanded());
1679        hints.toggle();
1680        assert!(hints.is_expanded());
1681        hints.toggle();
1682        assert!(!hints.is_expanded());
1683    }
1684
1685    // ── Word-level diff tests ──────────────────────────────────────────
1686
1687    #[test]
1688    fn test_compute_word_diff_identical() {
1689        let result = compute_word_diff("hello world", "hello world");
1690        let (added, removed, unchanged) = result.summary();
1691        assert_eq!(added, 0);
1692        assert_eq!(removed, 0);
1693        assert_eq!(unchanged, 2);
1694    }
1695
1696    #[test]
1697    fn test_compute_word_diff_added_words() {
1698        let result = compute_word_diff("hello", "hello world");
1699        let (added, removed, _) = result.summary();
1700        assert_eq!(added, 1); // "world" added
1701        assert_eq!(removed, 0);
1702    }
1703
1704    #[test]
1705    fn test_compute_word_diff_removed_words() {
1706        let result = compute_word_diff("hello world", "hello");
1707        let (added, removed, _) = result.summary();
1708        assert_eq!(added, 0);
1709        assert_eq!(removed, 1); // "world" removed
1710    }
1711
1712    #[test]
1713    fn test_compute_word_diff_changed() {
1714        let result = compute_word_diff("hello world", "hello rust");
1715        let (added, removed, unchanged) = result.summary();
1716        assert_eq!(added, 1); // "rust" added
1717        assert_eq!(removed, 1); // "world" removed
1718        assert_eq!(unchanged, 1); // "hello" unchanged
1719    }
1720
1721    #[test]
1722    fn test_diff_result_format_ansi() {
1723        let result = compute_word_diff("foo bar", "foo baz");
1724        let formatted = result.format_ansi();
1725        assert!(formatted.contains("foo"));
1726        assert!(formatted.contains("bar") || formatted.contains("baz"));
1727    }
1728
1729    #[test]
1730    fn test_diff_result_empty() {
1731        let result = compute_word_diff("", "hello");
1732        let (added, removed, _) = result.summary();
1733        assert_eq!(added, 1);
1734        assert_eq!(removed, 0);
1735    }
1736
1737    #[test]
1738    fn test_lcs_algorithm() {
1739        let a = vec!["a", "b", "c"];
1740        let b = vec!["a", "c", "d"];
1741        let lcs = longest_common_subsequence(&a, &b);
1742        assert!(lcs.contains(&(0, 0))); // "a"
1743        assert!(lcs.contains(&(2, 1))); // "c"
1744    }
1745}