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}
414
415impl DiffViewer {
416    pub fn new(file_path: String, diff_text: &str) -> Self {
417        let lines = parse_diff_lines(diff_text);
418        Self {
419            lines,
420            scroll_offset: 0,
421            visible_height: 30,
422            file_path,
423        }
424    }
425
426    /// Render the diff viewer
427    pub fn render(&self) -> String {
428        let mut output = String::new();
429        output.push_str(&format!("Diff: {}\n", self.file_path));
430        output.push_str(&format!("{}\n", "─".repeat(60)));
431
432        let visible: Vec<_> = self.lines
433            .iter()
434            .skip(self.scroll_offset)
435            .take(self.visible_height)
436            .collect();
437
438        for line in &visible {
439            match line {
440                DiffLine::Header { old_start, old_count, new_start, new_count } => {
441                    output.push_str(&format!(
442                        "@@ -{},{} +{},{} @@\n",
443                        old_start, old_count, new_start, new_count
444                    ));
445                }
446                DiffLine::Context { content, line_num } => {
447                    output.push_str(&format!(" {:>4} {}\n", line_num, content));
448                }
449                DiffLine::Added { content, line_num } => {
450                    output.push_str(&format!("+{:>4} {}\n", line_num, content));
451                }
452                DiffLine::Removed { content, line_num } => {
453                    output.push_str(&format!("-{:>4} {}\n", line_num, content));
454                }
455            }
456        }
457
458        let remaining = self.lines.len().saturating_sub(self.scroll_offset + self.visible_height);
459        if remaining > 0 {
460            output.push_str(&format!("... {} more lines\n", remaining));
461        }
462
463        output
464    }
465
466    /// Scroll up
467    pub fn scroll_up(&mut self, amount: usize) {
468        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
469    }
470
471    /// Scroll down
472    pub fn scroll_down(&mut self, amount: usize) {
473        let max = self.lines.len().saturating_sub(self.visible_height);
474        self.scroll_offset = (self.scroll_offset + amount).min(max);
475    }
476}
477
478/// Parse unified diff text into DiffLine structs
479fn parse_diff_lines(diff: &str) -> Vec<DiffLine> {
480    let mut lines = Vec::new();
481    let mut old_line = 0;
482    let mut new_line = 0;
483
484    for raw_line in diff.lines() {
485        if raw_line.starts_with("@@") {
486            // Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
487            if let Some(header) = parse_hunk_header(raw_line) {
488                old_line = header.0;
489                new_line = header.2;
490                lines.push(DiffLine::Header {
491                    old_start: header.0,
492                    old_count: header.1,
493                    new_start: header.2,
494                    new_count: header.3,
495                });
496            }
497        } else if raw_line.starts_with('+') {
498            let content = raw_line[1..].to_string();
499            lines.push(DiffLine::Added { content, line_num: new_line });
500            new_line += 1;
501        } else if raw_line.starts_with('-') {
502            let content = raw_line[1..].to_string();
503            lines.push(DiffLine::Removed { content, line_num: old_line });
504            old_line += 1;
505        } else if raw_line.starts_with(' ') {
506            let content = raw_line[1..].to_string();
507            lines.push(DiffLine::Context { content, line_num: new_line });
508            old_line += 1;
509            new_line += 1;
510        }
511    }
512
513    lines
514}
515
516fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
517    // @@ -old_start,old_count +new_start,new_count @@
518    let text = line.trim_start_matches('@').trim_start_matches(' ');
519    let text = text.trim_end_matches('@').trim_end_matches(' ');
520    let parts: Vec<&str> = text.split_whitespace().collect();
521    if parts.len() < 2 {
522        return None;
523    }
524
525    let old: Vec<usize> = parts[0]
526        .trim_start_matches('-')
527        .split(',')
528        .filter_map(|s| s.parse().ok())
529        .collect();
530    let new: Vec<usize> = parts
531        .get(1)?
532        .trim_start_matches('+')
533        .split(',')
534        .filter_map(|s| s.parse().ok())
535        .collect();
536
537    Some((
538        *old.first()?,
539        *old.get(1).unwrap_or(&1),
540        *new.first()?,
541        *new.get(1).unwrap_or(&1),
542    ))
543}
544
545/// Bash execution display state
546#[derive(Debug, Clone)]
547pub struct BashExecution {
548    pub command: String,
549    pub output: String,
550    pub exit_code: Option<i32>,
551    pub start_time: std::time::Instant,
552    pub is_running: bool,
553    pub is_cancelled: bool,
554}
555
556impl BashExecution {
557    pub fn new(command: String) -> Self {
558        Self {
559            command,
560            output: String::new(),
561            exit_code: None,
562            start_time: std::time::Instant::now(),
563            is_running: true,
564            is_cancelled: false,
565        }
566    }
567
568    /// Append output
569    pub fn append_output(&mut self, text: &str) {
570        self.output.push_str(text);
571    }
572
573    /// Mark as complete
574    pub fn complete(&mut self, exit_code: i32) {
575        self.exit_code = Some(exit_code);
576        self.is_running = false;
577    }
578
579    /// Cancel execution
580    pub fn cancel(&mut self) {
581        self.is_cancelled = true;
582        self.is_running = false;
583        self.exit_code = Some(-1);
584        self.output.push_str("\n[Cancelled]");
585    }
586
587    /// Get elapsed time
588    pub fn elapsed(&self) -> std::time::Duration {
589        self.start_time.elapsed()
590    }
591
592    /// Render the bash execution display
593    pub fn render(&self) -> String {
594        let mut output = String::new();
595        let status = if self.is_cancelled {
596            "⛔ CANCELLED"
597        } else if self.is_running {
598            &format!("⏳ Running ({:.1}s)", self.elapsed().as_secs_f64())
599        } else {
600            match self.exit_code {
601                Some(0) => "✓ Done",
602                Some(c) => &format!("✗ Exit code: {}", c) as &str,
603                None => "Running",
604            }
605        };
606
607        output.push_str(&format!("$ {}\n", self.command));
608        if !self.output.is_empty() {
609            output.push_str(&self.output);
610            if !self.output.ends_with('\n') {
611                output.push('\n');
612            }
613        }
614        output.push_str(&format!("{}\n", status));
615
616        output
617    }
618}
619
620/// Format bytes for human-readable display
621fn format_bytes(bytes: usize) -> String {
622    if bytes < 1024 {
623        format!("{}B", bytes)
624    } else if bytes < 1024 * 1024 {
625        format!("{:.1}KB", bytes as f64 / 1024.0)
626    } else if bytes < 1024 * 1024 * 1024 {
627        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
628    } else {
629        format!("{:.1}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn test_session_selector_navigation() {
639        let sessions = vec![
640            SessionInfo {
641                id: "1".to_string(),
642                name: "Session 1".to_string(),
643                created_at: "2025-01-01".to_string(),
644                message_count: 5,
645                model: Some("gpt-4".to_string()),
646                parent_id: None,
647            },
648            SessionInfo {
649                id: "2".to_string(),
650                name: "Session 2".to_string(),
651                created_at: "2025-01-02".to_string(),
652                message_count: 3,
653                model: Some("claude-3".to_string()),
654                parent_id: Some("1".to_string()),
655            },
656        ];
657        let mut selector = SessionSelector::new(sessions);
658        assert_eq!(selector.selected().unwrap().id, "1");
659        selector.move_down();
660        assert_eq!(selector.selected().unwrap().id, "2");
661        selector.move_up();
662        assert_eq!(selector.selected().unwrap().id, "1");
663    }
664
665    #[test]
666    fn test_session_selector_filter() {
667        let sessions = vec![
668            SessionInfo {
669                id: "1".to_string(),
670                name: "Rust coding".to_string(),
671                created_at: "2025-01-01".to_string(),
672                message_count: 5,
673                model: None,
674                parent_id: None,
675            },
676            SessionInfo {
677                id: "2".to_string(),
678                name: "Python coding".to_string(),
679                created_at: "2025-01-02".to_string(),
680                message_count: 3,
681                model: None,
682                parent_id: None,
683            },
684        ];
685        let mut selector = SessionSelector::new(sessions);
686        selector.set_filter("rust".to_string());
687        let filtered = selector.filtered_sessions();
688        assert_eq!(filtered.len(), 1);
689        assert_eq!(filtered[0].name, "Rust coding");
690    }
691
692    #[test]
693    fn test_model_selector() {
694        let models = vec![
695            ModelInfo {
696                id: "gpt-4o".to_string(),
697                name: "GPT-4o".to_string(),
698                provider: "openai".to_string(),
699                supports_vision: true,
700                supports_tools: true,
701                supports_thinking: false,
702                context_window: 128000,
703            },
704            ModelInfo {
705                id: "claude-sonnet".to_string(),
706                name: "Claude Sonnet".to_string(),
707                provider: "anthropic".to_string(),
708                supports_vision: true,
709                supports_tools: true,
710                supports_thinking: true,
711                context_window: 200000,
712            },
713        ];
714        let mut selector = ModelSelector::new(models);
715        assert_eq!(selector.selected().unwrap().id, "claude-sonnet");
716        selector.move_down();
717        assert_eq!(selector.selected().unwrap().id, "gpt-4o");
718    }
719
720    #[test]
721    fn test_footer_render() {
722        let footer = FooterData {
723            model_name: "gpt-4o".to_string(),
724            session_name: "test".to_string(),
725            provider_name: "openai".to_string(),
726            input_tokens: 1000,
727            output_tokens: 500,
728            total_cost: 0.05,
729            is_thinking: false,
730            elapsed_seconds: Some(30),
731        };
732        let rendered = footer.render(80);
733        assert!(rendered.contains("gpt-4o"));
734        assert!(rendered.contains("openai"));
735    }
736
737    #[test]
738    fn test_login_dialog() {
739        let mut dialog = LoginDialog::new(vec![
740            "anthropic".to_string(),
741            "openai".to_string(),
742        ]);
743        assert_eq!(dialog.selected_provider(), Some("anthropic"));
744        dialog.next_provider();
745        assert_eq!(dialog.selected_provider(), Some("openai"));
746        dialog.input_char('s');
747        dialog.input_char('k');
748        assert_eq!(dialog.api_key, "sk");
749        dialog.backspace();
750        assert_eq!(dialog.api_key, "s");
751    }
752
753    #[test]
754    fn test_login_dialog_validation() {
755        let mut dialog = LoginDialog::new(vec!["openai".to_string()]);
756        assert!(dialog.validate().is_err()); // empty key
757        dialog.api_key = "sk-1234".to_string();
758        assert!(dialog.validate().is_ok());
759    }
760
761    #[test]
762    fn test_diff_viewer() {
763        let diff = "@@ -1,3 +1,3 @@\n line1\n-old line\n+new line\n line3\n";
764        let viewer = DiffViewer::new("test.txt".to_string(), diff);
765        assert_eq!(viewer.lines.len(), 5); // header + 4 lines
766        let rendered = viewer.render();
767        assert!(rendered.contains("old line"));
768        assert!(rendered.contains("new line"));
769    }
770
771    #[test]
772    fn test_diff_viewer_scroll() {
773        let mut diff = "@@ -1,5 +1,5 @@\n".to_string();
774        for i in 0..100 {
775            diff.push_str(&format!(" line {}\n", i));  // context lines start with space
776        }
777        let mut viewer = DiffViewer::new("test.txt".to_string(), &diff);
778        viewer.visible_height = 10;
779        assert!(viewer.lines.len() > 10, "need {} lines, got {}", 11, viewer.lines.len());
780        viewer.scroll_down(10);
781        assert!(viewer.scroll_offset > 0);
782        viewer.scroll_up(5);
783        assert!(viewer.scroll_offset < 10);
784    }
785
786    #[test]
787    fn test_bash_execution() {
788        let mut exec = BashExecution::new("echo hello".to_string());
789        assert!(exec.is_running);
790        exec.append_output("hello\n");
791        exec.complete(0);
792        assert!(!exec.is_running);
793        assert_eq!(exec.exit_code, Some(0));
794        let rendered = exec.render();
795        assert!(rendered.contains("echo hello"));
796        assert!(rendered.contains("hello"));
797        assert!(rendered.contains("Done"));
798    }
799
800    #[test]
801    fn test_bash_execution_cancel() {
802        let mut exec = BashExecution::new("sleep 999".to_string());
803        exec.cancel();
804        assert!(exec.is_cancelled);
805        assert!(!exec.is_running);
806        let rendered = exec.render();
807        assert!(rendered.contains("CANCELLED"));
808    }
809
810    #[test]
811    fn test_parse_hunk_header() {
812        let result = parse_hunk_header("@@ -1,3 +1,3 @@");
813        assert_eq!(result, Some((1, 3, 1, 3)));
814    }
815
816    #[test]
817    fn test_format_bytes() {
818        assert_eq!(format_bytes(500), "500B");
819        assert_eq!(format_bytes(1024), "1.0KB");
820        assert_eq!(format_bytes(1024 * 1024), "1.0MB");
821        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0GB");
822    }
823}