1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum InteractiveState {
52 Input,
54 Thinking,
56 ToolExecution,
58 Display,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum SlashCommand {
65 Model { search: Option<String> },
67 Clear,
69 Compact { custom_instructions: Option<String> },
71 Undo,
73 Redo,
75 Branch,
77 Session,
79 Export { path: Option<String> },
81 Settings,
83 Help,
85 Quit,
87 Name { name: String },
89 Copy,
91 New,
93 Unknown { raw: String },
95}
96
97impl SlashCommand {
98 pub fn parse(input: &str) -> Self {
100 let trimmed = input.trim();
101 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 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
160pub async fn run_interactive(app: crate::App) -> Result<()> {
164 let theme = Theme::dark();
165 let agent: Arc<Agent> = app.agent();
166
167 let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
169 let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
170
171 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 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 let mut undo_stack: Vec<crate::ChatMessage> = Vec::new();
236
237 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 let mut surface = Surface::new(width, height);
252
253 let chat_area = Rect::new(0, 0, width, chat_height);
255 chat_view.render(&mut surface, chat_area);
256
257 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 surface.set(
269 chat_height + 1,
270 0,
271 oxi_tui::Cell::new('\u{276F}').with_fg(theme.colors.primary),
272 );
273
274 let input_area = Rect::new(2, chat_height + 1, width.saturating_sub(4), 1);
276 input.render(&mut surface, input_area);
277
278 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 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 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 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 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 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(&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 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 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 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 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 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 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 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 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 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 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 state = InteractiveState::Input;
734 }
735 UiEvent::Error(msg) => {
736 chat_view.finish_streaming_error(&msg);
737 state = InteractiveState::Input;
738 }
739 }
740 }
741
742 chat_view.scroll_to_bottom();
744 }
745
746 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
757fn render_surface_to_terminal(surface: &Surface, width: u16, height: u16) {
761 print!("\x1b[?2026h"); print!("\x1b[H"); 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"); }
841
842fn 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
882fn 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
914fn 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
941fn 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
963fn 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
982fn 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
1011fn 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 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
1045fn 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#[cfg(test)]
1056mod tests {
1057 use super::*;
1058
1059 #[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 assert_eq!(SlashCommand::parse("/?"), SlashCommand::Help);
1113 assert_eq!(SlashCommand::parse("/exit"), SlashCommand::Quit);
1115 assert_eq!(SlashCommand::parse("/q"), SlashCommand::Quit);
1116 assert_eq!(SlashCommand::parse("/fork"), SlashCommand::Branch);
1118 assert_eq!(SlashCommand::parse("/tree"), SlashCommand::Branch);
1119 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 #[test]
1137 fn test_state_ordering() {
1138 let states = [
1140 InteractiveState::Input,
1141 InteractiveState::Thinking,
1142 InteractiveState::ToolExecution,
1143 InteractiveState::Display,
1144 ];
1145 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 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 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 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 #[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 #[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 #[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 #[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 #[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}