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 oxi_agent::{Agent, AgentEvent};
16use oxi_tui::{
17    ChatMessageDisplay, ChatView, Component, ContentBlockDisplay, Input, MessageRole, Rect, Surface, Theme,
18};
19use std::os::unix::process::ExitStatusExt;
20use std::sync::Arc;
21use tokio::sync::mpsc;
22
23// ── UI events from agent → TUI ─────────────────────────────────────────────
24
25#[derive(Debug)]
26enum UiEvent {
27    Start,
28    Thinking,
29    TextDelta(String),
30    ToolCall {
31        id: String,
32        name: String,
33        arguments: String,
34    },
35    ToolStart {
36        tool_name: String,
37    },
38    ToolResult {
39        tool_name: String,
40        content: String,
41        is_error: bool,
42    },
43    Complete,
44    Error(String),
45}
46
47// ── Interactive mode state machine ─────────────────────────────────────────
48
49/// State of the interactive loop.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum InteractiveState {
52    /// Waiting for user input.
53    Input,
54    /// Agent is thinking / streaming text.
55    Thinking,
56    /// A tool is executing.
57    ToolExecution,
58    /// Final display before returning to input.
59    Display,
60}
61
62/// Parsed slash command.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum SlashCommand {
65    /// `/model [search]`
66    Model { search: Option<String> },
67    /// `/clear` — reset conversation.
68    Clear,
69    /// `/compact [custom_instructions]`
70    Compact { custom_instructions: Option<String> },
71    /// `/undo` — undo last exchange.
72    Undo,
73    /// `/redo` — redo last undone exchange.
74    Redo,
75    /// `/branch` — show branch / tree selector.
76    Branch,
77    /// `/session` — show session info.
78    Session,
79    /// `/export [path]`
80    Export { path: Option<String> },
81    /// `/settings` — open settings.
82    Settings,
83    /// `/help` — show help.
84    Help,
85    /// `/quit` — exit.
86    Quit,
87    /// `/name <name>` — set session name.
88    Name { name: String },
89    /// `/copy` — copy last assistant message.
90    Copy,
91    /// `/new` — start a new session.
92    New,
93    /// Unknown command.
94    Unknown { raw: String },
95}
96
97impl SlashCommand {
98    /// Parse a user-input line starting with `/` into a `SlashCommand`.
99    pub fn parse(input: &str) -> Self {
100        let trimmed = input.trim();
101        // Split into command and argument
102        let (cmd, arg) = if let Some(space) = trimmed.find(' ') {
103            (&trimmed[..space], Some(trimmed[space + 1..].trim()))
104        } else {
105            (trimmed, None)
106        };
107        let cmd_lower = cmd.to_lowercase();
108
109        match cmd_lower.as_str() {
110            "/model" => SlashCommand::Model {
111                search: arg.map(|s| s.to_string()),
112            },
113            "/clear" => SlashCommand::Clear,
114            "/compact" => SlashCommand::Compact {
115                custom_instructions: arg.map(|s| s.to_string()),
116            },
117            "/undo" => SlashCommand::Undo,
118            "/redo" => SlashCommand::Redo,
119            "/branch" | "/fork" | "/tree" => SlashCommand::Branch,
120            "/session" | "/resume" => SlashCommand::Session,
121            "/export" => SlashCommand::Export {
122                path: arg.map(|s| s.to_string()),
123            },
124            "/settings" => SlashCommand::Settings,
125            "/help" | "/?" => SlashCommand::Help,
126            "/quit" | "/exit" | "/q" => SlashCommand::Quit,
127            "/name" => SlashCommand::Name {
128                name: arg.unwrap_or("").to_string(),
129            },
130            "/copy" => SlashCommand::Copy,
131            "/new" => SlashCommand::New,
132            _ => SlashCommand::Unknown {
133                raw: trimmed.to_string(),
134            },
135        }
136    }
137
138    /// Human-readable description of the command.
139    pub fn description(&self) -> &'static str {
140        match self {
141            SlashCommand::Model { .. } => "Select model",
142            SlashCommand::Clear => "Clear conversation history",
143            SlashCommand::Compact { .. } => "Compact context",
144            SlashCommand::Undo => "Undo last exchange",
145            SlashCommand::Redo => "Redo last undone exchange",
146            SlashCommand::Branch => "Navigate session tree",
147            SlashCommand::Session => "Show session info",
148            SlashCommand::Export { .. } => "Export session",
149            SlashCommand::Settings => "Open settings",
150            SlashCommand::Help => "Show help",
151            SlashCommand::Quit => "Quit oxi",
152            SlashCommand::Name { .. } => "Set session name",
153            SlashCommand::Copy => "Copy last response",
154            SlashCommand::New => "Start new session",
155            SlashCommand::Unknown { .. } => "Unknown command",
156        }
157    }
158}
159
160// ── Interactive mode runner ─────────────────────────────────────────────────
161
162/// Run the full interactive mode loop.
163pub async fn run_interactive(app: crate::App) -> Result<()> {
164    let theme = Theme::dark();
165    let agent: Arc<Agent> = app.agent();
166
167    // Channels
168    let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
169    let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
170
171    // Agent worker thread (non-Send futures need a LocalSet)
172    let agent_for_thread: Arc<Agent> = Arc::clone(&agent);
173    let agent_handle = std::thread::spawn(move || {
174        let rt = tokio::runtime::Builder::new_current_thread()
175            .enable_all()
176            .build()
177            .expect("failed to build agent runtime");
178        rt.block_on(async {
179            let local = tokio::task::LocalSet::new();
180            local
181                .run_until(async {
182                    while let Some(prompt) = prompt_rx.recv().await {
183                        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(256);
184                        let ui_fwd = ui_tx.clone();
185                        let forwarder = tokio::task::spawn_local(async move {
186                            while let Some(event) = event_rx.recv().await {
187                                let ui_event = match event {
188                                    AgentEvent::Start { .. } => UiEvent::Start,
189                                    AgentEvent::Thinking => UiEvent::Thinking,
190                                    AgentEvent::TextChunk { text } => UiEvent::TextDelta(text),
191                                    AgentEvent::ToolCall { tool_call } => UiEvent::ToolCall {
192                                        id: tool_call.id,
193                                        name: tool_call.name,
194                                        arguments: tool_call.arguments.to_string(),
195                                    },
196                                    AgentEvent::ToolStart { tool_name, .. } => {
197                                        UiEvent::ToolStart { tool_name }
198                                    }
199                                    AgentEvent::ToolComplete { result } => UiEvent::ToolResult {
200                                        tool_name: String::new(),
201                                        content: result.content.chars().take(500).collect(),
202                                        is_error: false,
203                                    },
204                                    AgentEvent::ToolError { error, .. } => UiEvent::ToolResult {
205                                        tool_name: String::new(),
206                                        content: error.clone(),
207                                        is_error: true,
208                                    },
209                                    AgentEvent::Complete { .. } => UiEvent::Complete,
210                                    AgentEvent::Error { message } => UiEvent::Error(message),
211                                    _ => continue,
212                                };
213                                if ui_fwd.send(ui_event).await.is_err() {
214                                    break;
215                                }
216                            }
217                        });
218                        let a = Arc::clone(&agent_for_thread);
219                        let _ = a.run_with_channel(prompt, event_tx).await;
220                        let _ = forwarder.await;
221                    }
222                })
223                .await;
224        });
225    });
226
227    // TUI state
228    let mut chat_view = ChatView::new(theme.clone());
229    let mut input = Input::with_placeholder("Type a message... (Ctrl+C to quit)");
230    input.on_focus();
231    let mut state = InteractiveState::Input;
232    let mut session = InteractiveSession::new();
233
234    // Track undo/redo stacks
235    let mut undo_stack: Vec<crate::ChatMessage> = Vec::new();
236
237    // Terminal setup
238    use std::io::{self, Write};
239    crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
240    crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
241    crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture)?;
242
243    let mut running = true;
244
245    while running {
246        let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
247        let input_height: u16 = 3;
248        let chat_height = height.saturating_sub(input_height);
249
250        // ── Render ──────────────────────────────────────────────────────
251        let mut surface = Surface::new(width, height);
252
253        // Chat area
254        let chat_area = Rect::new(0, 0, width, chat_height);
255        chat_view.render(&mut surface, chat_area);
256
257        // Separator line
258        if chat_height < height {
259            for col in 0..width {
260                surface.set(
261                    chat_height,
262                    col,
263                    oxi_tui::Cell::new('\u{2500}').with_fg(theme.colors.border),
264                );
265            }
266
267            // Prompt indicator
268            surface.set(
269                chat_height + 1,
270                0,
271                oxi_tui::Cell::new('\u{276F}').with_fg(theme.colors.primary),
272            );
273
274            // Input area
275            let input_area = Rect::new(2, chat_height + 1, width.saturating_sub(4), 1);
276            input.render(&mut surface, input_area);
277
278            // Status indicator (bottom-right)
279            let status_text = match state {
280                InteractiveState::Thinking => "\u{25CF} thinking...",
281                InteractiveState::ToolExecution => "\u{2699} executing...",
282                InteractiveState::Display | InteractiveState::Input => "",
283            };
284            let status_fg = if state == InteractiveState::Thinking || state == InteractiveState::ToolExecution {
285                theme.colors.warning
286            } else {
287                theme.colors.muted
288            };
289            for (i, ch) in status_text.chars().enumerate() {
290                let col = width as usize - status_text.len() + i;
291                if col < width as usize {
292                    surface.set(
293                        chat_height + 2,
294                        col as u16,
295                        oxi_tui::Cell::new(ch).with_fg(status_fg),
296                    );
297                }
298            }
299        }
300
301        render_surface_to_terminal(&surface, width, height);
302        io::stdout().flush()?;
303
304        // ── Poll terminal events (~30 fps) ──────────────────────────────
305        let timeout = std::time::Duration::from_millis(33);
306
307        if crossterm::event::poll(timeout)? {
308            let event = crossterm::event::read()?;
309            match event {
310                crossterm::event::Event::Key(key) => {
311                    match key.code {
312                        crossterm::event::KeyCode::Enter => {
313                            if state == InteractiveState::Input {
314                                let value = input.value().to_string();
315                                if !value.is_empty() {
316                                    // ── Command handling ───────────────────
317                                    if value.starts_with('/') {
318                                        let cmd = SlashCommand::parse(&value);
319                                        match cmd {
320                                            SlashCommand::Clear => {
321                                                chat_view = ChatView::new(theme.clone());
322                                                session = InteractiveSession::new();
323                                                undo_stack.clear();
324                                                input.clear();
325                                                continue;
326                                            }
327                                            SlashCommand::Quit => {
328                                                running = false;
329                                                input.clear();
330                                                continue;
331                                            }
332                                            SlashCommand::Help => {
333                                                let help_text = format_help();
334                                                chat_view.add_message(ChatMessageDisplay {
335                                                    role: MessageRole::Assistant,
336                                                    content_blocks: vec![ContentBlockDisplay::Text {
337                                                        content: help_text,
338                                                    }],
339                                                    timestamp: now_millis(),
340                                                });
341                                                input.clear();
342                                                continue;
343                                            }
344                                            SlashCommand::Model { search } => {
345                                                let model_info = format!(
346                                                    "Current model: {}\n\
347                                                     Use /model <provider/model> to switch.",
348                                                    app.model_id(),
349                                                );
350                                                if let Some(query) = search {
351                                                    // Attempt to switch model directly
352                                                    match app.switch_model(&query) {
353                                                        Ok(()) => {
354                                                            chat_view.add_message(ChatMessageDisplay {
355                                                                role: MessageRole::Assistant,
356                                                                content_blocks: vec![
357                                                                    ContentBlockDisplay::Text {
358                                                                        content: format!(
359                                                                            "Switched to model: {}",
360                                                                            query
361                                                                        ),
362                                                                    },
363                                                                ],
364                                                                timestamp: now_millis(),
365                                                            });
366                                                        }
367                                                        Err(e) => {
368                                                            chat_view.add_message(ChatMessageDisplay {
369                                                                role: MessageRole::Assistant,
370                                                                content_blocks: vec![
371                                                                    ContentBlockDisplay::Text {
372                                                                        content: format!(
373                                                                            "Error switching model: {}",
374                                                                            e
375                                                                        ),
376                                                                    },
377                                                                ],
378                                                                timestamp: now_millis(),
379                                                            });
380                                                        }
381                                                    }
382                                                } else {
383                                                    chat_view.add_message(ChatMessageDisplay {
384                                                        role: MessageRole::Assistant,
385                                                        content_blocks: vec![
386                                                            ContentBlockDisplay::Text {
387                                                                content: model_info,
388                                                            },
389                                                        ],
390                                                        timestamp: now_millis(),
391                                                    });
392                                                }
393                                                input.clear();
394                                                continue;
395                                            }
396                                            SlashCommand::Session => {
397                                                let info = format_session_info(&session);
398                                                chat_view.add_message(ChatMessageDisplay {
399                                                    role: MessageRole::Assistant,
400                                                    content_blocks: vec![
401                                                        ContentBlockDisplay::Text { content: info },
402                                                    ],
403                                                    timestamp: now_millis(),
404                                                });
405                                                input.clear();
406                                                continue;
407                                            }
408                                            SlashCommand::Compact { custom_instructions } => {
409                                                // Compact is a hint; show message
410                                                let msg = if let Some(ci) = &custom_instructions {
411                                                    format!(
412                                                        "Compaction requested with instructions: {}\n\
413                                                         (Compaction is automatic when context exceeds threshold.)",
414                                                        ci
415                                                    )
416                                                } else {
417                                                    "Compaction requested.\n\
418                                                     (Compaction is automatic when context exceeds threshold.)"
419                                                        .to_string()
420                                                };
421                                                chat_view.add_message(ChatMessageDisplay {
422                                                    role: MessageRole::Assistant,
423                                                    content_blocks: vec![
424                                                        ContentBlockDisplay::Text { content: msg },
425                                                    ],
426                                                    timestamp: now_millis(),
427                                                });
428                                                input.clear();
429                                                continue;
430                                            }
431                                            SlashCommand::Undo => {
432                                                // Undo: remove last two messages (user + assistant)
433                                                if session.messages.len() >= 2 {
434                                                    let last_assistant = session.messages.pop();
435                                                    let last_user = session.messages.pop();
436                                                    if let (Some(u), Some(a)) = (last_user, last_assistant) {
437                                                        undo_stack.push(u);
438                                                        undo_stack.push(a);
439                                                    }
440                                                    // Rebuild chat view from remaining messages
441                                                    rebuild_chat_view(&mut chat_view, &session, &theme);
442                                                }
443                                                input.clear();
444                                                continue;
445                                            }
446                                            SlashCommand::Redo => {
447                                                if undo_stack.len() >= 2 {
448                                                    let user_msg = undo_stack.pop();
449                                                    let assistant_msg = undo_stack.pop();
450                                                    // Push in correct order: user first, then assistant
451                                                    if let (Some(a), Some(u)) = (assistant_msg, user_msg) {
452                                                        session.messages.push(u);
453                                                        session.messages.push(a);
454                                                    }
455                                                    rebuild_chat_view(&mut chat_view, &session, &theme);
456                                                }
457                                                input.clear();
458                                                continue;
459                                            }
460                                            SlashCommand::Branch => {
461                                                let msg = format!(
462                                                    "Session has {} messages.\n\
463                                                     Branch navigation coming soon.",
464                                                    session.messages.len()
465                                                );
466                                                chat_view.add_message(ChatMessageDisplay {
467                                                    role: MessageRole::Assistant,
468                                                    content_blocks: vec![
469                                                        ContentBlockDisplay::Text { content: msg },
470                                                    ],
471                                                    timestamp: now_millis(),
472                                                });
473                                                input.clear();
474                                                continue;
475                                            }
476                                            SlashCommand::Export { path } => {
477                                                let json = export_session_json(&session);
478                                                let export_path = path
479                                                    .clone()
480                                                    .unwrap_or_else(|| "oxi-session.json".to_string());
481                                                match std::fs::write(&export_path, &json) {
482                                                    Ok(()) => {
483                                                        chat_view.add_message(ChatMessageDisplay {
484                                                            role: MessageRole::Assistant,
485                                                            content_blocks: vec![
486                                                                ContentBlockDisplay::Text {
487                                                                    content: format!(
488                                                                        "Session exported to {}",
489                                                                        export_path
490                                                                    ),
491                                                                },
492                                                            ],
493                                                            timestamp: now_millis(),
494                                                        });
495                                                    }
496                                                    Err(e) => {
497                                                        chat_view.add_message(ChatMessageDisplay {
498                                                            role: MessageRole::Assistant,
499                                                            content_blocks: vec![
500                                                                ContentBlockDisplay::Text {
501                                                                    content: format!(
502                                                                        "Export failed: {}",
503                                                                        e
504                                                                    ),
505                                                                },
506                                                            ],
507                                                            timestamp: now_millis(),
508                                                        });
509                                                    }
510                                                }
511                                                input.clear();
512                                                continue;
513                                            }
514                                            SlashCommand::Settings => {
515                                                let settings_info = format!(
516                                                    "Model: {}\n\
517                                                     Thinking Level: {:?}\n\
518                                                     Temperature: {}\n\
519                                                     Max Tokens: {}\n\
520                                                     Auto-compaction: {}\n\
521                                                     Tool Timeout: {}s",
522                                                    app.settings().effective_model(None),
523                                                    app.settings().thinking_level,
524                                                    app.settings().effective_temperature()
525                                                        .map(|t| t.to_string())
526                                                        .unwrap_or_else(|| "default".to_string()),
527                                                    app.settings()
528                                                        .effective_max_tokens()
529                                                        .map(|t| t.to_string())
530                                                        .unwrap_or_else(|| "default".to_string()),
531                                                    app.settings().auto_compaction,
532                                                    app.settings().tool_timeout_seconds,
533                                                );
534                                                chat_view.add_message(ChatMessageDisplay {
535                                                    role: MessageRole::Assistant,
536                                                    content_blocks: vec![
537                                                        ContentBlockDisplay::Text {
538                                                            content: settings_info,
539                                                        },
540                                                    ],
541                                                    timestamp: now_millis(),
542                                                });
543                                                input.clear();
544                                                continue;
545                                            }
546                                            SlashCommand::Copy => {
547                                                // Get last assistant message text
548                                                let last_text = session
549                                                    .messages
550                                                    .iter()
551                                                    .rev()
552                                                    .find(|m| m.role == "assistant")
553                                                    .map(|m| m.content.clone())
554                                                    .unwrap_or_default();
555                                                // Copy to clipboard (best-effort)
556                                                let _ = copy_to_clipboard(&last_text);
557                                                input.clear();
558                                                continue;
559                                            }
560                                            SlashCommand::New => {
561                                                chat_view = ChatView::new(theme.clone());
562                                                session = InteractiveSession::new();
563                                                undo_stack.clear();
564                                                app.reset();
565                                                input.clear();
566                                                continue;
567                                            }
568                                            SlashCommand::Name { name } => {
569                                                if !name.is_empty() {
570                                                    session.session_id = Some(uuid::Uuid::new_v4());
571                                                    chat_view.add_message(ChatMessageDisplay {
572                                                        role: MessageRole::Assistant,
573                                                        content_blocks: vec![
574                                                            ContentBlockDisplay::Text {
575                                                                content: format!(
576                                                                    "Session named: {}",
577                                                                    name
578                                                                ),
579                                                            },
580                                                        ],
581                                                        timestamp: now_millis(),
582                                                    });
583                                                }
584                                                input.clear();
585                                                continue;
586                                            }
587                                            SlashCommand::Unknown { raw } => {
588                                                chat_view.add_message(ChatMessageDisplay {
589                                                    role: MessageRole::Assistant,
590                                                    content_blocks: vec![
591                                                        ContentBlockDisplay::Text {
592                                                            content: format!(
593                                                                "Unknown command: {}\n\
594                                                                 Type /help for available commands.",
595                                                                raw
596                                                            ),
597                                                        },
598                                                    ],
599                                                    timestamp: now_millis(),
600                                                });
601                                                input.clear();
602                                                continue;
603                                            }
604                                        }
605                                    } else if value.starts_with('!') {
606                                        // ── Bash command ─────────────────
607                                        let is_excluded = value.starts_with("!!");
608                                        let command = if is_excluded {
609                                            value[2..].trim().to_string()
610                                        } else {
611                                            value[1..].trim().to_string()
612                                        };
613                                        if !command.is_empty() {
614                                            // Run bash command inline, show output
615                                            let output = run_bash_command(&command);
616                                            chat_view.add_message(ChatMessageDisplay {
617                                                role: MessageRole::Assistant,
618                                                content_blocks: vec![ContentBlockDisplay::Text {
619                                                    content: format!("$ {}\n{}", command, output),
620                                                }],
621                                                timestamp: now_millis(),
622                                            });
623                                        }
624                                        input.clear();
625                                        continue;
626                                    } else {
627                                        // ── Normal user message → agent ──
628                                        session.add_user_message(value.clone());
629                                        chat_view.add_message(ChatMessageDisplay {
630                                            role: MessageRole::User,
631                                            content_blocks: vec![ContentBlockDisplay::Text {
632                                                content: value.clone(),
633                                            }],
634                                            timestamp: now_millis(),
635                                        });
636
637                                        // Transition to thinking
638                                        chat_view.start_streaming();
639                                        state = InteractiveState::Thinking;
640
641                                        let _ = prompt_tx.send(value).await;
642                                        input.clear();
643                                    }
644                                }
645                            }
646                        }
647                        crossterm::event::KeyCode::Char('c')
648                            if key
649                                .modifiers
650                                .contains(crossterm::event::KeyModifiers::CONTROL) =>
651                        {
652                            // Double Ctrl+C to exit, single Ctrl+C interrupts
653                            running = false;
654                        }
655                        crossterm::event::KeyCode::PageUp => {
656                            chat_view.scroll_up(10);
657                        }
658                        crossterm::event::KeyCode::PageDown => {
659                            chat_view.scroll_down(10);
660                        }
661                        _ => {
662                            if let Some(tui_event) = convert_key_event(key) {
663                                input.handle_event(&tui_event);
664                            }
665                        }
666                    }
667                }
668                crossterm::event::Event::Mouse(mouse) => match mouse.kind {
669                    crossterm::event::MouseEventKind::ScrollUp => {
670                        if mouse.row < chat_height {
671                            chat_view.scroll_up(3);
672                        }
673                    }
674                    crossterm::event::MouseEventKind::ScrollDown => {
675                        if mouse.row < chat_height {
676                            chat_view.scroll_down(3);
677                        }
678                    }
679                    _ => {}
680                },
681                crossterm::event::Event::Resize(_, _) => {}
682                _ => {}
683            }
684        }
685
686        // ── Drain agent events ──────────────────────────────────────────
687        while let Ok(ui_event) = ui_rx.try_recv() {
688            match ui_event {
689                UiEvent::Start => {}
690                UiEvent::Thinking => {
691                    chat_view.stream_thinking_start();
692                    state = InteractiveState::Thinking;
693                }
694                UiEvent::TextDelta(text) => {
695                    chat_view.stream_text_delta(&text);
696                }
697                UiEvent::ToolCall { id, name, arguments } => {
698                    chat_view.stream_thinking_end();
699                    chat_view.stream_tool_call(id, name, arguments);
700                    state = InteractiveState::ToolExecution;
701                }
702                UiEvent::ToolStart { tool_name } => {
703                    chat_view.stream_tool_call(
704                        format!("tool-{}", tool_name),
705                        tool_name,
706                        String::new(),
707                    );
708                    state = InteractiveState::ToolExecution;
709                }
710                UiEvent::ToolResult {
711                    tool_name,
712                    content,
713                    is_error,
714                } => {
715                    chat_view.stream_tool_result(tool_name, content, is_error);
716                }
717                UiEvent::Complete => {
718                    chat_view.stream_thinking_end();
719                    chat_view.finish_streaming();
720                    let _display_state = InteractiveState::Display;
721                    state = InteractiveState::Input;
722
723                    // Capture the response text into session
724                    let st = app.agent_state();
725                    for msg in st.messages.iter().rev() {
726                        if let oxi_ai::Message::Assistant(a) = msg {
727                            session.add_assistant_message(a.text_content());
728                            break;
729                        }
730                    }
731
732                    // Brief display then return to input
733                    state = InteractiveState::Input;
734                }
735                UiEvent::Error(msg) => {
736                    chat_view.finish_streaming_error(&msg);
737                    state = InteractiveState::Input;
738                }
739            }
740        }
741
742        // Auto-scroll
743        chat_view.scroll_to_bottom();
744    }
745
746    // ── Cleanup ────────────────────────────────────────────────────────
747    drop(prompt_tx);
748    let _ = agent_handle.join();
749    crossterm::execute!(io::stdout(), crossterm::cursor::Show)?;
750    crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture)?;
751    crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
752    io::stdout().flush()?;
753
754    Ok(())
755}
756
757// ── Helpers ─────────────────────────────────────────────────────────────────
758
759/// Render a surface to the terminal using efficient SGR sequences.
760fn render_surface_to_terminal(surface: &Surface, width: u16, height: u16) {
761    print!("\x1b[?2026h"); // Begin synchronized update
762    print!("\x1b[H"); // Move to home
763
764    let mut last_fg = oxi_tui::Color::Default;
765    let mut last_bg = oxi_tui::Color::Default;
766    let mut last_bold = false;
767    let mut last_italic = false;
768    let mut last_underline = false;
769    let mut last_strike = false;
770
771    for row in 0..height {
772        if row > 0 {
773            print!("\r\n");
774        }
775        for col in 0..width {
776            if let Some(cell) = surface.get(row, col) {
777                let fg_changed = cell.fg != last_fg;
778                let bg_changed = cell.bg != last_bg;
779                let attrs_changed = cell.attrs.bold != last_bold
780                    || cell.attrs.italic != last_italic
781                    || cell.attrs.underline != last_underline
782                    || cell.attrs.strikethrough != last_strike;
783
784                if fg_changed || bg_changed || attrs_changed {
785                    print!("\x1b[0m");
786                    match cell.fg {
787                        oxi_tui::Color::Default => {}
788                        oxi_tui::Color::Black => print!("\x1b[30m"),
789                        oxi_tui::Color::Red => print!("\x1b[31m"),
790                        oxi_tui::Color::Green => print!("\x1b[32m"),
791                        oxi_tui::Color::Yellow => print!("\x1b[33m"),
792                        oxi_tui::Color::Blue => print!("\x1b[34m"),
793                        oxi_tui::Color::Magenta => print!("\x1b[35m"),
794                        oxi_tui::Color::Cyan => print!("\x1b[36m"),
795                        oxi_tui::Color::White => print!("\x1b[37m"),
796                        oxi_tui::Color::Indexed(n) => print!("\x1b[38;5;{}m", n),
797                        oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[38;2;{};{};{}m", r, g, b),
798                    }
799                    match cell.bg {
800                        oxi_tui::Color::Default => {}
801                        oxi_tui::Color::Black => print!("\x1b[40m"),
802                        oxi_tui::Color::Red => print!("\x1b[41m"),
803                        oxi_tui::Color::Green => print!("\x1b[42m"),
804                        oxi_tui::Color::Yellow => print!("\x1b[43m"),
805                        oxi_tui::Color::Blue => print!("\x1b[44m"),
806                        oxi_tui::Color::Magenta => print!("\x1b[45m"),
807                        oxi_tui::Color::Cyan => print!("\x1b[46m"),
808                        oxi_tui::Color::White => print!("\x1b[47m"),
809                        oxi_tui::Color::Indexed(n) => print!("\x1b[48;5;{}m", n),
810                        oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[48;2;{};{};{}m", r, g, b),
811                    }
812                    if cell.attrs.bold {
813                        print!("\x1b[1m");
814                    }
815                    if cell.attrs.italic {
816                        print!("\x1b[3m");
817                    }
818                    if cell.attrs.underline {
819                        print!("\x1b[4m");
820                    }
821                    if cell.attrs.strikethrough {
822                        print!("\x1b[9m");
823                    }
824                    last_fg = cell.fg;
825                    last_bg = cell.bg;
826                    last_bold = cell.attrs.bold;
827                    last_italic = cell.attrs.italic;
828                    last_underline = cell.attrs.underline;
829                    last_strike = cell.attrs.strikethrough;
830                }
831                print!("{}", cell.char);
832            } else {
833                print!(" ");
834            }
835        }
836    }
837
838    print!("\x1b[0m");
839    print!("\x1b[?2026l"); // End synchronized update
840}
841
842/// Convert a crossterm key event to an oxi-tui Event.
843fn convert_key_event(key: crossterm::event::KeyEvent) -> Option<oxi_tui::Event> {
844    use oxi_tui::event::KeyCode as KC;
845
846    let code = match key.code {
847        crossterm::event::KeyCode::Enter => return None,
848        crossterm::event::KeyCode::Char('c')
849            if key
850                .modifiers
851                .contains(crossterm::event::KeyModifiers::CONTROL) =>
852        {
853            return None;
854        }
855        crossterm::event::KeyCode::Esc => KC::Escape,
856        crossterm::event::KeyCode::Tab => KC::Tab,
857        crossterm::event::KeyCode::Backspace => KC::Backspace,
858        crossterm::event::KeyCode::Delete => KC::Delete,
859        crossterm::event::KeyCode::Up => KC::Up,
860        crossterm::event::KeyCode::Down => KC::Down,
861        crossterm::event::KeyCode::Left => KC::Left,
862        crossterm::event::KeyCode::Right => KC::Right,
863        crossterm::event::KeyCode::Home => KC::Home,
864        crossterm::event::KeyCode::End => KC::End,
865        crossterm::event::KeyCode::Char(c) => KC::Char(c),
866        crossterm::event::KeyCode::F(n) => KC::F(n),
867        _ => return None,
868    };
869
870    let modifiers = oxi_tui::KeyModifiers {
871        shift: key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT),
872        ctrl: key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL),
873        alt: key.modifiers.contains(crossterm::event::KeyModifiers::ALT),
874        meta: key.modifiers.contains(crossterm::event::KeyModifiers::META),
875    };
876
877    Some(oxi_tui::Event::Key(oxi_tui::KeyEvent::with_modifiers(
878        code, modifiers,
879    )))
880}
881
882/// Format the help text.
883fn format_help() -> String {
884    r#"oxi — AI Coding Assistant
885
886Commands:
887  /model [search]    Select or switch model
888  /clear             Clear conversation history
889  /compact [instr]   Compact context with optional instructions
890  /undo              Undo last exchange
891  /redo              Redo last undone exchange
892  /branch            Navigate session tree
893  /session           Show session info and stats
894  /export [path]     Export session to JSON
895  /settings          Show current settings
896  /name <name>       Set session display name
897  /copy              Copy last assistant response
898  /new               Start a new session
899  /help              Show this help message
900  /quit              Quit oxi
901
902Bash:
903  !<command>         Run a bash command
904  !!<command>        Run bash (excluded from context)
905
906Keybindings:
907  Enter              Send message or command
908  Ctrl+C             Quit
909  PageUp/PageDown    Scroll chat history
910  Mouse scroll       Scroll chat history
911"#.to_string()
912}
913
914/// Format session info.
915fn format_session_info(session: &InteractiveSession) -> String {
916    let msg_count = session.messages.len();
917    let user_count = session.messages.iter().filter(|m| m.role == "user").count();
918    let assistant_count = session
919        .messages
920        .iter()
921        .filter(|m| m.role == "assistant")
922        .count();
923    let entry_count = session.entries.len();
924
925    format!(
926        "Session Info:\n\
927         Messages: {} total ({} user, {} assistant)\n\
928         Entries: {}\n\
929         ID: {}",
930        msg_count,
931        user_count,
932        assistant_count,
933        entry_count,
934        session
935            .session_id
936            .map(|u| u.to_string())
937            .unwrap_or_else(|| "none".to_string()),
938    )
939}
940
941/// Export session to a JSON string.
942fn export_session_json(session: &InteractiveSession) -> String {
943    let messages: Vec<serde_json::Value> = session
944        .messages
945        .iter()
946        .map(|m| {
947            serde_json::json!({
948                "role": m.role,
949                "content": m.content,
950                "timestamp": m.timestamp.to_rfc3339(),
951            })
952        })
953        .collect();
954
955    serde_json::to_string_pretty(&serde_json::json!({
956        "session_id": session.session_id.map(|u| u.to_string()),
957        "messages": messages,
958        "entry_count": session.entries.len(),
959    }))
960    .unwrap_or_else(|_| "{}".to_string())
961}
962
963/// Rebuild the chat view from session messages (used after undo/redo).
964fn rebuild_chat_view(chat_view: &mut ChatView, session: &InteractiveSession, theme: &Theme) {
965    *chat_view = ChatView::new(theme.clone());
966    for msg in &session.messages {
967        let role = if msg.role == "user" {
968            MessageRole::User
969        } else {
970            MessageRole::Assistant
971        };
972        chat_view.add_message(ChatMessageDisplay {
973            role,
974            content_blocks: vec![ContentBlockDisplay::Text {
975                content: msg.content.clone(),
976            }],
977            timestamp: msg.timestamp.timestamp_millis(),
978        });
979    }
980}
981
982/// Run a bash command and return its output.
983fn run_bash_command(command: &str) -> String {
984    use std::process::Command;
985    let output = Command::new("sh")
986        .arg("-c")
987        .arg(command)
988        .output()
989        .unwrap_or_else(|e| std::process::Output {
990            stdout: Vec::new(),
991            stderr: format!("Failed to execute: {}", e).into_bytes(),
992            status: std::process::ExitStatus::from_raw(1),
993        });
994
995    let mut result = String::new();
996    if !output.stdout.is_empty() {
997        result.push_str(&String::from_utf8_lossy(&output.stdout));
998    }
999    if !output.stderr.is_empty() {
1000        if !result.is_empty() {
1001            result.push('\n');
1002        }
1003        result.push_str(&String::from_utf8_lossy(&output.stderr));
1004    }
1005    if !output.status.success() {
1006        result.push_str(&format!("\nExit code: {}", output.status.code().unwrap_or(-1)));
1007    }
1008    result
1009}
1010
1011/// Copy text to clipboard (best-effort, uses pbcopy/xclip/wl-copy).
1012fn copy_to_clipboard(text: &str) -> Result<()> {
1013    use std::io::Write;
1014    use std::process::{Command, Stdio};
1015
1016    let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
1017        ("pbcopy", &[])
1018    } else if cfg!(target_os = "linux") {
1019        // Try wl-copy first (Wayland), fall back to xclip (X11)
1020        if std::path::Path::new("/usr/bin/wl-copy").exists()
1021            || std::path::Path::new("/usr/local/bin/wl-copy").exists()
1022        {
1023            ("wl-copy", &[])
1024        } else {
1025            ("xclip", &["-selection", "clipboard"])
1026        }
1027    } else {
1028        return Err(anyhow::anyhow!("Clipboard not supported on this platform"));
1029    };
1030
1031    let mut child = Command::new(cmd)
1032        .args(args)
1033        .stdin(Stdio::piped())
1034        .spawn()
1035        .map_err(|e| anyhow::anyhow!("Failed to spawn clipboard command: {}", e))?;
1036
1037    if let Some(mut stdin) = child.stdin.take() {
1038        let _ = stdin.write_all(text.as_bytes());
1039    }
1040
1041    let _ = child.wait();
1042    Ok(())
1043}
1044
1045/// Current timestamp in milliseconds.
1046fn now_millis() -> i64 {
1047    std::time::SystemTime::now()
1048        .duration_since(std::time::UNIX_EPOCH)
1049        .unwrap_or_default()
1050        .as_millis() as i64
1051}
1052
1053// ── Tests ───────────────────────────────────────────────────────────────────
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::*;
1058
1059    // ── SlashCommand parsing tests ────────────────────────────────────
1060
1061    #[test]
1062    fn test_parse_model_no_arg() {
1063        let cmd = SlashCommand::parse("/model");
1064        assert_eq!(cmd, SlashCommand::Model { search: None });
1065    }
1066
1067    #[test]
1068    fn test_parse_model_with_search() {
1069        let cmd = SlashCommand::parse("/model claude-sonnet");
1070        assert_eq!(
1071            cmd,
1072            SlashCommand::Model {
1073                search: Some("claude-sonnet".to_string()),
1074            }
1075        );
1076    }
1077
1078    #[test]
1079    fn test_parse_clear() {
1080        assert_eq!(SlashCommand::parse("/clear"), SlashCommand::Clear);
1081    }
1082
1083    #[test]
1084    fn test_parse_compact_no_arg() {
1085        assert_eq!(
1086            SlashCommand::parse("/compact"),
1087            SlashCommand::Compact {
1088                custom_instructions: None
1089            }
1090        );
1091    }
1092
1093    #[test]
1094    fn test_parse_compact_with_instructions() {
1095        assert_eq!(
1096            SlashCommand::parse("/compact focus on error handling"),
1097            SlashCommand::Compact {
1098                custom_instructions: Some("focus on error handling".to_string()),
1099            }
1100        );
1101    }
1102
1103    #[test]
1104    fn test_parse_undo_redo() {
1105        assert_eq!(SlashCommand::parse("/undo"), SlashCommand::Undo);
1106        assert_eq!(SlashCommand::parse("/redo"), SlashCommand::Redo);
1107    }
1108
1109    #[test]
1110    fn test_parse_aliases() {
1111        // /? is an alias for /help
1112        assert_eq!(SlashCommand::parse("/?"), SlashCommand::Help);
1113        // /exit and /q are aliases for /quit
1114        assert_eq!(SlashCommand::parse("/exit"), SlashCommand::Quit);
1115        assert_eq!(SlashCommand::parse("/q"), SlashCommand::Quit);
1116        // /fork and /tree are aliases for /branch
1117        assert_eq!(SlashCommand::parse("/fork"), SlashCommand::Branch);
1118        assert_eq!(SlashCommand::parse("/tree"), SlashCommand::Branch);
1119        // /resume is alias for /session
1120        assert_eq!(SlashCommand::parse("/resume"), SlashCommand::Session);
1121    }
1122
1123    #[test]
1124    fn test_parse_unknown() {
1125        let cmd = SlashCommand::parse("/foobar");
1126        assert_eq!(
1127            cmd,
1128            SlashCommand::Unknown {
1129                raw: "/foobar".to_string()
1130            }
1131        );
1132    }
1133
1134    // ── State machine tests ───────────────────────────────────────────
1135
1136    #[test]
1137    fn test_state_ordering() {
1138        // Verify that states exist and are distinct
1139        let states = [
1140            InteractiveState::Input,
1141            InteractiveState::Thinking,
1142            InteractiveState::ToolExecution,
1143            InteractiveState::Display,
1144        ];
1145        // All unique
1146        for i in 0..states.len() {
1147            for j in (i + 1)..states.len() {
1148                assert_ne!(states[i], states[j]);
1149            }
1150        }
1151    }
1152
1153    #[test]
1154    fn test_state_transitions_input_to_thinking() {
1155        let state = InteractiveState::Input;
1156        // On user submit: Input -> Thinking
1157        let next = InteractiveState::Thinking;
1158        assert_eq!(next, InteractiveState::Thinking);
1159        assert_ne!(state, next);
1160    }
1161
1162    #[test]
1163    fn test_state_transitions_thinking_to_tool_execution() {
1164        // On tool call: Thinking -> ToolExecution
1165        let state = InteractiveState::Thinking;
1166        let next = InteractiveState::ToolExecution;
1167        assert_ne!(state, next);
1168    }
1169
1170    #[test]
1171    fn test_state_transitions_tool_execution_to_display() {
1172        // On complete: ToolExecution -> Display -> Input
1173        let state = InteractiveState::ToolExecution;
1174        let display = InteractiveState::Display;
1175        let input = InteractiveState::Input;
1176        assert_ne!(state, display);
1177        assert_ne!(display, input);
1178    }
1179
1180    // ── Bash execution tests ──────────────────────────────────────────
1181
1182    #[test]
1183    fn test_bash_command_execution() {
1184        let output = run_bash_command("echo hello");
1185        assert!(output.contains("hello"));
1186    }
1187
1188    #[test]
1189    fn test_bash_command_failure() {
1190        let output = run_bash_command("false");
1191        assert!(output.contains("Exit code:"));
1192    }
1193
1194    // ── Export tests ──────────────────────────────────────────────────
1195
1196    #[test]
1197    fn test_export_empty_session() {
1198        let session = InteractiveSession::new();
1199        let json = export_session_json(&session);
1200        assert!(json.contains("\"messages\": []"));
1201        assert!(json.contains("\"entry_count\": 0"));
1202    }
1203
1204    #[test]
1205    fn test_export_session_with_messages() {
1206        let mut session = InteractiveSession::new();
1207        session.add_user_message("Hello".to_string());
1208        session.add_assistant_message("Hi there!".to_string());
1209        let json = export_session_json(&session);
1210        assert!(json.contains("\"role\": \"user\""));
1211        assert!(json.contains("\"content\": \"Hello\""));
1212        assert!(json.contains("\"role\": \"assistant\""));
1213    }
1214
1215    // ── Session info tests ────────────────────────────────────────────
1216
1217    #[test]
1218    fn test_session_info_empty() {
1219        let session = InteractiveSession::new();
1220        let info = format_session_info(&session);
1221        assert!(info.contains("Messages: 0 total"));
1222        assert!(info.contains("ID: none"));
1223    }
1224
1225    #[test]
1226    fn test_session_info_with_messages() {
1227        let mut session = InteractiveSession::new();
1228        session.add_user_message("Hello".to_string());
1229        session.add_assistant_message("Hi".to_string());
1230        let info = format_session_info(&session);
1231        assert!(info.contains("Messages: 2 total"));
1232        assert!(info.contains("1 user"));
1233        assert!(info.contains("1 assistant"));
1234    }
1235
1236    // ── Help text test ────────────────────────────────────────────────
1237
1238    #[test]
1239    fn test_help_text_contains_all_commands() {
1240        let help = format_help();
1241        assert!(help.contains("/model"));
1242        assert!(help.contains("/clear"));
1243        assert!(help.contains("/compact"));
1244        assert!(help.contains("/undo"));
1245        assert!(help.contains("/redo"));
1246        assert!(help.contains("/branch"));
1247        assert!(help.contains("/session"));
1248        assert!(help.contains("/export"));
1249        assert!(help.contains("/settings"));
1250        assert!(help.contains("/help"));
1251        assert!(help.contains("/quit"));
1252    }
1253
1254    // ── Command description tests ─────────────────────────────────────
1255
1256    #[test]
1257    fn test_command_descriptions() {
1258        assert_eq!(
1259            SlashCommand::Model { search: None }.description(),
1260            "Select model"
1261        );
1262        assert_eq!(SlashCommand::Clear.description(), "Clear conversation history");
1263        assert_eq!(SlashCommand::Undo.description(), "Undo last exchange");
1264        assert_eq!(SlashCommand::Redo.description(), "Redo last undone exchange");
1265        assert_eq!(SlashCommand::Quit.description(), "Quit oxi");
1266        assert_eq!(
1267            SlashCommand::Unknown { raw: "/x".to_string() }.description(),
1268            "Unknown command"
1269        );
1270    }
1271}