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