1use crate::agent::types::Usage;
2use crate::agent::ui::messages::pad_to_width;
3use crate::agent::ui::theme::RabTheme;
4use crate::tui::util::{truncate_to_width, visible_width};
5
6fn sanitize_status_text(text: &str) -> String {
11 text.replace(['\r', '\n', '\t'], " ")
12 .split(' ')
13 .filter(|s| !s.is_empty())
14 .collect::<Vec<_>>()
15 .join(" ")
16}
17
18pub fn format_tokens(count: u64) -> String {
20 if count < 1000 {
21 return count.to_string();
22 }
23 if count < 10000 {
24 return format!("{:.1}k", count as f64 / 1000.0);
25 }
26 if count < 1_000_000 {
27 return format!("{}k", (count as f64 / 1000.0).round() as u64);
28 }
29 if count < 10_000_000 {
30 return format!("{:.1}M", count as f64 / 1_000_000.0);
31 }
32 format!("{}M", (count as f64 / 1_000_000.0).round() as u64)
33}
34
35pub fn format_cwd_for_footer(cwd: &str, home: Option<&str>) -> String {
38 let home = match home {
39 Some(h) => h,
40 None => return cwd.to_string(),
41 };
42
43 let resolved_cwd = std::path::Path::new(cwd);
44 let resolved_home = std::path::Path::new(home);
45
46 let relative = match resolved_cwd.strip_prefix(resolved_home) {
48 Ok(rest) => {
49 if rest.as_os_str().is_empty() {
50 return "~".to_string();
52 }
53 rest.to_string_lossy().to_string()
54 }
55 Err(_) => return cwd.to_string(),
56 };
57
58 format!("~/{}", relative)
59}
60
61pub struct Footer {
66 cwd: String,
67 git_branch: Option<String>,
68 session_name: Option<String>,
69
70 total_input: u64,
72 total_output: u64,
73 total_cache_read: u64,
74 total_cache_write: u64,
75 total_cost: f64,
76 latest_cache_hit_rate: Option<f64>,
77
78 context_percent: Option<f64>,
79 context_window: u64,
80
81 auto_compact: bool,
82
83 model: String,
84 model_supports_reasoning: bool,
86 thinking_level: Option<String>,
87
88 available_provider_count: usize,
90
91 using_subscription: bool,
93
94 experimental_enabled: bool,
96
97 pub extension_statuses: Vec<(String, String)>, theme: RabTheme,
100}
101
102impl Footer {
103 pub fn new(cwd: impl Into<String>) -> Self {
104 let theme = crate::agent::ui::theme::current_theme().clone();
105 Self {
106 cwd: cwd.into(),
107 git_branch: None,
108 session_name: None,
109 total_input: 0,
110 total_output: 0,
111 total_cache_read: 0,
112 total_cache_write: 0,
113 total_cost: 0.0,
114 latest_cache_hit_rate: None,
115 context_percent: None,
116 context_window: 0,
117 auto_compact: true,
118 model: String::new(),
119 model_supports_reasoning: false,
120 thinking_level: None,
121 available_provider_count: 1,
122 using_subscription: false,
123 experimental_enabled: false,
124 extension_statuses: Vec::new(),
125 theme,
126 }
127 }
128
129 pub fn set_cwd(&mut self, cwd: impl Into<String>) {
132 self.cwd = cwd.into();
133 }
134
135 pub fn set_git_branch(&mut self, branch: Option<String>) {
136 self.git_branch = branch;
137 }
138
139 pub fn set_session_name(&mut self, name: Option<String>) {
140 self.session_name = name;
141 }
142
143 pub fn set_model(&mut self, model: impl Into<String>) {
144 self.model = model.into();
145 }
146
147 pub fn set_model_supports_reasoning(&mut self, supports: bool) {
149 self.model_supports_reasoning = supports;
150 }
151
152 pub fn set_thinking_level(&mut self, level: Option<String>) {
153 self.thinking_level = level;
154 }
155
156 pub fn set_auto_compact(&mut self, enabled: bool) {
157 self.auto_compact = enabled;
158 }
159
160 pub fn set_available_provider_count(&mut self, count: usize) {
161 self.available_provider_count = count;
162 }
163
164 pub fn set_using_subscription(&mut self, using: bool) {
165 self.using_subscription = using;
166 }
167
168 pub fn set_experimental_enabled(&mut self, enabled: bool) {
169 self.experimental_enabled = enabled;
170 }
171
172 pub fn accumulate_usage(&mut self, usage: &Usage) {
174 let input = usage.input_tokens.unwrap_or(0) as u64;
175 let output = usage.output_tokens.unwrap_or(0) as u64;
176 let cache_read = usage.cache_tokens.unwrap_or(0) as u64;
177
178 self.total_input += input;
179 self.total_output += output;
180 self.total_cache_read += cache_read;
181
182 let total_prompt = input + cache_read;
184 if total_prompt > 0 {
185 self.latest_cache_hit_rate = Some((cache_read as f64 / total_prompt as f64) * 100.0);
186 }
187 }
188
189 pub fn set_usage(
191 &mut self,
192 total_input: u64,
193 total_output: u64,
194 total_cache_read: u64,
195 total_cache_write: u64,
196 total_cost: f64,
197 latest_cache_hit_rate: Option<f64>,
198 ) {
199 self.total_input = total_input;
200 self.total_output = total_output;
201 self.total_cache_read = total_cache_read;
202 self.total_cache_write = total_cache_write;
203 self.total_cost = total_cost;
204 self.latest_cache_hit_rate = latest_cache_hit_rate;
205 }
206
207 pub fn set_cost(&mut self, cost: f64) {
209 self.total_cost = cost;
210 }
211
212 pub fn set_cache_write(&mut self, cache_write: u64) {
214 self.total_cache_write = cache_write;
215 }
216
217 pub fn set_streaming(&mut self, _streaming: bool) {
220 }
222
223 pub fn set_context(&mut self, percent: Option<f64>, window: u64) {
225 self.context_percent = percent;
226 self.context_window = window;
227 }
228
229 pub fn set_extension_status(&mut self, key: String, text: Option<String>) {
231 if let Some(text) = text {
232 if let Some(pos) = self.extension_statuses.iter().position(|(k, _)| k == &key) {
234 self.extension_statuses[pos].1 = text;
235 } else {
236 self.extension_statuses.push((key, text));
237 }
238 } else {
239 self.extension_statuses.retain(|(k, _)| k != &key);
241 }
242 self.extension_statuses.sort_by(|(a, _), (b, _)| a.cmp(b));
244 }
245
246 pub fn clear_extension_statuses(&mut self) {
248 self.extension_statuses.clear();
249 }
250}
251
252impl crate::tui::Component for Footer {
253 fn render(&self, width: usize) -> Vec<String> {
254 let w = width;
255 if w < 4 {
256 return vec![]; }
258
259 let theme = &self.theme;
260
261 let home = std::env::var("HOME").ok();
263 let mut pwd = format_cwd_for_footer(&self.cwd, home.as_deref());
264
265 if let Some(ref branch) = self.git_branch {
266 pwd = format!("{} ({})", pwd, branch);
267 }
268 if let Some(ref name) = self.session_name {
269 pwd = format!("{} • {}", pwd, name);
270 }
271 let pwd_line = truncate_to_width(&theme.fg("dim", &pwd), w, &theme.fg("dim", "..."), true);
272 let pwd_line = if pwd_line.is_empty() {
273 String::new()
274 } else {
275 pad_to_width(&pwd_line, w)
276 };
277
278 let mut stats_parts: Vec<String> = Vec::new();
281
282 if self.total_input > 0 {
283 stats_parts.push(format!("↑{}", format_tokens(self.total_input)));
284 }
285 if self.total_output > 0 {
286 stats_parts.push(format!("↓{}", format_tokens(self.total_output)));
287 }
288 if self.total_cache_read > 0 {
289 stats_parts.push(format!("R{}", format_tokens(self.total_cache_read)));
290 }
291 if self.total_cache_write > 0 {
292 stats_parts.push(format!("W{}", format_tokens(self.total_cache_write)));
293 }
294 if (self.total_cache_read > 0 || self.total_cache_write > 0)
295 && let Some(hit_rate) = self.latest_cache_hit_rate
296 {
297 stats_parts.push(format!("CH{:.1}%", hit_rate));
298 }
299
300 if self.total_cost > 0.0 || self.using_subscription {
302 let cost_str = if self.using_subscription {
303 format!("${:.3} (sub)", self.total_cost)
304 } else {
305 format!("${:.3}", self.total_cost)
306 };
307 stats_parts.push(cost_str);
308 }
309
310 let context_percent_str = match self.context_percent {
312 Some(p) => {
313 let window_str = format_tokens(self.context_window);
314 let display = if self.auto_compact {
315 format!("{:.1}%/{} (auto)", p, window_str)
316 } else {
317 format!("{:.1}%/{}", p, window_str)
318 };
319 if p > 90.0 {
320 theme.fg("error", &display)
321 } else if p > 70.0 {
322 theme.fg("warning", &display)
323 } else {
324 display
325 }
326 }
327 None => {
328 let window_str = format_tokens(self.context_window);
329 if self.auto_compact {
330 format!("?/{} (auto)", window_str)
331 } else {
332 format!("?/{}", window_str)
333 }
334 }
335 };
336 if !context_percent_str.is_empty() {
337 stats_parts.push(context_percent_str);
338 }
339
340 if self.experimental_enabled {
342 stats_parts.push(format!(
343 "{} {}",
344 theme.fg("dim", "•"),
345 theme.bold(&theme.fg("warning", "xp"))
346 ));
347 }
348
349 let mut stats_left = stats_parts.join(" ");
350
351 let model_name = if self.model.is_empty() {
353 "no-model".to_string()
354 } else {
355 self.model
356 .strip_prefix("opencode_go::")
357 .unwrap_or(&self.model)
358 .to_string()
359 };
360
361 let right_side_without_provider = if self.model_supports_reasoning {
363 match &self.thinking_level {
364 Some(level) if level != "off" => format!("{} • {}", model_name, level),
365 _ => format!("{} • thinking off", model_name),
366 }
367 } else {
368 model_name.clone()
369 };
370
371 let right_side = if self.available_provider_count > 1 && !self.model.is_empty() {
373 let model_with_provider = format!("(?) {}", right_side_without_provider);
374 model_with_provider
376 } else {
377 right_side_without_provider.clone()
378 };
379
380 let mut stats_left_width = visible_width(&stats_left);
382
383 if stats_left_width > w {
385 stats_left = truncate_to_width(&stats_left, w, "…", true);
386 stats_left_width = visible_width(&stats_left);
387 }
388
389 let right_side_width = visible_width(&right_side);
390 let min_padding: usize = 2;
391
392 let stats_line = if stats_left_width + min_padding + right_side_width <= w {
393 let padding = " ".repeat(w - stats_left_width - right_side_width);
395 format!("{}{}{}", stats_left, padding, right_side)
396 } else if !self.model.is_empty()
397 && self.available_provider_count > 1
398 && stats_left_width + min_padding + visible_width(&right_side_without_provider) <= w
399 {
400 let padding =
402 " ".repeat(w - stats_left_width - visible_width(&right_side_without_provider));
403 format!("{}{}{}", stats_left, padding, right_side_without_provider)
404 } else {
405 let available_for_right = w.saturating_sub(stats_left_width + min_padding);
407 if available_for_right > 0 {
408 let truncated_right = truncate_to_width(&right_side, available_for_right, "", true);
409 let truncated_right_width = visible_width(&truncated_right);
410 let padding = " ".repeat(w - stats_left_width - truncated_right_width);
411 format!("{}{}{}", stats_left, padding, truncated_right)
412 } else {
413 stats_left.clone()
415 }
416 };
417
418 let dim_stats_left = theme.fg("dim", &stats_left);
420 let remainder = &stats_line[stats_left.len()..]; let dim_remainder = theme.fg("dim", remainder);
422
423 let stats_line_formatted = format!("{}{}", dim_stats_left, dim_remainder);
424
425 let mut lines = vec![pwd_line, stats_line_formatted];
426
427 if !self.extension_statuses.is_empty() {
429 let status_text: Vec<String> = self
430 .extension_statuses
431 .iter()
432 .map(|(_, text)| sanitize_status_text(text))
433 .collect();
434 let status_line = status_text.join(" ");
435 let truncated = truncate_to_width(&status_line, w, &theme.fg("dim", "..."), true);
436 let status_line = if truncated.is_empty() {
437 String::new()
438 } else {
439 pad_to_width(&truncated, w)
440 };
441 if !status_line.trim().is_empty() {
442 lines.push(status_line);
443 }
444 }
445
446 lines
447 }
448
449 fn invalidate(&mut self) {
450 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::tui::Component;
458
459 fn make_footer() -> Footer {
460 crate::agent::ui::theme::init_theme(Some("dark"), false);
461 let mut footer = Footer::new("/home/user/project");
462 footer.set_model("test-model");
463 footer.set_git_branch(Some("main".into()));
464 footer
465 }
466
467 #[test]
470 fn test_format_cwd_home() {
471 let result = format_cwd_for_footer("/home/user/project", Some("/home/user"));
472 assert_eq!(result, "~/project");
473 }
474
475 #[test]
476 fn test_format_cwd_home_exact() {
477 let result = format_cwd_for_footer("/home/user", Some("/home/user"));
478 assert_eq!(result, "~");
479 }
480
481 #[test]
482 fn test_format_cwd_outside_home() {
483 let result = format_cwd_for_footer("/opt/app", Some("/home/user"));
484 assert_eq!(result, "/opt/app");
485 }
486
487 #[test]
488 fn test_format_cwd_no_home() {
489 let result = format_cwd_for_footer("/some/path", None::<&str>);
490 assert_eq!(result, "/some/path");
491 }
492
493 #[test]
496 fn test_format_tokens_under_1k() {
497 assert_eq!(format_tokens(500), "500");
498 }
499
500 #[test]
501 fn test_format_tokens_1k_to_10k() {
502 assert_eq!(format_tokens(5500), "5.5k");
503 }
504
505 #[test]
506 fn test_format_tokens_10k_to_1m() {
507 assert_eq!(format_tokens(55500), "56k");
508 }
509
510 #[test]
511 fn test_format_tokens_1m_to_10m() {
512 assert_eq!(format_tokens(5_500_000), "5.5M");
513 }
514
515 #[test]
516 fn test_format_tokens_over_10m() {
517 assert_eq!(format_tokens(55_000_000), "55M");
518 }
519
520 #[test]
523 fn test_sanitize_status() {
524 assert_eq!(sanitize_status_text("hello\nworld"), "hello world");
525 assert_eq!(sanitize_status_text("hello\tworld"), "hello world");
526 assert_eq!(sanitize_status_text("hello\r\nworld"), "hello world");
527 assert_eq!(sanitize_status_text(" spaced "), "spaced");
528 }
529
530 #[test]
533 fn test_footer_shows_cwd() {
534 let footer = make_footer();
535 let lines = footer.render(80);
536 assert!(lines.len() >= 2, "Should have at least 2 lines");
537 assert!(lines[0].contains("project"), "Should show cwd");
538 }
539
540 #[test]
541 fn test_footer_shows_git_branch() {
542 let footer = make_footer();
543 let lines = footer.render(80);
544 assert!(lines[0].contains("main"), "Should show git branch");
545 }
546
547 #[test]
548 fn test_footer_shows_session_name() {
549 let mut footer = make_footer();
550 footer.set_session_name(Some("my-session".into()));
551 let lines = footer.render(80);
552 assert!(lines[0].contains("my-session"), "Should show session name");
553 }
554
555 #[test]
556 fn test_cwd_truncated_to_width() {
557 let mut footer = Footer::new("/very/long/path/that/exceeds/available/width/completely");
558 footer.set_model("model");
559 let lines = footer.render(30);
560 assert!(lines.len() >= 2);
561 for line in &lines {
562 assert!(
563 visible_width(line) <= 30,
564 "Line '{}' exceeds width 30",
565 line
566 );
567 }
568 }
569
570 #[test]
573 fn test_footer_shows_model() {
574 let footer = make_footer();
575 let lines = footer.render(80);
576 assert!(lines[1].contains("test-model"), "Should show model name");
577 }
578
579 #[test]
580 fn test_footer_shows_no_model() {
581 let mut footer = Footer::new("/path");
582 footer.set_model("");
583 let lines = footer.render(80);
584 assert!(
585 lines[1].contains("no-model"),
586 "Should show 'no-model' when model not set"
587 );
588 }
589
590 #[test]
591 fn test_footer_shows_thinking_level() {
592 let mut footer = make_footer();
593 footer.set_model_supports_reasoning(true);
594 footer.set_thinking_level(Some("high".into()));
595 let lines = footer.render(80);
596 assert!(lines[1].contains("high"), "Should show thinking level");
597 }
598
599 #[test]
600 fn test_footer_thinking_off_with_reasoning() {
601 let mut footer = make_footer();
602 footer.set_model_supports_reasoning(true);
603 footer.set_thinking_level(Some("off".into()));
604 let lines = footer.render(80);
605 assert!(
606 lines[1].contains("thinking off"),
607 "Should show 'thinking off' when reasoning model has level off"
608 );
609 }
610
611 #[test]
612 fn test_footer_shows_token_usage() {
613 let mut footer = make_footer();
614 let usage = Usage {
615 input_tokens: Some(1500),
616 output_tokens: Some(500),
617 cache_tokens: None,
618 };
619 footer.accumulate_usage(&usage);
620 let lines = footer.render(80);
621 assert!(lines[1].contains("↑"), "Should show input tokens");
622 assert!(lines[1].contains("↓"), "Should show output tokens");
623 }
624
625 #[test]
626 fn test_footer_usage_multiple_calls() {
627 let mut footer = make_footer();
628 let u1 = Usage {
629 input_tokens: Some(1000),
630 output_tokens: Some(500),
631 cache_tokens: None,
632 };
633 footer.accumulate_usage(&u1);
634 let u2 = Usage {
635 input_tokens: Some(2000),
636 output_tokens: Some(300),
637 cache_tokens: None,
638 };
639 footer.accumulate_usage(&u2);
640 let lines = footer.render(80);
641 assert!(lines[1].contains("↑3.0k"), "Should show accumulated input");
642 assert!(lines[1].contains("↓800"), "Should show accumulated output");
643 }
644
645 #[test]
646 fn test_footer_shows_cache_hit_rate() {
647 let mut footer = make_footer();
648 let usage = Usage {
649 input_tokens: Some(1000),
650 output_tokens: Some(500),
651 cache_tokens: Some(200),
652 };
653 footer.accumulate_usage(&usage);
654 let lines = footer.render(80);
655 assert!(
656 lines[1].contains("CH"),
657 "Should show cache hit rate when cache tokens present"
658 );
659 assert!(
661 lines[1].contains("CH16.7%"),
662 "Should show correct cache hit rate"
663 );
664 }
665
666 #[test]
667 fn test_footer_shows_cost() {
668 let mut footer = make_footer();
669 footer.set_cost(0.0123);
670 let lines = footer.render(80);
671 assert!(lines[1].contains("$0.012"), "Should show cost");
672 }
673
674 #[test]
675 fn test_footer_shows_subscription_indicator() {
676 let mut footer = make_footer();
677 footer.set_cost(0.0);
678 footer.set_using_subscription(true);
679 let lines = footer.render(80);
680 assert!(
681 lines[1].contains("(sub)"),
682 "Should show subscription indicator"
683 );
684 }
685
686 #[test]
689 fn test_footer_shows_auto_compact_next_to_context() {
690 let mut footer = make_footer();
691 footer.set_auto_compact(true);
692 footer.set_context(Some(50.0), 128000);
693 let lines = footer.render(80);
694 assert!(
695 lines[1].contains("(auto)"),
696 "Should show (auto) next to context percentage"
697 );
698 assert!(
699 lines[1].contains("50.0%/128k (auto)"),
700 "Should show context percent with auto compact"
701 );
702 }
703
704 #[test]
705 fn test_footer_hides_auto_compact_when_disabled() {
706 let mut footer = make_footer();
707 footer.set_auto_compact(false);
708 footer.set_context(Some(50.0), 128000);
709 let lines = footer.render(80);
710 assert!(
711 !lines[1].contains("(auto)"),
712 "Should NOT show (auto) when disabled"
713 );
714 }
715
716 #[test]
719 fn test_footer_context_percent_high() {
720 let mut footer = make_footer();
721 footer.set_context(Some(95.0), 128000);
722 let lines = footer.render(80);
723 assert!(lines[1].contains("95"), "Should show context percent");
724 assert!(
725 lines[1].contains("128k"),
726 "Should show formatted window size"
727 );
728 assert!(
730 lines[1].contains("\x1b[38;2;"),
731 "Should have ANSI color for high context"
732 );
733 }
734
735 #[test]
736 fn test_footer_context_without_percent() {
737 let mut footer = make_footer();
738 footer.set_context(None, 64000);
739 let lines = footer.render(80);
740 assert!(lines[1].contains("?"), "Should show unknown context");
741 assert!(lines[1].contains("64k"), "Should show context window size");
742 }
743
744 #[test]
747 fn test_footer_shows_extension_statuses() {
748 let mut footer = make_footer();
749 footer.set_extension_status("ext1".into(), Some("ready".into()));
750 let lines = footer.render(80);
751 assert!(lines.len() >= 3, "Should have 3 lines");
752 assert!(lines[2].contains("ready"), "Should show extension status");
753 }
754
755 #[test]
756 fn test_footer_extension_status_sorted() {
757 let mut footer = make_footer();
758 footer.set_extension_status("z_last".into(), Some("last".into()));
759 footer.set_extension_status("a_first".into(), Some("first".into()));
760 let lines = footer.render(80);
761 if lines.len() >= 3 {
762 let first_idx = lines[2].find("first");
763 let last_idx = lines[2].find("last");
764 assert!(
765 first_idx < last_idx,
766 "Extension statuses should be sorted by key"
767 );
768 }
769 }
770
771 #[test]
772 fn test_footer_extension_status_sanitized() {
773 let mut footer = make_footer();
774 footer.set_extension_status("ext1".into(), Some("hello\nworld\ttab".into()));
775 let lines = footer.render(80);
776 if lines.len() >= 3 {
777 assert!(
778 !lines[2].contains('\n'),
779 "Extension status should not contain newlines"
780 );
781 assert!(
782 !lines[2].contains('\t'),
783 "Extension status should not contain tabs"
784 );
785 }
786 }
787
788 #[test]
789 fn test_footer_extension_status_removed() {
790 let mut footer = make_footer();
791 footer.set_extension_status("ext1".into(), Some("ready".into()));
792 footer.set_extension_status("ext1".into(), None);
793 let lines = footer.render(80);
794 assert!(
795 lines.len() < 3 || !lines[2].contains("ready"),
796 "Extension status should be removed"
797 );
798 }
799
800 #[test]
803 fn test_footer_handles_narrow_terminal() {
804 let mut footer = make_footer();
805 footer.set_model_supports_reasoning(true);
806 footer.set_thinking_level(Some("high".into()));
807 let usage = Usage {
808 input_tokens: Some(100000),
809 output_tokens: Some(50000),
810 cache_tokens: Some(10000),
811 };
812 footer.accumulate_usage(&usage);
813 footer.set_context(Some(45.0), 128000);
814 let lines = footer.render(10);
815 assert!(!lines.is_empty(), "Should render even at width 10");
816 for line in &lines {
817 assert!(
818 visible_width(line) <= 10,
819 "Line '{}' exceeds width 10",
820 line
821 );
822 }
823 }
824
825 #[test]
826 fn test_footer_handles_very_narrow_terminal() {
827 let footer = make_footer();
828 let lines = footer.render(3);
829 assert!(lines.is_empty(), "Should return empty at width 3");
830 }
831
832 #[test]
833 fn test_footer_stats_not_truncated_when_room() {
834 let mut footer = make_footer();
835 let usage = Usage {
836 input_tokens: Some(100),
837 output_tokens: Some(50),
838 cache_tokens: None,
839 };
840 footer.accumulate_usage(&usage);
841 let lines = footer.render(80);
842 assert!(lines[1].contains("↑100"), "Should show full token count");
843 assert!(lines[1].contains("↓50"), "Should show full token count");
844 }
845
846 #[test]
847 fn test_footer_line2_exact_width() {
848 let footer = make_footer();
849 let lines = footer.render(80);
850 for line in &lines {
851 let vw = visible_width(line);
852 assert!(vw <= 80, "Line width {} > 80", vw);
853 }
854 }
855
856 #[test]
857 fn test_footer_line2_padded_correctly() {
858 let footer = make_footer();
859 for w in [40, 60, 80, 120] {
860 let lines = footer.render(w);
861 for line in &lines {
862 let vw = visible_width(line);
863 assert!(vw <= w, "At width {}: line width {} exceeds", w, vw);
864 }
865 }
866 }
867
868 #[test]
869 fn test_footer_model_strip_prefix() {
870 let mut footer = make_footer();
871 footer.set_model("opencode_go::claude-opus");
872 let lines = footer.render(80);
873 assert!(
874 !lines[1].contains("opencode_go::"),
875 "Should strip opencode_go:: prefix"
876 );
877 assert!(
878 lines[1].contains("claude-opus"),
879 "Should show model after prefix"
880 );
881 }
882
883 #[test]
884 fn test_footer_provider_prefix_when_multiple_providers() {
885 let mut footer = make_footer();
886 footer.set_model("test-model");
887 footer.set_available_provider_count(2);
888 let lines = footer.render(80);
889 assert!(
891 lines[1].contains("(?)"),
892 "Should show provider count-based prefix"
893 );
894 }
895
896 #[test]
897 fn test_footer_experimental_indicator() {
898 let mut footer = make_footer();
899 footer.set_experimental_enabled(true);
900 let lines = footer.render(80);
901 assert!(
902 lines[1].contains("xp"),
903 "Should show experimental indicator"
904 );
905 }
906}