Skip to main content

rab/agent/ui/
footer.rs

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