1use serde::{Deserialize, Serialize};
12
13#[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#[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 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 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 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 pub fn selected(&self) -> Option<&SessionInfo> {
80 self.filtered_sessions().into_iter().nth(self.selected_index)
81 }
82
83 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 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#[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#[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 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 pub fn move_up(&mut self) {
189 if self.selected_index > 0 {
190 self.selected_index -= 1;
191 }
192 }
193
194 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 pub fn selected(&self) -> Option<&ModelInfo> {
204 self.filtered_models().into_iter().nth(self.selected_index)
205 }
206
207 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 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#[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 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 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#[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 pub fn selected_provider(&self) -> Option<&str> {
316 self.providers.get(self.selected_provider_index).map(|s| s.as_str())
317 }
318
319 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 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 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 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 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 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 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 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#[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#[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 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, }
427 }
428
429 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 pub fn set_word_diff(&mut self, enabled: bool) {
443 self.word_diff = enabled;
444 }
445
446 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 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 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 pub fn scroll_up(&mut self, amount: usize) {
500 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
501 }
502
503 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
510fn 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 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 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
577fn highlight_words_diff(content: &str, is_added: bool) -> String {
580 use std::fmt::Write;
581
582 let words: Vec<&str> = content.split_whitespace().collect();
584 let mut result = String::new();
585
586 for (i, word) in words.iter().enumerate() {
587 let is_short_change = word.len() <= 4 && !word.chars().all(|c| c.is_alphanumeric());
589
590 if is_short_change && i > 0 {
591 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#[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 pub fn append_output(&mut self, text: &str) {
627 self.output.push_str(text);
628 }
629
630 pub fn complete(&mut self, exit_code: i32) {
632 self.exit_code = Some(exit_code);
633 self.is_running = false;
634 }
635
636 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 pub fn elapsed(&self) -> std::time::Duration {
646 self.start_time.elapsed()
647 }
648
649 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
677fn 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()); 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); 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)); }
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}