1use 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#[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
49pub 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
66pub 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
91pub struct FooterComponent {
93 status: Mutex<String>,
95 extension_status: Mutex<String>,
98 model: Mutex<String>,
100 hints: Mutex<String>,
102 thinking_level: Mutex<Option<String>>,
105 cwd: Mutex<Option<String>>,
107 home: Mutex<Option<String>>,
109 git_branch: Mutex<Option<String>>,
111 session_name: Mutex<Option<String>>,
113 usage: Mutex<UsageTotals>,
115 cache_hit_rate: Mutex<Option<f64>>,
117 context_percent: Mutex<Option<f64>>,
119 context_tokens: Mutex<Option<i64>>,
123 context_window: Mutex<i64>,
125 auto_compact: Mutex<bool>,
127}
128
129impl FooterComponent {
130 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 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 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 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 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 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 pub fn clear_thinking_level(&self) {
194 if let Ok(mut t) = self.thinking_level.lock() {
195 *t = None;
196 }
197 }
198
199 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 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 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 pub fn set_usage(&self, totals: UsageTotals) {
222 if let Ok(mut u) = self.usage.lock() {
223 *u = totals;
224 }
225 }
226
227 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 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 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 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 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 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 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 pub fn get_status(&self) -> String {
293 self.status.lock().unwrap().clone()
294 }
295
296 pub fn get_model(&self) -> String {
298 self.model.lock().unwrap().clone()
299 }
300
301 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 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 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 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 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 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 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 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 }
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 #[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 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 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 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 let stats = strip_ansi(&footer.render(120).join("\n"));
626 assert!(stats.contains("0.0%/512k"), "stats: {stats}");
627
628 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 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 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}