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