Skip to main content

oxi/
tui_components.rs

1//! Interactive mode TUI components
2//!
3//! Provides high-level interactive components for the oxi terminal interface:
4//! - Session selector (navigate/switch/create/delete sessions)
5//! - Model selector (choose AI model grouped by provider)
6//! - Footer (status bar with model, session, tokens, cost)
7//! - Login dialog (API key entry with provider selection)
8//! - Diff viewer (show edit diffs with color highlighting)
9//! - Bash execution display (streaming output, timer, cancel)
10
11use serde::{Deserialize, Serialize};
12
13/// Session info for display in session selector
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SessionInfo {
16    pub id: String,
17    pub name: String,
18    pub created_at: String,
19    pub message_count: usize,
20    pub model: Option<String>,
21    pub parent_id: Option<String>,
22}
23
24/// Session selector state
25#[derive(Debug, Clone)]
26pub struct SessionSelector {
27    pub sessions: Vec<SessionInfo>,
28    pub selected_index: usize,
29    pub filter: String,
30    pub scroll_offset: usize,
31    pub visible_height: usize,
32}
33
34impl SessionSelector {
35    pub fn new(sessions: Vec<SessionInfo>) -> Self {
36        Self {
37            sessions,
38            selected_index: 0,
39            filter: String::new(),
40            scroll_offset: 0,
41            visible_height: 20,
42        }
43    }
44
45    /// Get filtered sessions matching the current filter
46    pub fn filtered_sessions(&self) -> Vec<&SessionInfo> {
47        if self.filter.is_empty() {
48            self.sessions.iter().collect()
49        } else {
50            let filter_lower = self.filter.to_lowercase();
51            self.sessions
52                .iter()
53                .filter(|s| {
54                    s.name.to_lowercase().contains(&filter_lower)
55                        || s.id.to_lowercase().contains(&filter_lower)
56                })
57                .collect()
58        }
59    }
60
61    /// Move selection up
62    pub fn move_up(&mut self) {
63        if self.selected_index > 0 {
64            self.selected_index -= 1;
65            self.adjust_scroll();
66        }
67    }
68
69    /// Move selection down
70    pub fn move_down(&mut self) {
71        let max = self.filtered_sessions().len().saturating_sub(1);
72        if self.selected_index < max {
73            self.selected_index += 1;
74            self.adjust_scroll();
75        }
76    }
77
78    /// Get currently selected session
79    pub fn selected(&self) -> Option<&SessionInfo> {
80        self.filtered_sessions().into_iter().nth(self.selected_index)
81    }
82
83    /// Update filter text
84    pub fn set_filter(&mut self, filter: String) {
85        self.filter = filter;
86        self.selected_index = 0;
87        self.scroll_offset = 0;
88    }
89
90    fn adjust_scroll(&mut self) {
91        if self.selected_index < self.scroll_offset {
92            self.scroll_offset = self.selected_index;
93        } else if self.selected_index >= self.scroll_offset + self.visible_height {
94            self.scroll_offset = self.selected_index - self.visible_height + 1;
95        }
96    }
97
98    /// Render the session selector as a string
99    pub fn render(&self) -> String {
100        let mut output = String::new();
101        output.push_str(&format!("{}\n", "─".repeat(60)));
102        output.push_str("Sessions (↑↓ navigate, Enter select, n new, d delete, / filter)\n");
103        output.push_str(&format!("{}\n", "─".repeat(60)));
104
105        if !self.filter.is_empty() {
106            output.push_str(&format!("Filter: {}\n", self.filter));
107        }
108
109        let filtered: Vec<_> = self.filtered_sessions();
110        for (i, session) in filtered.iter().enumerate() {
111            let marker = if i == self.selected_index { "▶" } else { " " };
112            let branch = if session.parent_id.is_some() { "├─ " } else { "  " };
113            let name = if session.name.is_empty() {
114                &session.id[..8.min(session.id.len())]
115            } else {
116                &session.name
117            };
118            output.push_str(&format!(
119                "{} {}{:<30} {} msg:{} model:{}\n",
120                marker,
121                branch,
122                name,
123                &session.created_at[..10.min(session.created_at.len())],
124                session.message_count,
125                session.model.as_deref().unwrap_or("-"),
126            ));
127        }
128
129        if filtered.is_empty() {
130            output.push_str("  (no sessions)\n");
131        }
132
133        output
134    }
135}
136
137/// Model info for model selector
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ModelInfo {
140    pub id: String,
141    pub name: String,
142    pub provider: String,
143    pub supports_vision: bool,
144    pub supports_tools: bool,
145    pub supports_thinking: bool,
146    pub context_window: usize,
147}
148
149/// Model selector state
150#[derive(Debug, Clone)]
151pub struct ModelSelector {
152    pub models: Vec<ModelInfo>,
153    pub selected_index: usize,
154    pub filter: String,
155    pub grouped: bool,
156}
157
158impl ModelSelector {
159    pub fn new(models: Vec<ModelInfo>) -> Self {
160        let mut models = models;
161        models.sort_by(|a, b| a.provider.cmp(&b.provider).then(a.name.cmp(&b.name)));
162        Self {
163            models,
164            selected_index: 0,
165            filter: String::new(),
166            grouped: true,
167        }
168    }
169
170    /// Get filtered models
171    pub fn filtered_models(&self) -> Vec<&ModelInfo> {
172        if self.filter.is_empty() {
173            self.models.iter().collect()
174        } else {
175            let filter_lower = self.filter.to_lowercase();
176            self.models
177                .iter()
178                .filter(|m| {
179                    m.name.to_lowercase().contains(&filter_lower)
180                        || m.id.to_lowercase().contains(&filter_lower)
181                        || m.provider.to_lowercase().contains(&filter_lower)
182                })
183                .collect()
184        }
185    }
186
187    /// Move selection up
188    pub fn move_up(&mut self) {
189        if self.selected_index > 0 {
190            self.selected_index -= 1;
191        }
192    }
193
194    /// Move selection down
195    pub fn move_down(&mut self) {
196        let max = self.filtered_models().len().saturating_sub(1);
197        if self.selected_index < max {
198            self.selected_index += 1;
199        }
200    }
201
202    /// Get currently selected model
203    pub fn selected(&self) -> Option<&ModelInfo> {
204        self.filtered_models().into_iter().nth(self.selected_index)
205    }
206
207    /// Render the model selector
208    pub fn render(&self) -> String {
209        let mut output = String::new();
210        output.push_str(&format!("{}\n", "─".repeat(60)));
211        output.push_str("Select Model (↑↓ navigate, Enter select, / filter)\n");
212        output.push_str(&format!("{}\n", "─".repeat(60)));
213
214        let filtered: Vec<_> = self.filtered_models();
215        let mut last_provider = String::new();
216
217        for (i, model) in filtered.iter().enumerate() {
218            // Provider group header
219            if self.grouped && model.provider != last_provider {
220                last_provider = model.provider.clone();
221                output.push_str(&format!("\n  {}\n", model.provider.to_uppercase()));
222            }
223
224            let marker = if i == self.selected_index { "▶" } else { " " };
225            let vision = if model.supports_vision { "👁" } else { " " };
226            let tools = if model.supports_tools { "🔧" } else { " " };
227            let thinking = if model.supports_thinking { "💭" } else { " " };
228            let ctx = format_bytes(model.context_window);
229
230            output.push_str(&format!(
231                " {} {} {}{}{} {:<30} ctx:{}\n",
232                marker, model.id, vision, tools, thinking, model.name, ctx,
233            ));
234        }
235
236        output
237    }
238}
239
240/// Footer status bar data
241#[derive(Debug, Clone, Default)]
242pub struct FooterData {
243    pub model_name: String,
244    pub session_name: String,
245    pub provider_name: String,
246    pub input_tokens: usize,
247    pub output_tokens: usize,
248    pub total_cost: f64,
249    pub is_thinking: bool,
250    pub elapsed_seconds: Option<u64>,
251}
252
253impl FooterData {
254    /// Render the footer as a single-line status bar
255    pub fn render(&self, width: usize) -> String {
256        let thinking = if self.is_thinking { "⏳" } else { "✓" };
257        let tokens = if self.input_tokens > 0 || self.output_tokens > 0 {
258            format!("tok:{}+{}", self.input_tokens, self.output_tokens)
259        } else {
260            String::new()
261        };
262        let cost = if self.total_cost > 0.0 {
263            format!("${:.4}", self.total_cost)
264        } else {
265            String::new()
266        };
267        let elapsed = self.elapsed_seconds
268            .map(|s| format!("{}m{}s", s / 60, s % 60))
269            .unwrap_or_default();
270
271        let left = format!("{} {} @ {}", thinking, self.model_name, self.provider_name);
272        let right = format!("{} {} {}", tokens, cost, elapsed);
273
274        let session_part = if !self.session_name.is_empty() {
275            format!(" │ {}", self.session_name)
276        } else {
277            String::new()
278        };
279
280        // Pad to width
281        let content_len = left.len() + session_part.len() + right.len() + 2;
282        if content_len < width {
283            let padding = width - content_len;
284            format!("{}{}{:>width$}", left, session_part, right, width = padding + right.len())
285        } else {
286            format!("{}{} {}", left, session_part, right)
287        }
288    }
289}
290
291/// Login dialog state
292#[derive(Debug, Clone)]
293pub struct LoginDialog {
294    pub providers: Vec<String>,
295    pub selected_provider_index: usize,
296    pub api_key: String,
297    pub cursor_pos: usize,
298    pub error_message: Option<String>,
299    pub is_masked: bool,
300}
301
302impl LoginDialog {
303    pub fn new(providers: Vec<String>) -> Self {
304        Self {
305            providers,
306            selected_provider_index: 0,
307            api_key: String::new(),
308            cursor_pos: 0,
309            error_message: None,
310            is_masked: true,
311        }
312    }
313
314    /// Get selected provider
315    pub fn selected_provider(&self) -> Option<&str> {
316        self.providers.get(self.selected_provider_index).map(|s| s.as_str())
317    }
318
319    /// Input a character
320    pub fn input_char(&mut self, c: char) {
321        self.api_key.insert(self.cursor_pos, c);
322        self.cursor_pos += 1;
323        self.error_message = None;
324    }
325
326    /// Delete character before cursor
327    pub fn backspace(&mut self) {
328        if self.cursor_pos > 0 {
329            self.cursor_pos -= 1;
330            self.api_key.remove(self.cursor_pos);
331            self.error_message = None;
332        }
333    }
334
335    /// Cycle provider selection
336    pub fn next_provider(&mut self) {
337        if !self.providers.is_empty() {
338            self.selected_provider_index = (self.selected_provider_index + 1) % self.providers.len();
339            self.api_key.clear();
340            self.cursor_pos = 0;
341            self.error_message = None;
342        }
343    }
344
345    /// Validate API key format (basic checks)
346    pub fn validate(&self) -> Result<(), String> {
347        if self.api_key.is_empty() {
348            return Err("API key cannot be empty".to_string());
349        }
350        let provider = self.selected_provider().unwrap_or("");
351        match provider {
352            "anthropic" if !self.api_key.starts_with("sk-ant-") => {
353                Err("Anthropic API keys start with 'sk-ant-'".to_string())
354            }
355            "openai" if !self.api_key.starts_with("sk-") => {
356                Err("OpenAI API keys start with 'sk-'".to_string())
357            }
358            _ => Ok(()),
359        }
360    }
361
362    /// Render the login dialog
363    pub fn render(&self) -> String {
364        let mut output = String::new();
365        output.push_str(&format!("{}\n", "─".repeat(50)));
366        output.push_str("  API Key Configuration\n");
367        output.push_str(&format!("{}\n", "─".repeat(50)));
368
369        // Provider tabs
370        for (i, provider) in self.providers.iter().enumerate() {
371            if i == self.selected_provider_index {
372                output.push_str(&format!(" [{}] ", provider));
373            } else {
374                output.push_str(&format!("  {}  ", provider));
375            }
376        }
377        output.push('\n');
378
379        // API key input
380        let display_key = if self.is_masked {
381            "*".repeat(self.api_key.len())
382        } else {
383            self.api_key.clone()
384        };
385        output.push_str(&format!("\n  API Key: {}\n", display_key));
386
387        // Error message
388        if let Some(ref err) = self.error_message {
389            output.push_str(&format!("  ⚠ {}\n", err));
390        }
391
392        output.push_str("\n  Tab: switch provider, Enter: save, Esc: cancel\n");
393        output
394    }
395}
396
397/// Diff line for the diff viewer
398#[derive(Debug, Clone)]
399pub enum DiffLine {
400    Context { content: String, line_num: usize },
401    Added { content: String, line_num: usize },
402    Removed { content: String, line_num: usize },
403    Header { old_start: usize, old_count: usize, new_start: usize, new_count: usize },
404}
405
406/// Diff viewer state
407#[derive(Debug, Clone)]
408pub struct DiffViewer {
409    pub lines: Vec<DiffLine>,
410    pub scroll_offset: usize,
411    pub visible_height: usize,
412    pub file_path: String,
413    /// Enable word-level highlighting for changed parts
414    pub word_diff: bool,
415}
416
417impl DiffViewer {
418    pub fn new(file_path: String, diff_text: &str) -> Self {
419        let lines = parse_diff_lines(diff_text);
420        Self {
421            lines,
422            scroll_offset: 0,
423            visible_height: 30,
424            file_path,
425            word_diff: true, // Enable word-level highlighting by default
426        }
427    }
428
429    /// Create without word diff highlighting
430    pub fn new_simple(file_path: String, diff_text: &str) -> Self {
431        let lines = parse_diff_lines(diff_text);
432        Self {
433            lines,
434            scroll_offset: 0,
435            visible_height: 30,
436            file_path,
437            word_diff: false,
438        }
439    }
440
441    /// Enable or disable word-level diff highlighting
442    pub fn set_word_diff(&mut self, enabled: bool) {
443        self.word_diff = enabled;
444    }
445
446    /// Render the diff viewer with optional word-level highlighting
447    pub fn render(&self) -> String {
448        let mut output = String::new();
449        output.push_str(&format!("Diff: {}\n", self.file_path));
450        output.push_str(&format!("{}\n", "─".repeat(60)));
451
452        let visible: Vec<_> = self.lines
453            .iter()
454            .skip(self.scroll_offset)
455            .take(self.visible_height)
456            .collect();
457
458        for line in &visible {
459            match line {
460                DiffLine::Header { old_start, old_count, new_start, new_count } => {
461                    output.push_str(&format!(
462                        "@@ -{},{} +{},{} @@\n",
463                        old_start, old_count, new_start, new_count
464                    ));
465                }
466                DiffLine::Context { content, line_num } => {
467                    output.push_str(&format!(" {:>4} {}\n", line_num, content));
468                }
469                DiffLine::Added { content, line_num } => {
470                    if self.word_diff {
471                        // Apply word-level highlighting for added lines
472                        let highlighted = highlight_words_diff(content, true);
473                        output.push_str(&format!("+{:>4} {}\n", line_num, highlighted));
474                    } else {
475                        output.push_str(&format!("+{:>4} {}\n", line_num, content));
476                    }
477                }
478                DiffLine::Removed { content, line_num } => {
479                    if self.word_diff {
480                        // Apply word-level highlighting for removed lines
481                        let highlighted = highlight_words_diff(content, false);
482                        output.push_str(&format!("-{:>4} {}\n", line_num, highlighted));
483                    } else {
484                        output.push_str(&format!("-{:>4} {}\n", line_num, content));
485                    }
486                }
487            }
488        }
489
490        let remaining = self.lines.len().saturating_sub(self.scroll_offset + self.visible_height);
491        if remaining > 0 {
492            output.push_str(&format!("... {} more lines\n", remaining));
493        }
494
495        output
496    }
497
498    /// Scroll up
499    pub fn scroll_up(&mut self, amount: usize) {
500        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
501    }
502
503    /// Scroll down
504    pub fn scroll_down(&mut self, amount: usize) {
505        let max = self.lines.len().saturating_sub(self.visible_height);
506        self.scroll_offset = (self.scroll_offset + amount).min(max);
507    }
508}
509
510/// Parse unified diff text into DiffLine structs
511fn parse_diff_lines(diff: &str) -> Vec<DiffLine> {
512    let mut lines = Vec::new();
513    let mut old_line = 0;
514    let mut new_line = 0;
515
516    for raw_line in diff.lines() {
517        if raw_line.starts_with("@@") {
518            // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
519            if let Some(header) = parse_hunk_header(raw_line) {
520                old_line = header.0;
521                new_line = header.2;
522                lines.push(DiffLine::Header {
523                    old_start: header.0,
524                    old_count: header.1,
525                    new_start: header.2,
526                    new_count: header.3,
527                });
528            }
529        } else if raw_line.starts_with('+') {
530            let content = raw_line[1..].to_string();
531            lines.push(DiffLine::Added { content, line_num: new_line });
532            new_line += 1;
533        } else if raw_line.starts_with('-') {
534            let content = raw_line[1..].to_string();
535            lines.push(DiffLine::Removed { content, line_num: old_line });
536            old_line += 1;
537        } else if raw_line.starts_with(' ') {
538            let content = raw_line[1..].to_string();
539            lines.push(DiffLine::Context { content, line_num: new_line });
540            old_line += 1;
541            new_line += 1;
542        }
543    }
544
545    lines
546}
547
548fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
549    // @@ -old_start,old_count +new_start,new_count @@
550    let text = line.trim_start_matches('@').trim_start_matches(' ');
551    let text = text.trim_end_matches('@').trim_end_matches(' ');
552    let parts: Vec<&str> = text.split_whitespace().collect();
553    if parts.len() < 2 {
554        return None;
555    }
556
557    let old: Vec<usize> = parts[0]
558        .trim_start_matches('-')
559        .split(',')
560        .filter_map(|s| s.parse().ok())
561        .collect();
562    let new: Vec<usize> = parts
563        .get(1)?
564        .trim_start_matches('+')
565        .split(',')
566        .filter_map(|s| s.parse().ok())
567        .collect();
568
569    Some((
570        *old.first()?,
571        *old.get(1).unwrap_or(&1),
572        *new.first()?,
573        *new.get(1).unwrap_or(&1),
574    ))
575}
576
577/// Highlight word-level changes in a diff line
578/// Returns the content with ANSI color codes for changed words.
579fn highlight_words_diff(content: &str, is_added: bool) -> String {
580    use std::fmt::Write;
581
582    // Split content into words while preserving spaces
583    let words: Vec<&str> = content.split_whitespace().collect();
584    let mut result = String::new();
585
586    for (i, word) in words.iter().enumerate() {
587        // Simple heuristic: short words (1-4 chars) that differ are likely changed
588        let is_short_change = word.len() <= 4 && !word.chars().all(|c| c.is_alphanumeric());
589
590        if is_short_change && i > 0 {
591            // Highlight as changed
592            let color = if is_added { "\x1b[32m" } else { "\x1b[31m" };
593            write!(&mut result, "{}{}{}\x1b[0m ", color, word, "\x1b[0m").unwrap();
594        } else {
595            write!(&mut result, "{} ", word).unwrap();
596        }
597    }
598
599    result.trim_end().to_string()
600}
601
602/// Bash execution display state
603#[derive(Debug, Clone)]
604pub struct BashExecution {
605    pub command: String,
606    pub output: String,
607    pub exit_code: Option<i32>,
608    pub start_time: std::time::Instant,
609    pub is_running: bool,
610    pub is_cancelled: bool,
611}
612
613impl BashExecution {
614    pub fn new(command: String) -> Self {
615        Self {
616            command,
617            output: String::new(),
618            exit_code: None,
619            start_time: std::time::Instant::now(),
620            is_running: true,
621            is_cancelled: false,
622        }
623    }
624
625    /// Append output
626    pub fn append_output(&mut self, text: &str) {
627        self.output.push_str(text);
628    }
629
630    /// Mark as complete
631    pub fn complete(&mut self, exit_code: i32) {
632        self.exit_code = Some(exit_code);
633        self.is_running = false;
634    }
635
636    /// Cancel execution
637    pub fn cancel(&mut self) {
638        self.is_cancelled = true;
639        self.is_running = false;
640        self.exit_code = Some(-1);
641        self.output.push_str("\n[Cancelled]");
642    }
643
644    /// Get elapsed time
645    pub fn elapsed(&self) -> std::time::Duration {
646        self.start_time.elapsed()
647    }
648
649    /// Render the bash execution display
650    pub fn render(&self) -> String {
651        let mut output = String::new();
652        let status = if self.is_cancelled {
653            "⛔ CANCELLED"
654        } else if self.is_running {
655            &format!("⏳ Running ({:.1}s)", self.elapsed().as_secs_f64())
656        } else {
657            match self.exit_code {
658                Some(0) => "✓ Done",
659                Some(c) => &format!("✗ Exit code: {}", c) as &str,
660                None => "Running",
661            }
662        };
663
664        output.push_str(&format!("$ {}\n", self.command));
665        if !self.output.is_empty() {
666            output.push_str(&self.output);
667            if !self.output.ends_with('\n') {
668                output.push('\n');
669            }
670        }
671        output.push_str(&format!("{}\n", status));
672
673        output
674    }
675}
676
677/// Format bytes for human-readable display
678fn format_bytes(bytes: usize) -> String {
679    if bytes < 1024 {
680        format!("{}B", bytes)
681    } else if bytes < 1024 * 1024 {
682        format!("{:.1}KB", bytes as f64 / 1024.0)
683    } else if bytes < 1024 * 1024 * 1024 {
684        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
685    } else {
686        format!("{:.1}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn test_session_selector_navigation() {
696        let sessions = vec![
697            SessionInfo {
698                id: "1".to_string(),
699                name: "Session 1".to_string(),
700                created_at: "2025-01-01".to_string(),
701                message_count: 5,
702                model: Some("gpt-4".to_string()),
703                parent_id: None,
704            },
705            SessionInfo {
706                id: "2".to_string(),
707                name: "Session 2".to_string(),
708                created_at: "2025-01-02".to_string(),
709                message_count: 3,
710                model: Some("claude-3".to_string()),
711                parent_id: Some("1".to_string()),
712            },
713        ];
714        let mut selector = SessionSelector::new(sessions);
715        assert_eq!(selector.selected().unwrap().id, "1");
716        selector.move_down();
717        assert_eq!(selector.selected().unwrap().id, "2");
718        selector.move_up();
719        assert_eq!(selector.selected().unwrap().id, "1");
720    }
721
722    #[test]
723    fn test_session_selector_filter() {
724        let sessions = vec![
725            SessionInfo {
726                id: "1".to_string(),
727                name: "Rust coding".to_string(),
728                created_at: "2025-01-01".to_string(),
729                message_count: 5,
730                model: None,
731                parent_id: None,
732            },
733            SessionInfo {
734                id: "2".to_string(),
735                name: "Python coding".to_string(),
736                created_at: "2025-01-02".to_string(),
737                message_count: 3,
738                model: None,
739                parent_id: None,
740            },
741        ];
742        let mut selector = SessionSelector::new(sessions);
743        selector.set_filter("rust".to_string());
744        let filtered = selector.filtered_sessions();
745        assert_eq!(filtered.len(), 1);
746        assert_eq!(filtered[0].name, "Rust coding");
747    }
748
749    #[test]
750    fn test_model_selector() {
751        let models = vec![
752            ModelInfo {
753                id: "gpt-4o".to_string(),
754                name: "GPT-4o".to_string(),
755                provider: "openai".to_string(),
756                supports_vision: true,
757                supports_tools: true,
758                supports_thinking: false,
759                context_window: 128000,
760            },
761            ModelInfo {
762                id: "claude-sonnet".to_string(),
763                name: "Claude Sonnet".to_string(),
764                provider: "anthropic".to_string(),
765                supports_vision: true,
766                supports_tools: true,
767                supports_thinking: true,
768                context_window: 200000,
769            },
770        ];
771        let mut selector = ModelSelector::new(models);
772        assert_eq!(selector.selected().unwrap().id, "claude-sonnet");
773        selector.move_down();
774        assert_eq!(selector.selected().unwrap().id, "gpt-4o");
775    }
776
777    #[test]
778    fn test_footer_render() {
779        let footer = FooterData {
780            model_name: "gpt-4o".to_string(),
781            session_name: "test".to_string(),
782            provider_name: "openai".to_string(),
783            input_tokens: 1000,
784            output_tokens: 500,
785            total_cost: 0.05,
786            is_thinking: false,
787            elapsed_seconds: Some(30),
788        };
789        let rendered = footer.render(80);
790        assert!(rendered.contains("gpt-4o"));
791        assert!(rendered.contains("openai"));
792    }
793
794    #[test]
795    fn test_login_dialog() {
796        let mut dialog = LoginDialog::new(vec![
797            "anthropic".to_string(),
798            "openai".to_string(),
799        ]);
800        assert_eq!(dialog.selected_provider(), Some("anthropic"));
801        dialog.next_provider();
802        assert_eq!(dialog.selected_provider(), Some("openai"));
803        dialog.input_char('s');
804        dialog.input_char('k');
805        assert_eq!(dialog.api_key, "sk");
806        dialog.backspace();
807        assert_eq!(dialog.api_key, "s");
808    }
809
810    #[test]
811    fn test_login_dialog_validation() {
812        let mut dialog = LoginDialog::new(vec!["openai".to_string()]);
813        assert!(dialog.validate().is_err()); // empty key
814        dialog.api_key = "sk-1234".to_string();
815        assert!(dialog.validate().is_ok());
816    }
817
818    #[test]
819    fn test_diff_viewer() {
820        let diff = "@@ -1,3 +1,3 @@\n line1\n-old line\n+new line\n line3\n";
821        let viewer = DiffViewer::new("test.txt".to_string(), diff);
822        assert_eq!(viewer.lines.len(), 5); // header + 4 lines
823        let rendered = viewer.render();
824        assert!(rendered.contains("old line"));
825        assert!(rendered.contains("new line"));
826    }
827
828    #[test]
829    fn test_diff_viewer_scroll() {
830        let mut diff = "@@ -1,5 +1,5 @@\n".to_string();
831        for i in 0..100 {
832            diff.push_str(&format!(" line {}\n", i));  // context lines start with space
833        }
834        let mut viewer = DiffViewer::new("test.txt".to_string(), &diff);
835        viewer.visible_height = 10;
836        assert!(viewer.lines.len() > 10, "need {} lines, got {}", 11, viewer.lines.len());
837        viewer.scroll_down(10);
838        assert!(viewer.scroll_offset > 0);
839        viewer.scroll_up(5);
840        assert!(viewer.scroll_offset < 10);
841    }
842
843    #[test]
844    fn test_bash_execution() {
845        let mut exec = BashExecution::new("echo hello".to_string());
846        assert!(exec.is_running);
847        exec.append_output("hello\n");
848        exec.complete(0);
849        assert!(!exec.is_running);
850        assert_eq!(exec.exit_code, Some(0));
851        let rendered = exec.render();
852        assert!(rendered.contains("echo hello"));
853        assert!(rendered.contains("hello"));
854        assert!(rendered.contains("Done"));
855    }
856
857    #[test]
858    fn test_bash_execution_cancel() {
859        let mut exec = BashExecution::new("sleep 999".to_string());
860        exec.cancel();
861        assert!(exec.is_cancelled);
862        assert!(!exec.is_running);
863        let rendered = exec.render();
864        assert!(rendered.contains("CANCELLED"));
865    }
866
867    #[test]
868    fn test_parse_hunk_header() {
869        let result = parse_hunk_header("@@ -1,3 +1,3 @@");
870        assert_eq!(result, Some((1, 3, 1, 3)));
871    }
872
873    #[test]
874    fn test_format_bytes() {
875        assert_eq!(format_bytes(500), "500B");
876        assert_eq!(format_bytes(1024), "1.0KB");
877        assert_eq!(format_bytes(1024 * 1024), "1.0MB");
878        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0GB");
879    }
880}