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
86    context_percent: Option<f64>,
87    context_window: u64,
88
89    // ── Model / settings state (set directly by App) ──
90    model: String,
91    model_supports_reasoning: bool,
92    thinking_level: Option<String>,
93    auto_compact: bool,
94    experimental_enabled: bool,
95
96    // ── Data provider (pull-based: git branch, extension statuses) ──
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            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    // ── 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 latest_cache_hit_rate: Option<f64> = None;
138        // Track the last assistant message's total tokens for context %.
139        // usage.input represents the FULL context sent in that request
140        // (system + accumulated history). Using the last message avoids
141        // summing all historical usage values (which would overcount).
142        let mut last_context_tokens: Option<u64> = None;
143
144        // Walk session entries summing usage from all assistant messages
145        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                // Keep updating — after the loop this holds the LAST assistant's usage
155                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        // Compute context percentage from the LAST assistant message's
172        // total tokens (not the sum of all usage), matching
173        // compaction::estimate_context_tokens approach.
174        // This avoids massive overcounting from summing all usage.input
175        // values (each represents the full context for that request).
176        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            // No assistant messages yet — show unknown
185            self.context_percent = None;
186        } else {
187            self.context_percent = None;
188        }
189
190        // Update session name from session
191        self.session_name = session.session_name().map(|s| s.to_string());
192    }
193
194    // ── Direct setters (model / settings state) ────────────────
195
196    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        // Don't recompute context_percent here — it's set correctly by
219        // refresh_from_session which uses the last assistant's usage.
220        // If set_context_window is called before refresh_from_session,
221        // context_percent stays None (shown as "?/window").
222    }
223
224    pub fn set_experimental_enabled(&mut self, enabled: bool) {
225        self.experimental_enabled = enabled;
226    }
227
228    /// Pi-style: no streaming dot indicator in footer (handled by working indicator).
229    /// Kept for compatibility with existing call sites.
230    pub fn set_streaming(&mut self, _streaming: bool) {
231        // No-op: pi footer doesn't show streaming dot
232    }
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![]; // Too narrow to show anything
240        }
241
242        let theme = &self.theme;
243
244        // ── Pull git branch and extension statuses from provider ──
245        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        // ── Line 1: pwd (git branch) • session-name ──
260        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, // pi: no padding
274        );
275
276        // ── Line 2: stats left, model right (both dimmed separately) ──
277        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        // Context percentage with color (pi-style: red > 90, yellow > 70)
298        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                    // No context window configured — don't show context at all
324                    String::new()
325                }
326            }
327        };
328        if !context_percent_str.is_empty() {
329            stats_parts.push(context_percent_str);
330        }
331
332        // Experimental features indicator (pi-style)
333        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        // Build right side: model name + thinking level (pi-style)
344        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        // Pi-style right side with thinking level indicator
354        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        // Prepend provider in parentheses if multiple providers (pi-style)
364        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        // Compute widths and layout (pi-style)
373        let mut stats_left_width = visible_width(&stats_left);
374
375        // Pi-style: if statsLeft is too wide, truncate it (no padding).
376        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                // Both fit on one line
387                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                // Try without provider prefix
394                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                // Don't fit on one line — put on separate lines
402                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        // Pi-style: dim statsLeft and remainder separately
411        let dim_stats_left = theme.fg_key(ThemeKey::Dim, &stats_left);
412        let remainder = &stats_line[stats_left.len()..]; // padding + rightSide (if combined)
413        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        // ── Extra line: model info on its own line (when stats+model don't fit together) ──
420        if let Some(model_line) = extra_model_line {
421            lines.push(theme.fg_key(ThemeKey::Dim, &model_line));
422        }
423
424        // ── Last line(s): extension statuses (sorted by key, sanitized) ──
425        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, // pi: no padding
436            );
437            if !truncated.trim().is_empty() {
438                lines.push(truncated);
439            }
440        }
441
442        lines
443    }
444
445    fn invalidate(&mut self) {
446        // No cached state to invalidate
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::tui::Component;
454
455    /// Create a Footer with a fresh provider and test-model set, for tests that
456    /// don't need git branch (most rendering scenarios).
457    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    // ── format_cwd_for_footer tests ──
469
470    #[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    // ── format_tokens tests ──
495
496    #[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    // ── sanitize_status_text tests ──
522
523    #[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    // ── Line 2 (stats/model) tests ──
532
533    #[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        // Simulate what refresh_from_session would compute
589        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    // ── Auto-compact indicator tests ──
617
618    #[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    // ── Context percent colors ──
657
658    #[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    // ── Extension status line tests ──
694
695    #[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    // ── Narrow terminal tests ──
776
777    #[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}