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 total_cost: f64,
86
87 context_percent: Option<f64>,
88 context_window: u64,
89
90 model: String,
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 total_cost: 0.0,
114 context_percent: None,
115 context_window: 0,
116 auto_compact: true,
117 model: String::new(),
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 total_cost: f64 = 0.0;
138 let mut latest_cache_hit_rate: Option<f64> = None;
139 let mut last_context_tokens: Option<u64> = None;
144
145 for entry in session.get_entries() {
149 if let crate::agent::session::SessionEntry::Message(msg_entry) = entry
150 && let Some(yoagent::types::Message::Assistant { usage, .. }) =
151 msg_entry.message.as_llm()
152 {
153 total_input += usage.input;
154 total_output += usage.output;
155 total_cache_read += usage.cache_read;
156 total_cache_write += usage.cache_write;
157 last_context_tokens = Some(usage.input + usage.output + usage.cache_read);
159
160 let total_prompt = usage.input + usage.cache_read + usage.cache_write;
161 if total_prompt > 0 {
162 latest_cache_hit_rate =
163 Some((usage.cache_read as f64 / total_prompt as f64) * 100.0);
164 }
165
166 total_cost += msg_entry.cost;
168 }
169 }
170
171 self.total_input = total_input;
172 self.total_output = total_output;
173 self.total_cache_read = total_cache_read;
174 self.total_cache_write = total_cache_write;
175 self.total_cost = total_cost;
176 self.latest_cache_hit_rate = latest_cache_hit_rate;
177
178 if let Some(ctx_tokens) = last_context_tokens {
184 if self.context_window > 0 {
185 self.context_percent =
186 Some((ctx_tokens as f64 / self.context_window as f64) * 100.0);
187 } else {
188 self.context_percent = None;
189 }
190 } else if self.context_window > 0 {
191 self.context_percent = None;
193 } else {
194 self.context_percent = None;
195 }
196
197 self.session_name = session.session_name().map(|s| s.to_string());
199
200 self.provider.borrow_mut().refresh_from_session(session);
202
203 {
205 let prov = self.provider.borrow();
206 if let Some(mid) = prov.get_model_id() {
207 self.model = mid.to_string();
208 }
209 }
210
211 for entry in session.get_entries() {
213 if let crate::agent::session::SessionEntry::ThinkingLevelChange(e) = entry {
214 self.thinking_level = Some(e.thinking_level.clone());
215 }
216 }
217 }
218
219 pub fn set_cwd(&mut self, cwd: impl Into<String>) {
222 self.cwd = cwd.into();
223 }
224
225 pub fn set_model(&mut self, model: impl Into<String>) {
226 self.model = model.into();
227 }
228
229 pub fn set_thinking_level(&mut self, level: Option<String>) {
230 self.thinking_level = level;
231 }
232
233 pub fn set_auto_compact(&mut self, enabled: bool) {
234 self.auto_compact = enabled;
235 }
236
237 pub fn set_context_window(&mut self, window: u64) {
238 self.context_window = window;
239 }
244
245 pub fn set_experimental_enabled(&mut self, enabled: bool) {
246 self.experimental_enabled = enabled;
247 }
248
249 pub fn set_streaming(&mut self, _streaming: bool) {
252 }
254}
255
256impl crate::tui::Component for Footer {
257 fn render(&mut self, width: usize) -> Vec<String> {
258 let w = width;
259 if w < 4 {
260 return vec![]; }
262
263 let theme = &self.theme;
264
265 let git_branch = self
267 .provider
268 .borrow()
269 .get_git_branch()
270 .map(|s| s.to_string());
271
272 let extension_statuses: Vec<(String, String)> = self
273 .provider
274 .borrow()
275 .get_extension_statuses()
276 .iter()
277 .map(|(k, v)| (k.clone(), v.clone()))
278 .collect();
279
280 let home = std::env::var("HOME").ok();
282 let mut pwd = format_cwd_for_footer(&self.cwd, home.as_deref());
283
284 if let Some(ref branch) = git_branch {
285 pwd = format!("{} ({})", pwd, branch);
286 }
287 if let Some(ref name) = self.session_name {
288 pwd = format!("{} • {}", pwd, name);
289 }
290 let pwd_line = truncate_to_width(
291 &theme.fg_key(ThemeKey::Dim, &pwd),
292 w,
293 &theme.fg_key(ThemeKey::Dim, "..."),
294 false, );
296
297 let mut stats_parts: Vec<String> = Vec::new();
299
300 if self.total_input > 0 {
301 stats_parts.push(format!("↑{}", format_tokens(self.total_input)));
302 }
303 if self.total_output > 0 {
304 stats_parts.push(format!("↓{}", format_tokens(self.total_output)));
305 }
306 if self.total_cache_read > 0 {
307 stats_parts.push(format!("R{}", format_tokens(self.total_cache_read)));
308 }
309 if self.total_cache_write > 0 {
310 stats_parts.push(format!("W{}", format_tokens(self.total_cache_write)));
311 }
312 if (self.total_cache_read > 0 || self.total_cache_write > 0)
313 && let Some(hit_rate) = self.latest_cache_hit_rate
314 {
315 stats_parts.push(format!("CH{:.1}%", hit_rate));
316 }
317
318 if self.total_cost > 0.0 {
320 stats_parts.push(format!("${:.3}", self.total_cost));
321 }
322
323 let context_percent_str = match self.context_percent {
325 Some(p) => {
326 let window_str = format_tokens(self.context_window);
327 let display = if self.auto_compact {
328 format!("{:.1}%/{} (auto)", p, window_str)
329 } else {
330 format!("{:.1}%/{}", p, window_str)
331 };
332 if p > 90.0 {
333 theme.fg_key(ThemeKey::Error, &display)
334 } else if p > 70.0 {
335 theme.fg_key(ThemeKey::Warning, &display)
336 } else {
337 display
338 }
339 }
340 None => {
341 let window_str = format_tokens(self.context_window);
342 if self.context_window > 0 {
343 if self.auto_compact {
344 format!("?/{} (auto)", window_str)
345 } else {
346 format!("?/{}", window_str)
347 }
348 } else {
349 String::new()
351 }
352 }
353 };
354 if !context_percent_str.is_empty() {
355 stats_parts.push(context_percent_str);
356 }
357
358 if self.experimental_enabled {
360 stats_parts.push(format!(
361 "{} {}",
362 theme.fg_key(ThemeKey::Dim, "•"),
363 theme.bold(&theme.fg_key(ThemeKey::Warning, "xp"))
364 ));
365 }
366
367 let mut stats_left = stats_parts.join(" ");
368
369 let model_name = if self.model.is_empty() {
371 "no-model".to_string()
372 } else {
373 self.model.clone()
374 };
375
376 let right_side_without_provider = match &self.thinking_level {
378 Some(level) if level != "off" => format!("{} • {}", model_name, level),
379 Some(_) => format!("{} • thinking off", model_name),
380 None => model_name.clone(),
381 };
382
383 let pname = self
385 .provider
386 .borrow()
387 .get_model_provider()
388 .map(|s| s.to_string());
389 let right_side = if let Some(ref pname) = pname {
390 format!("({}) {}", pname, right_side_without_provider)
391 } else {
392 right_side_without_provider.clone()
393 };
394
395 let mut stats_left_width = visible_width(&stats_left);
397
398 if stats_left_width > w {
400 stats_left = truncate_to_width(&stats_left, w, "...", false);
401 stats_left_width = visible_width(&stats_left);
402 }
403
404 let right_side_width = visible_width(&right_side);
405 let min_padding: usize = 2;
406
407 let (stats_line, extra_model_line) = if stats_left_width + min_padding + right_side_width
408 <= w
409 {
410 let padding = " ".repeat(w - stats_left_width - right_side_width);
412 (format!("{}{}{}", stats_left, padding, right_side), None)
413 } else if pname.is_some() {
414 let without_provider_width = visible_width(&right_side_without_provider);
416 if stats_left_width + min_padding + without_provider_width <= w {
417 let padding = " ".repeat(w - stats_left_width - without_provider_width);
418 (
419 format!("{}{}{}", stats_left, padding, right_side_without_provider),
420 None,
421 )
422 } else {
423 let model_for_line = if right_side_width > w {
425 truncate_to_width(&right_side, w, &theme.fg_key(ThemeKey::Dim, "..."), false)
426 } else {
427 right_side.clone()
428 };
429 (stats_left.clone(), Some(model_for_line))
430 }
431 } else {
432 let model_for_line = if right_side_width > w {
434 truncate_to_width(&right_side, w, &theme.fg_key(ThemeKey::Dim, "..."), false)
435 } else {
436 right_side.clone()
437 };
438 (stats_left.clone(), Some(model_for_line))
439 };
440
441 let dim_stats_left = theme.fg_key(ThemeKey::Dim, &stats_left);
443 let remainder = &stats_line[stats_left.len()..]; let dim_remainder = theme.fg_key(ThemeKey::Dim, remainder);
445
446 let stats_line_formatted = format!("{}{}", dim_stats_left, dim_remainder);
447
448 let mut lines = vec![pwd_line, stats_line_formatted];
449
450 if let Some(model_line) = extra_model_line {
452 lines.push(theme.fg_key(ThemeKey::Dim, &model_line));
453 }
454
455 if !extension_statuses.is_empty() {
457 let status_text: Vec<String> = extension_statuses
458 .iter()
459 .map(|(_, text)| sanitize_status_text(text))
460 .collect();
461 let status_line = status_text.join(" ");
462 let truncated = truncate_to_width(
463 &status_line,
464 w,
465 &theme.fg_key(ThemeKey::Dim, "..."),
466 false, );
468 if !truncated.trim().is_empty() {
469 lines.push(truncated);
470 }
471 }
472
473 lines
474 }
475
476 fn invalidate(&mut self) {
477 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::tui::Component;
485
486 fn make_footer() -> Footer {
489 crate::agent::ui::theme::init_theme(Some("dark"), false);
490 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
491 "/home/user/project".into(),
492 )));
493 provider.borrow_mut().set_test_git_branch(Some("main"));
494 let mut footer = Footer::new("/home/user/project", provider);
495 footer.set_model("test-model");
496 footer
497 }
498
499 #[test]
502 fn test_format_cwd_home() {
503 let result = format_cwd_for_footer("/home/user/project", Some("/home/user"));
504 assert_eq!(result, "~/project");
505 }
506
507 #[test]
508 fn test_format_cwd_home_exact() {
509 let result = format_cwd_for_footer("/home/user", Some("/home/user"));
510 assert_eq!(result, "~");
511 }
512
513 #[test]
514 fn test_format_cwd_outside_home() {
515 let result = format_cwd_for_footer("/opt/app", Some("/home/user"));
516 assert_eq!(result, "/opt/app");
517 }
518
519 #[test]
520 fn test_format_cwd_no_home() {
521 let result = format_cwd_for_footer("/some/path", None::<&str>);
522 assert_eq!(result, "/some/path");
523 }
524
525 #[test]
528 fn test_format_tokens_under_1k() {
529 assert_eq!(format_tokens(500), "500");
530 }
531
532 #[test]
533 fn test_format_tokens_1k_to_10k() {
534 assert_eq!(format_tokens(5500), "5.5k");
535 }
536
537 #[test]
538 fn test_format_tokens_10k_to_1m() {
539 assert_eq!(format_tokens(55500), "56k");
540 }
541
542 #[test]
543 fn test_format_tokens_1m_to_10m() {
544 assert_eq!(format_tokens(5_500_000), "5.5M");
545 }
546
547 #[test]
548 fn test_format_tokens_over_10m() {
549 assert_eq!(format_tokens(55_000_000), "55M");
550 }
551
552 #[test]
555 fn test_sanitize_status() {
556 assert_eq!(sanitize_status_text("hello\nworld"), "hello world");
557 assert_eq!(sanitize_status_text("hello\tworld"), "hello world");
558 assert_eq!(sanitize_status_text("hello\r\nworld"), "hello world");
559 assert_eq!(sanitize_status_text(" spaced "), "spaced");
560 }
561
562 #[test]
565 fn test_footer_shows_model() {
566 let mut footer = make_footer();
567 let lines = footer.render(80);
568 assert!(lines[1].contains("test-model"), "Should show model name");
569 }
570
571 #[test]
572 fn test_footer_shows_no_model() {
573 let provider = Rc::new(RefCell::new(FooterDataProvider::new("/path".into())));
574 let mut footer = Footer::new("/path", provider);
575 footer.set_model("");
576 let lines = footer.render(80);
577 assert!(
578 lines[1].contains("no-model"),
579 "Should show 'no-model' when model not set"
580 );
581 }
582
583 #[test]
584 fn test_footer_shows_thinking_level() {
585 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
586 "/home/user/project".into(),
587 )));
588 let mut footer = Footer::new("/home/user/project", provider);
589 footer.set_model("test-model");
590 footer.set_thinking_level(Some("high".into()));
591 let lines = footer.render(80);
592 assert!(lines[1].contains("high"), "Should show thinking level");
593 }
594
595 #[test]
596 fn test_footer_thinking_off_with_reasoning() {
597 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
598 "/home/user/project".into(),
599 )));
600 let mut footer = Footer::new("/home/user/project", provider);
601 footer.set_model("test-model");
602 footer.set_thinking_level(Some("off".into()));
603 let lines = footer.render(80);
604 assert!(
605 lines[1].contains("thinking off"),
606 "Should show 'thinking off' when reasoning model has level off"
607 );
608 }
609
610 #[test]
611 fn test_footer_shows_token_usage() {
612 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
613 "/home/user/project".into(),
614 )));
615 let mut footer = Footer::new("/home/user/project", provider);
616 footer.set_model("test-model");
617 footer.total_input = 1500;
619 footer.total_output = 500;
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_shows_cache_hit_rate() {
627 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
628 "/home/user/project".into(),
629 )));
630 let mut footer = Footer::new("/home/user/project", provider);
631 footer.set_model("test-model");
632 footer.total_cache_read = 200;
633 footer.latest_cache_hit_rate = Some(16.7);
634 let lines = footer.render(80);
635 assert!(
636 lines[1].contains("CH"),
637 "Should show cache hit rate when cache tokens present"
638 );
639 assert!(
640 lines[1].contains("CH16.7%"),
641 "Should show correct cache hit rate"
642 );
643 }
644
645 #[test]
648 fn test_footer_shows_auto_compact_next_to_context() {
649 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
650 "/home/user/project".into(),
651 )));
652 let mut footer = Footer::new("/home/user/project", provider);
653 footer.set_model("test-model");
654 footer.set_auto_compact(true);
655 footer.context_window = 64000;
656 footer.context_percent = Some(50.0);
657 let lines = footer.render(80);
658 assert!(
659 lines[1].contains("(auto)"),
660 "Should show (auto) next to context percentage"
661 );
662 assert!(
663 lines[1].contains("50.0%/64k (auto)"),
664 "Should show context percent with auto compact"
665 );
666 }
667
668 #[test]
669 fn test_footer_hides_auto_compact_when_disabled() {
670 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
671 "/home/user/project".into(),
672 )));
673 let mut footer = Footer::new("/home/user/project", provider);
674 footer.set_model("test-model");
675 footer.set_auto_compact(false);
676 footer.context_window = 128000;
677 footer.context_percent = Some(50.0);
678 let lines = footer.render(80);
679 assert!(
680 !lines[1].contains("(auto)"),
681 "Should NOT show (auto) when disabled"
682 );
683 }
684
685 #[test]
688 fn test_footer_context_percent_high() {
689 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
690 "/home/user/project".into(),
691 )));
692 let mut footer = Footer::new("/home/user/project", provider);
693 footer.set_model("test-model");
694 footer.context_window = 64000;
695 footer.context_percent = Some(95.0);
696 let lines = footer.render(80);
697 assert!(lines[1].contains("95"), "Should show context percent");
698 assert!(
699 lines[1].contains("64k"),
700 "Should show formatted window size"
701 );
702 assert!(
703 lines[1].contains("\x1b[38;2;"),
704 "Should have ANSI color for high context"
705 );
706 }
707
708 #[test]
709 fn test_footer_context_without_percent() {
710 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
711 "/home/user/project".into(),
712 )));
713 let mut footer = Footer::new("/home/user/project", provider);
714 footer.set_model("test-model");
715 footer.context_window = 64000;
716 footer.context_percent = None;
717 let lines = footer.render(80);
718 assert!(lines[1].contains("?"), "Should show unknown context");
719 assert!(lines[1].contains("64k"), "Should show context window size");
720 }
721
722 #[test]
725 fn test_footer_shows_extension_statuses() {
726 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
727 "/home/user/project".into(),
728 )));
729 provider
730 .borrow_mut()
731 .set_extension_status("ext1", Some("ready"));
732 let mut footer = Footer::new("/home/user/project", provider);
733 footer.set_model("test-model");
734 let lines = footer.render(80);
735 assert!(lines.len() >= 3, "Should have 3 lines");
736 assert!(lines[2].contains("ready"), "Should show extension status");
737 }
738
739 #[test]
740 fn test_footer_extension_status_sorted() {
741 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
742 "/home/user/project".into(),
743 )));
744 provider
745 .borrow_mut()
746 .set_extension_status("z_last", Some("last"));
747 provider
748 .borrow_mut()
749 .set_extension_status("a_first", Some("first"));
750 let mut footer = Footer::new("/home/user/project", provider);
751 footer.set_model("test-model");
752 let lines = footer.render(80);
753 if lines.len() >= 3 {
754 let first_idx = lines[2].find("first");
755 let last_idx = lines[2].find("last");
756 assert!(
757 first_idx < last_idx,
758 "Extension statuses should be sorted by key"
759 );
760 }
761 }
762
763 #[test]
764 fn test_footer_extension_status_sanitized() {
765 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
766 "/home/user/project".into(),
767 )));
768 provider
769 .borrow_mut()
770 .set_extension_status("ext1", Some("hello\nworld\ttab"));
771 let mut footer = Footer::new("/home/user/project", provider);
772 footer.set_model("test-model");
773 let lines = footer.render(80);
774 if lines.len() >= 3 {
775 assert!(
776 !lines[2].contains('\n'),
777 "Extension status should not contain newlines"
778 );
779 assert!(
780 !lines[2].contains('\t'),
781 "Extension status should not contain tabs"
782 );
783 }
784 }
785
786 #[test]
787 fn test_footer_extension_status_removed() {
788 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
789 "/home/user/project".into(),
790 )));
791 provider
792 .borrow_mut()
793 .set_extension_status("ext1", Some("ready"));
794 provider.borrow_mut().set_extension_status("ext1", None);
795 let mut footer = Footer::new("/home/user/project", provider);
796 footer.set_model("test-model");
797 let lines = footer.render(80);
798 assert!(
799 lines.len() < 3 || !lines[2].contains("ready"),
800 "Extension status should be removed"
801 );
802 }
803
804 #[test]
807 fn test_footer_handles_narrow_terminal() {
808 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
809 "/home/user/project".into(),
810 )));
811 let mut footer = Footer::new("/home/user/project", provider);
812 footer.set_model("test-model");
813 footer.set_thinking_level(Some("high".into()));
814 footer.total_input = 100000;
815 footer.total_output = 50000;
816 footer.total_cache_read = 10000;
817 footer.context_window = 128000;
818 footer.context_percent = Some(12.0);
819 let lines = footer.render(10);
820 assert!(!lines.is_empty(), "Should render even at width 10");
821 for line in &lines {
822 assert!(
823 visible_width(line) <= 10,
824 "Line '{}' exceeds width 10",
825 line
826 );
827 }
828 }
829
830 #[test]
831 fn test_footer_handles_very_narrow_terminal() {
832 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
833 "/home/user/project".into(),
834 )));
835 let mut footer = Footer::new("/home/user/project", provider);
836 let lines = footer.render(3);
837 assert!(lines.is_empty(), "Should return empty at width 3");
838 }
839
840 #[test]
841 fn test_footer_line2_exact_width() {
842 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
843 "/home/user/project".into(),
844 )));
845 let mut footer = Footer::new("/home/user/project", provider);
846 footer.set_model("test-model");
847 let lines = footer.render(80);
848 for line in &lines {
849 let vw = visible_width(line);
850 assert!(vw <= 80, "Line width {} > 80", vw);
851 }
852 }
853
854 #[test]
855 fn test_footer_line2_padded_correctly() {
856 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
857 "/home/user/project".into(),
858 )));
859 let mut footer = Footer::new("/home/user/project", provider);
860 footer.set_model("test-model");
861 for w in [40, 60, 80, 120] {
862 let lines = footer.render(w);
863 for line in &lines {
864 let vw = visible_width(line);
865 assert!(vw <= w, "At width {}: line width {} exceeds", w, vw);
866 }
867 }
868 }
869
870 #[test]
871 fn test_footer_model_with_provider_prefix() {
872 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
873 "/home/user/project".into(),
874 )));
875 let mut footer = Footer::new("/home/user/project", provider);
876 footer.set_model("opencode-go/deepseek-v4-flash");
877 let lines = footer.render(80);
878 assert!(
879 lines[1].contains("opencode-go/deepseek-v4-flash"),
880 "Should show provider/model format"
881 );
882 }
883
884 #[test]
885 fn test_footer_provider_prefix_when_multiple_providers() {
886 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
887 "/home/user/project".into(),
888 )));
889 provider.borrow_mut().set_available_provider_count(2);
890 provider
891 .borrow_mut()
892 .set_test_model_provider(Some("opencode-go"));
893 let mut footer = Footer::new("/home/user/project", provider);
894 footer.set_model("test-model");
895 let lines = footer.render(80);
896 assert!(
897 lines[1].contains("(opencode-go)"),
898 "Should show provider name in parentheses"
899 );
900 }
901
902 #[test]
903 fn test_footer_experimental_indicator() {
904 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
905 "/home/user/project".into(),
906 )));
907 let mut footer = Footer::new("/home/user/project", provider);
908 footer.set_model("test-model");
909 footer.set_experimental_enabled(true);
910 let lines = footer.render(80);
911 assert!(
912 lines[1].contains("xp"),
913 "Should show experimental indicator"
914 );
915 }
916
917 #[test]
918 fn test_pwd_line_not_padded() {
919 let provider = Rc::new(RefCell::new(FooterDataProvider::new("/home/user".into())));
920 let mut footer = Footer::new("/home/user", provider);
921 footer.set_model("test-model");
922 let lines = footer.render(80);
923 assert!(visible_width(&lines[0]) <= 80, "Pwd line exceeds width");
924 assert!(
925 visible_width(&lines[0]) < 80,
926 "Pwd line should not be padded to full width (pi behavior)"
927 );
928 }
929
930 #[test]
931 fn test_extension_line_not_padded() {
932 let provider = Rc::new(RefCell::new(FooterDataProvider::new(
933 "/home/user/project".into(),
934 )));
935 provider
936 .borrow_mut()
937 .set_extension_status("ext1", Some("short"));
938 let mut footer = Footer::new("/home/user/project", provider);
939 footer.set_model("test-model");
940 let lines = footer.render(80);
941 if lines.len() >= 3 {
942 assert!(
943 visible_width(&lines[2]) <= 80,
944 "Extension line exceeds width"
945 );
946 assert!(
947 visible_width(&lines[2]) < 80,
948 "Extension line should not be padded to full width (pi behavior)"
949 );
950 }
951 }
952}