Skip to main content

rpi_tui/
footer.rs

1//! Footer component for TUI.
2//!
3//! Based on TypeScript implementation:
4//! `packages/coding-agent/src/modes/interactive/components/footer.ts`.
5//!
6//! Upstream's footer is a two-line status bar:
7//!
8//! ```text
9//! ~/proj/pi-rust (main) • my-session
10//! ↑12k ↓3.4k R40k W1.2k CH72.0% $0.421 63.2%/200k (auto)   [model • medium]
11//! ```
12//!
13//! * line 1 — the working directory (home-relative), the git branch, and the
14//!   session name, all dimmed;
15//! * line 2 — usage totals (`↑input ↓output RcacheRead WcacheWrite CHhit%`),
16//!   cost, and context-window usage on the left, with the model (and thinking
17//!   level) right-aligned, mirroring pi.
18//!
19//! The editor's own bottom edge provides separation, so the footer draws no
20//! extra rule.
21
22use std::any::Any;
23use std::sync::Mutex;
24
25use super::component::Component;
26use crate::theme::theme;
27use crate::utils::{truncate_to_width, visible_width};
28
29/// Usage totals aggregated across the session (native `usage-totals.ts`).
30#[derive(Debug, Clone, Copy, Default, PartialEq)]
31pub struct UsageTotals {
32    pub input: i64,
33    pub output: i64,
34    pub cache_read: i64,
35    pub cache_write: i64,
36    pub cost: f64,
37}
38
39impl UsageTotals {
40    pub fn add(&mut self, input: i64, output: i64, cache_read: i64, cache_write: i64, cost: f64) {
41        self.input += input;
42        self.output += output;
43        self.cache_read += cache_read;
44        self.cache_write += cache_write;
45        self.cost += cost;
46    }
47}
48
49/// Format token counts for compact footer display (native `formatTokens`).
50pub fn format_tokens(count: i64) -> String {
51    if count < 1_000 {
52        return count.to_string();
53    }
54    if count < 10_000 {
55        return format!("{:.1}k", count as f64 / 1_000.0);
56    }
57    if count < 1_000_000 {
58        return format!("{}k", (count as f64 / 1_000.0).round() as i64);
59    }
60    if count < 10_000_000 {
61        return format!("{:.1}M", count as f64 / 1_000_000.0);
62    }
63    format!("{}M", (count as f64 / 1_000_000.0).round() as i64)
64}
65
66/// Render a cwd home-relative (`~` prefix), matching native `formatCwdForFooter`.
67pub fn format_cwd_for_footer(cwd: &str, home: Option<&str>) -> String {
68    let Some(home) = home.filter(|h| !h.is_empty()) else {
69        return cwd.to_string();
70    };
71    let home = home.trim_end_matches(['/', '\\']);
72    if cwd == home {
73        return "~".to_string();
74    }
75    for sep in ['/', '\\'] {
76        let prefix = format!("{home}{sep}");
77        if let Some(rest) = cwd.strip_prefix(&prefix) {
78            return format!("~{sep}{rest}");
79        }
80    }
81    cwd.to_string()
82}
83
84fn sanitize(text: &str) -> String {
85    text.replace(['\r', '\n', '\t'], " ")
86        .split_whitespace()
87        .collect::<Vec<_>>()
88        .join(" ")
89}
90
91/// Footer component that displays status information.
92pub struct FooterComponent {
93    /// Current status text (transient run state).
94    status: Mutex<String>,
95    /// Extension-provided status text (e.g. `langfuse ✓ (trace sent)`), driven
96    /// by the host's `SetStatus` runtime action. Rendered after the run status.
97    extension_status: Mutex<String>,
98    /// Model name.
99    model: Mutex<String>,
100    /// Keybinding hints (shown only when there is no stats content).
101    hints: Mutex<String>,
102    /// Optional thinking-level suffix shown after the model (pi parity:
103    /// `model • thinking off` / `model • medium`). `None` → not shown.
104    thinking_level: Mutex<Option<String>>,
105    /// Working directory (unmodified).
106    cwd: Mutex<Option<String>>,
107    /// Home directory for `~`-relative rendering.
108    home: Mutex<Option<String>>,
109    /// Git branch, when inside a repository.
110    git_branch: Mutex<Option<String>>,
111    /// Session name.
112    session_name: Mutex<Option<String>>,
113    /// Aggregated usage totals.
114    usage: Mutex<UsageTotals>,
115    /// Latest prompt-cache hit rate (0..=100), when known.
116    cache_hit_rate: Mutex<Option<f64>>,
117    /// Context usage percent (0..=100), when known.
118    context_percent: Mutex<Option<f64>>,
119    /// Latest context-token count reported by an assistant response, when
120    /// known. The percent is derived from this against `context_window`; `None`
121    /// renders `?` (no post-compaction usage yet, or nothing reported).
122    context_tokens: Mutex<Option<i64>>,
123    /// Model context window in tokens (0 ⇒ unknown).
124    context_window: Mutex<i64>,
125    /// Whether auto-compaction is enabled (renders the `(auto)` suffix).
126    auto_compact: Mutex<bool>,
127}
128
129impl FooterComponent {
130    /// Create a new footer component.
131    pub fn new() -> Self {
132        Self {
133            status: Mutex::new(String::new()),
134            extension_status: Mutex::new(String::new()),
135            model: Mutex::new("claude-sonnet-5".to_string()),
136            hints: Mutex::new("Enter send · Ctrl+C abort · /help".to_string()),
137            thinking_level: Mutex::new(None),
138            cwd: Mutex::new(None),
139            home: Mutex::new(
140                std::env::var("HOME")
141                    .ok()
142                    .or_else(|| std::env::var("USERPROFILE").ok()),
143            ),
144            git_branch: Mutex::new(None),
145            session_name: Mutex::new(None),
146            usage: Mutex::new(UsageTotals::default()),
147            cache_hit_rate: Mutex::new(None),
148            context_percent: Mutex::new(None),
149            context_tokens: Mutex::new(None),
150            context_window: Mutex::new(0),
151            auto_compact: Mutex::new(true),
152        }
153    }
154
155    /// Set the status text.
156    pub fn set_status(&self, status: &str) {
157        if let Ok(mut s) = self.status.lock() {
158            *s = status.to_string();
159        }
160    }
161
162    /// Set the extension status text (empty clears it). Kept separate from
163    /// [`set_status`](Self::set_status) so a run-state transition that clears
164    /// the run status does not wipe what an extension reported.
165    pub fn set_extension_status(&self, status: &str) {
166        if let Ok(mut s) = self.extension_status.lock() {
167            *s = status.to_string();
168        }
169    }
170
171    /// Set the model name.
172    pub fn set_model(&self, model: &str) {
173        if let Ok(mut m) = self.model.lock() {
174            *m = model.to_string();
175        }
176    }
177
178    /// Set the keybinding hints.
179    pub fn set_hints(&self, hints: &str) {
180        if let Ok(mut h) = self.hints.lock() {
181            *h = hints.to_string();
182        }
183    }
184
185    /// Set the thinking-level suffix shown after the model name (pi parity).
186    pub fn set_thinking_level(&self, level: Option<&str>) {
187        if let Ok(mut t) = self.thinking_level.lock() {
188            *t = level.map(|s| s.to_string());
189        }
190    }
191
192    /// Clear the thinking-level suffix.
193    pub fn clear_thinking_level(&self) {
194        if let Ok(mut t) = self.thinking_level.lock() {
195            *t = None;
196        }
197    }
198
199    /// Set the working directory (line 1).
200    pub fn set_cwd(&self, cwd: &str) {
201        if let Ok(mut c) = self.cwd.lock() {
202            *c = Some(cwd.to_string());
203        }
204    }
205
206    /// Set the git branch shown next to the pwd (line 1).
207    pub fn set_git_branch(&self, branch: Option<&str>) {
208        if let Ok(mut b) = self.git_branch.lock() {
209            *b = branch.map(|s| s.to_string());
210        }
211    }
212
213    /// Set the session name shown after the branch (line 1).
214    pub fn set_session_name(&self, name: Option<&str>) {
215        if let Ok(mut n) = self.session_name.lock() {
216            *n = name.map(|s| s.to_string());
217        }
218    }
219
220    /// Replace the aggregated usage totals (line 2).
221    pub fn set_usage(&self, totals: UsageTotals) {
222        if let Ok(mut u) = self.usage.lock() {
223            *u = totals;
224        }
225    }
226
227    /// Add one turn's usage to the running totals (line 2).
228    pub fn add_usage(&self, input: i64, output: i64, cache_read: i64, cache_write: i64, cost: f64) {
229        if let Ok(mut u) = self.usage.lock() {
230            u.add(input, output, cache_read, cache_write, cost);
231        }
232    }
233
234    /// Set the latest cache hit rate percent (line 2), or `None` to hide.
235    pub fn set_cache_hit_rate(&self, rate: Option<f64>) {
236        if let Ok(mut r) = self.cache_hit_rate.lock() {
237            *r = rate;
238        }
239    }
240
241    /// Set the context usage percent + window (line 2).
242    pub fn set_context_usage(&self, percent: Option<f64>, window: i64) {
243        if let Ok(mut p) = self.context_percent.lock() {
244            *p = percent;
245        }
246        if let Ok(mut w) = self.context_window.lock() {
247            *w = window;
248        }
249    }
250
251    /// Record the context-token count reported by the latest assistant
252    /// response and recompute the displayed `%`. Pass `None` to reset the
253    /// badge to `?` — e.g. after a compaction, when the last usage describes
254    /// the pre-compaction context (mirrors pi's `percent: null`).
255    pub fn set_context_tokens(&self, tokens: Option<i64>) {
256        if let Ok(mut t) = self.context_tokens.lock() {
257            *t = tokens;
258        }
259        self.recompute_context_percent();
260    }
261
262    /// Set the model's context window and recompute the `%` against the
263    /// tracked token count (a model switch changes the denominator).
264    pub fn set_context_window(&self, window: i64) {
265        if let Ok(mut w) = self.context_window.lock() {
266            *w = window;
267        }
268        self.recompute_context_percent();
269    }
270
271    /// Derive the context-usage percent from the tracked tokens/window.
272    fn recompute_context_percent(&self) {
273        let window = *self.context_window.lock().unwrap();
274        let tokens = *self.context_tokens.lock().unwrap();
275        let percent = match (tokens, window) {
276            (Some(t), w) if t > 0 && w > 0 => Some(t as f64 / w as f64 * 100.0),
277            _ => None,
278        };
279        if let Ok(mut p) = self.context_percent.lock() {
280            *p = percent;
281        }
282    }
283
284    /// Set whether auto-compaction is enabled (renders `(auto)`).
285    pub fn set_auto_compact(&self, enabled: bool) {
286        if let Ok(mut a) = self.auto_compact.lock() {
287            *a = enabled;
288        }
289    }
290
291    /// Get the status text.
292    pub fn get_status(&self) -> String {
293        self.status.lock().unwrap().clone()
294    }
295
296    /// Get the model name.
297    pub fn get_model(&self) -> String {
298        self.model.lock().unwrap().clone()
299    }
300
301    /// Build the line-1 pwd/branch/session string (uncolored).
302    fn pwd_line(&self) -> String {
303        let cwd = self.cwd.lock().unwrap().clone();
304        let home = self.home.lock().unwrap().clone();
305        let branch = self.git_branch.lock().unwrap().clone();
306        let name = self.session_name.lock().unwrap().clone();
307
308        let mut parts = Vec::new();
309        if let Some(cwd) = cwd.filter(|c| !c.is_empty()) {
310            parts.push(format_cwd_for_footer(&cwd, home.as_deref()));
311        }
312        if let Some(branch) = branch.filter(|b| !b.is_empty()) {
313            if let Some(last) = parts.last_mut() {
314                *last = format!("{last} ({branch})");
315            } else {
316                parts.push(format!("({branch})"));
317            }
318        }
319        if let Some(name) = name.filter(|n| !n.is_empty()) {
320            parts.push(name);
321        }
322        parts.join(" • ")
323    }
324
325    /// Build the line-2 stats-left string; returns `(plain, styled)`.
326    fn stats_line(&self) -> (String, String) {
327        let colors = theme().colors;
328        let usage = *self.usage.lock().unwrap();
329        let cache_hit = *self.cache_hit_rate.lock().unwrap();
330        let context_percent = *self.context_percent.lock().unwrap();
331        let context_window = *self.context_window.lock().unwrap();
332        let auto = *self.auto_compact.lock().unwrap();
333
334        let mut parts: Vec<String> = Vec::new();
335        if usage.input > 0 {
336            parts.push(format!("↑{}", format_tokens(usage.input)));
337        }
338        if usage.output > 0 {
339            parts.push(format!("↓{}", format_tokens(usage.output)));
340        }
341        if usage.cache_read > 0 {
342            parts.push(format!("R{}", format_tokens(usage.cache_read)));
343        }
344        if usage.cache_write > 0 {
345            parts.push(format!("W{}", format_tokens(usage.cache_write)));
346        }
347        if usage.cache_read > 0 || usage.cache_write > 0 {
348            if let Some(rate) = cache_hit {
349                parts.push(format!("CH{rate:.1}%"));
350            }
351        }
352        if usage.cost > 0.0 {
353            parts.push(format!("${:.3}", usage.cost));
354        }
355
356        // Context usage: colorized by pressure like upstream.
357        let auto_indicator = if auto { " (auto)" } else { "" };
358        let context_display = match context_percent {
359            Some(p) => format!("{p:.1}%/{}{auto_indicator}", format_tokens(context_window)),
360            None if context_window > 0 => {
361                format!("0.0%/{}{auto_indicator}", format_tokens(context_window))
362            }
363            None => String::new(),
364        };
365        let context_styled = if context_display.is_empty() {
366            String::new()
367        } else {
368            let color = match context_percent {
369                Some(p) if p > 90.0 => colors.error,
370                Some(p) if p > 70.0 => colors.warning,
371                _ => colors.muted,
372            };
373            color.fg(&context_display)
374        };
375
376        let plain = if context_display.is_empty() {
377            parts.join(" ")
378        } else if parts.is_empty() {
379            context_display.clone()
380        } else {
381            format!("{} {}", parts.join(" "), context_display)
382        };
383
384        let mut styled = parts.join(" ");
385        if !context_styled.is_empty() {
386            if styled.is_empty() {
387                styled = context_styled;
388            } else {
389                styled = format!("{styled} {context_styled}");
390            }
391        }
392        (plain, styled)
393    }
394}
395
396impl Default for FooterComponent {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402impl Component for FooterComponent {
403    fn render(&self, width: usize) -> Vec<String> {
404        let colors = theme().colors;
405        let status = self.status.lock().unwrap().clone();
406        let extension_status = self.extension_status.lock().unwrap().clone();
407        let model = self.model.lock().unwrap().clone();
408        let hints = self.hints.lock().unwrap().clone();
409        let thinking_level = self.thinking_level.lock().unwrap().clone();
410
411        // ---- Line 1: pwd (branch) • session ----
412        let pwd = self.pwd_line();
413        let mut lines: Vec<String> = Vec::new();
414        if !pwd.is_empty() {
415            let styled = colors.dim.fg(&pwd);
416            let row = if visible_width(&styled) > width {
417                truncate_to_width(&styled, width, "…")
418            } else {
419                styled
420            };
421            lines.push(row);
422        }
423
424        // ---- Line 2: stats … model ----
425        let model_label = if !model.is_empty() {
426            let inner = match thinking_level {
427                Some(ref lvl) if lvl == "off" => format!("{model} • thinking off"),
428                Some(ref lvl) => format!("{model} • {lvl}"),
429                None => model.clone(),
430            };
431            colors.dim.fg(&inner)
432        } else {
433            String::new()
434        };
435
436        let status_word = if status.is_empty() {
437            String::new()
438        } else {
439            let sanitized = sanitize(&status);
440            let lower = sanitized.to_ascii_lowercase();
441            let color = if lower.contains("abort") {
442                colors.error
443            } else if lower.contains("work") || lower.contains("running") {
444                colors.accent
445            } else {
446                colors.muted
447            };
448            format!(" {}", color.fg(&sanitized))
449        };
450
451        // Extension status (e.g. the langfuse plugin) sits after the run status
452        // and is dimmed so it never competes with `Aborting…`/hints.
453        let extension_word = if extension_status.trim().is_empty() {
454            String::new()
455        } else {
456            format!(" {}", colors.dim.fg(&sanitize(&extension_status)))
457        };
458
459        let (stats_plain, stats_styled) = self.stats_line();
460        let has_stats = !stats_plain.is_empty();
461
462        // Left = usage stats (or keybinding hints on a fresh session) + status;
463        // right = the model, right-aligned (pi layout).
464        let left_styled = if has_stats {
465            format!("{stats_styled}{status_word}{extension_word}")
466        } else {
467            format!("{}{status_word}{extension_word}", colors.dim.fg(&hints))
468        };
469        let right_styled = model_label;
470
471        let left_w = visible_width(&left_styled);
472        let right_w = visible_width(&right_styled);
473        let min_padding = 2;
474
475        let row = if left_w == 0 && right_w == 0 {
476            String::new()
477        } else if left_w + min_padding + right_w <= width {
478            let pad = " ".repeat(width.saturating_sub(left_w + right_w));
479            format!("{left_styled}{pad}{right_styled}")
480        } else if right_w == 0 {
481            if left_w > width {
482                truncate_to_width(&left_styled, width, "…")
483            } else {
484                left_styled
485            }
486        } else if left_w + min_padding <= width {
487            let available = width - left_w - min_padding;
488            let truncated_right = truncate_to_width(&right_styled, available, "");
489            let rw = visible_width(&truncated_right);
490            let pad = " ".repeat(width.saturating_sub(left_w + rw));
491            format!("{left_styled}{pad}{truncated_right}")
492        } else {
493            // No room for the model; show stats truncated.
494            if left_w > width {
495                truncate_to_width(&left_styled, width, "…")
496            } else {
497                left_styled
498            }
499        };
500
501        lines.push(row);
502        lines
503    }
504
505    fn invalidate(&self) {
506        // Footer has no cached state
507    }
508
509    fn as_any(&self) -> &dyn Any {
510        self
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::ansi::strip_ansi;
518
519    /// The extension status (langfuse ✓ …) renders after the run status and is
520    /// independent of it: clearing the run status must not clear it.
521    #[test]
522    fn test_footer_with_extension_status() {
523        let footer = FooterComponent::new();
524        footer.set_status("Aborting…");
525        footer.set_extension_status("langfuse ✓ (trace sent)");
526        let row = strip_ansi(&footer.render(120).join(" "));
527        assert!(row.contains("Aborting…"), "run status lost: {row}");
528        assert!(
529            row.contains("langfuse ✓ (trace sent)"),
530            "ext status lost: {row}"
531        );
532
533        // A run-status transition that clears the run status keeps the
534        // extension line (the two are separate slots).
535        footer.set_status("");
536        let row = strip_ansi(&footer.render(120).join(" "));
537        assert!(!row.contains("Aborting…"));
538        assert!(
539            row.contains("langfuse ✓ (trace sent)"),
540            "ext status lost: {row}"
541        );
542
543        // Clearing the extension status removes it again.
544        footer.set_extension_status("");
545        let row = strip_ansi(&footer.render(120).join(" "));
546        assert!(!row.contains("langfuse"), "ext status not cleared: {row}");
547    }
548
549    #[test]
550    fn test_footer_basic() {
551        let footer = FooterComponent::new();
552        let lines = footer.render(80);
553        // No cwd set ⇒ single stats row.
554        assert_eq!(lines.len(), 1);
555        assert!(strip_ansi(&lines[0]).contains("Ctrl+C"));
556    }
557
558    #[test]
559    fn test_footer_with_status() {
560        let footer = FooterComponent::new();
561        footer.set_status("Working…");
562        footer.set_model("claude-sonnet-5");
563        let lines = footer.render(80);
564        let row = strip_ansi(&lines[0]);
565        assert!(row.contains("Working…"));
566        assert!(row.contains("claude-sonnet-5"));
567    }
568
569    #[test]
570    fn test_footer_truncate() {
571        let footer = FooterComponent::new();
572        footer.set_status("This is a very long status message that should be truncated");
573        footer.set_model("claude-opus-4");
574        footer.set_hints("Ctrl+C: Exit | Shift+Enter: Send | Ctrl+L: Clear | More hints here");
575        let lines = footer.render(40);
576        for line in &lines {
577            assert!(
578                visible_width(line) <= 40,
579                "row too wide: {}",
580                visible_width(line)
581            );
582        }
583    }
584
585    #[test]
586    fn test_footer_thinking_level_suffix() {
587        let footer = FooterComponent::new();
588        footer.set_model("test-model");
589        footer.set_thinking_level(Some("medium"));
590        let row = strip_ansi(&footer.render(80)[0]);
591        assert!(row.contains("test-model • medium"), "suffix missing: {row}");
592        assert!(!row.contains("[test-model"), "unexpected brackets: {row}");
593    }
594
595    #[test]
596    fn test_two_line_with_cwd_and_usage() {
597        let footer = FooterComponent::new();
598        footer.set_cwd("/home/user/proj");
599        footer.set_git_branch(Some("main"));
600        footer.set_usage(UsageTotals {
601            input: 12_300,
602            output: 3_400,
603            cache_read: 40_000,
604            cache_write: 1_200,
605            cost: 0.421,
606        });
607        footer.set_cache_hit_rate(Some(72.0));
608        footer.set_context_usage(Some(63.2), 200_000);
609        let lines = footer.render(120);
610        assert_eq!(lines.len(), 2, "expected pwd + stats rows");
611        let pwd = strip_ansi(&lines[0]);
612        assert!(pwd.contains("(main)"), "pwd line: {pwd}");
613        let stats = strip_ansi(&lines[1]);
614        assert!(stats.contains("↑12k"), "stats: {stats}");
615        assert!(stats.contains("$0.421"), "stats: {stats}");
616        assert!(stats.contains("63.2%/200k"), "stats: {stats}");
617        assert!(stats.contains("(auto)"), "stats: {stats}");
618    }
619
620    #[test]
621    fn context_badge_derives_percent_from_reported_tokens() {
622        let footer = FooterComponent::new();
623        footer.set_context_window(512_000);
624        // No response yet → shows 0.0% initially.
625        let stats = strip_ansi(&footer.render(120).join("\n"));
626        assert!(stats.contains("0.0%/512k"), "stats: {stats}");
627
628        // A reported token count fills in the percent against the window.
629        footer.set_context_tokens(Some(256_000));
630        let stats = strip_ansi(&footer.render(120).join("\n"));
631        assert!(stats.contains("50.0%/512k"), "stats: {stats}");
632
633        // Switching the model rescales the same token count.
634        footer.set_context_window(128_000);
635        let stats = strip_ansi(&footer.render(120).join("\n"));
636        assert!(stats.contains("200.0%/128k"), "stats: {stats}");
637
638        // `None` (e.g. right after a compaction) resets to 0.0%.
639        footer.set_context_tokens(None);
640        let stats = strip_ansi(&footer.render(120).join("\n"));
641        assert!(stats.contains("0.0%/128k"), "stats: {stats}");
642    }
643
644    #[test]
645    fn format_tokens_compacts() {
646        assert_eq!(format_tokens(999), "999");
647        assert_eq!(format_tokens(1_500), "1.5k");
648        assert_eq!(format_tokens(40_000), "40k");
649        assert_eq!(format_tokens(2_500_000), "2.5M");
650    }
651
652    #[test]
653    fn cwd_home_relative() {
654        assert_eq!(
655            format_cwd_for_footer("/home/u/proj", Some("/home/u")),
656            "~/proj"
657        );
658        assert_eq!(format_cwd_for_footer("/home/u", Some("/home/u")), "~");
659        assert_eq!(format_cwd_for_footer("/other", Some("/home/u")), "/other");
660        assert_eq!(format_cwd_for_footer("/x", None), "/x");
661    }
662}