Skip to main content

rab/agent/ui/
footer.rs

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
10// ── Helpers matching pi's footer.ts ──────────────────────────────
11
12/// Sanitize text for display in a single-line status.
13/// Removes newlines, tabs, carriage returns, and other control characters.
14fn 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
22/// Format token count for compact footer display (pi-style).
23pub 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
39/// Format cwd for footer display (pi-style `formatCwdForFooter`).
40/// Resolves cwd relative to home directory, using `~` prefix.
41///
42/// Matches pi which uses `path.resolve()` + `path.relative()` to handle
43/// symlinks, `..`, and edge cases correctly.
44pub 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    // Canonicalize both paths to resolve symlinks and `..` (pi uses `resolve`).
51    // Fall back to raw paths if canonicalize fails (e.g. non-existent cwd).
52    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
63// ── Footer Component ─────────────────────────────────────────────
64
65/// Pi-style footer: 2-3 lines with dim styling.
66/// Matches pi's `FooterComponent` in `footer.ts` exactly.
67///
68/// Architecture (pull-based):
69/// - Renders cached usage/context stats refreshed at **turn end** via
70///   `refresh_from_session()`, not on every render frame.
71/// - Git branch and extension statuses are **pulled** from the
72///   `FooterDataProvider` each render, not pushed from the App.
73/// - Model/settings state (model name, thinking level, auto-compact)
74///   is set directly by the App (these change infrequently mid-session).
75pub struct Footer {
76    cwd: String,
77    session_name: Option<String>,
78
79    // ── Cached usage stats — refreshed at turn end via refresh_from_session() ──
80    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 / settings state (set directly by App) ──
91    model: String,
92    thinking_level: Option<String>,
93    auto_compact: bool,
94    experimental_enabled: bool,
95
96    // ── Data provider (pull-based: git branch, extension statuses, model/provider) ──
97    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    // ── Pull-based refresh (called at turn end) ─────────────────
126
127    /// Refresh cached usage/context stats from session entries.
128    /// Called at turn end (AgentEnd) — NOT on every render frame.
129    ///
130    /// Matches pi's `render()` scanning `sessionManager.getEntries()`,
131    /// but the scan happens once per turn instead of once per frame.
132    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        // Track the last assistant message's total tokens for context %.
140        // usage.input represents the FULL context sent in that request
141        // (system + accumulated history). Using the last message avoids
142        // summing all historical usage values (which would overcount).
143        let mut last_context_tokens: Option<u64> = None;
144
145        // Walk session entries summing usage and cost from all assistant messages.
146        // Cost is pre-computed per message and stored in the session (pi-style),
147        // so we just sum the stored values — no need to re-resolve models.
148        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                // Keep updating — after the loop this holds the LAST assistant's usage
158                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                // Sum pre-computed cost (pi-style: stored per message at creation time)
167                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        // Compute context percentage from the LAST assistant message's
179        // total tokens (not the sum of all usage), matching
180        // compaction::estimate_context_tokens approach.
181        // This avoids massive overcounting from summing all usage.input
182        // values (each represents the full context for that request).
183        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            // No assistant messages yet — show unknown
192            self.context_percent = None;
193        } else {
194            self.context_percent = None;
195        }
196
197        // Update session name from session
198        self.session_name = session.session_name().map(|s| s.to_string());
199
200        // Pull model/provider/thinking from the latest session changes
201        self.provider.borrow_mut().refresh_from_session(session);
202
203        // Update footer fields from provider (round-trip through session)
204        {
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        // Extract latest thinking level from session
212        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    // ── Direct setters (model / settings state) ────────────────
220
221    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        // Don't recompute context_percent here — it's set correctly by
240        // refresh_from_session which uses the last assistant's usage.
241        // If set_context_window is called before refresh_from_session,
242        // context_percent stays None (shown as "?/window").
243    }
244
245    pub fn set_experimental_enabled(&mut self, enabled: bool) {
246        self.experimental_enabled = enabled;
247    }
248
249    /// Pi-style: no streaming dot indicator in footer (handled by working indicator).
250    /// Kept for compatibility with existing call sites.
251    pub fn set_streaming(&mut self, _streaming: bool) {
252        // No-op: pi footer doesn't show streaming dot
253    }
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![]; // Too narrow to show anything
261        }
262
263        let theme = &self.theme;
264
265        // ── Pull git branch and extension statuses from provider ──
266        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        // ── Line 1: pwd (git branch) • session-name ──
281        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, // pi: no padding
295        );
296
297        // ── Line 2: stats left, model right (both dimmed separately) ──
298        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        // Cost display (pi-style: $X.XXX or $X.XXX (sub))
319        if self.total_cost > 0.0 {
320            stats_parts.push(format!("${:.3}", self.total_cost));
321        }
322
323        // Context percentage with color (pi-style: red > 90, yellow > 70)
324        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                    // No context window configured — don't show context at all
350                    String::new()
351                }
352            }
353        };
354        if !context_percent_str.is_empty() {
355            stats_parts.push(context_percent_str);
356        }
357
358        // Experimental features indicator (pi-style)
359        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        // Build right side: model name + thinking level (pi-style)
370        let model_name = if self.model.is_empty() {
371            "no-model".to_string()
372        } else {
373            self.model.clone()
374        };
375
376        // Always show thinking level if available
377        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        // Always prepend provider in parentheses if available
384        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        // Compute widths and layout (pi-style)
396        let mut stats_left_width = visible_width(&stats_left);
397
398        // Pi-style: if statsLeft is too wide, truncate it (no padding).
399        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            // Both fit on one line
411            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            // Try without provider prefix
415            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                // Don't fit on one line — put on separate lines
424                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            // Don't fit on one line — put on separate lines
433            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        // Pi-style: dim statsLeft and remainder separately
442        let dim_stats_left = theme.fg_key(ThemeKey::Dim, &stats_left);
443        let remainder = &stats_line[stats_left.len()..]; // padding + rightSide (if combined)
444        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        // ── Extra line: model info on its own line (when stats+model don't fit together) ──
451        if let Some(model_line) = extra_model_line {
452            lines.push(theme.fg_key(ThemeKey::Dim, &model_line));
453        }
454
455        // ── Last line(s): extension statuses (sorted by key, sanitized) ──
456        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, // pi: no padding
467            );
468            if !truncated.trim().is_empty() {
469                lines.push(truncated);
470            }
471        }
472
473        lines
474    }
475
476    fn invalidate(&mut self) {
477        // No cached state to invalidate
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::tui::Component;
485
486    /// Create a Footer with a fresh provider and test-model set, for tests that
487    /// don't need git branch (most rendering scenarios).
488    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    // ── format_cwd_for_footer tests ──
500
501    #[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    // ── format_tokens tests ──
526
527    #[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    // ── sanitize_status_text tests ──
553
554    #[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    // ── Line 2 (stats/model) tests ──
563
564    #[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        // Simulate what refresh_from_session would compute
618        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    // ── Auto-compact indicator tests ──
646
647    #[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    // ── Context percent colors ──
686
687    #[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    // ── Extension status line tests ──
723
724    #[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    // ── Narrow terminal tests ──
805
806    #[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}