Skip to main content

wisp/components/
context_bar.rs

1use tui::{Color, Theme};
2
3/// Nominal context usage values shown in the status line.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct ContextUsageDisplay {
6    pub used_tokens: u32,
7    pub limit_tokens: u32,
8}
9
10impl ContextUsageDisplay {
11    pub fn new(used_tokens: u32, limit_tokens: u32) -> Self {
12        Self { used_tokens, limit_tokens }
13    }
14
15    pub fn used_ratio(&self) -> f64 {
16        if self.limit_tokens == 0 {
17            return 0.0;
18        }
19        (f64::from(self.used_tokens) / f64::from(self.limit_tokens)).clamp(0.0, 1.0)
20    }
21}
22
23/// Renders a bracketed slot bar with `filled` of `total` slots filled.
24///
25/// Example: `slot_bar(2, 3)` => `[■■·]`
26pub(crate) fn slot_bar(filled: usize, total: usize) -> String {
27    let slots: String = (0..total).map(|i| if i < filled { '■' } else { '·' }).collect();
28    format!("[{slots}]")
29}
30
31/// Renders a compact 3-slot context gauge with label and nominal usage.
32///
33/// Each slot represents ~33% of context used. Visual mapping:
34/// - `200k / 200k` => `ctx [■■■] 200k / 200k`
35/// - `100k / 200k` => `ctx [■■·] 100k / 200k`
36/// - `1.2k / 200k` => `ctx [···] 1.2k / 200k`
37pub(crate) fn context_bar(usage: ContextUsageDisplay) -> String {
38    const TOTAL: u32 = 3;
39    let filled = (usage.used_tokens.saturating_mul(TOTAL) + usage.limit_tokens / 2) / usage.limit_tokens.max(1);
40    let filled = (filled as usize).min(TOTAL as usize);
41    format!(
42        "ctx {} {} / {}",
43        slot_bar(filled, TOTAL as usize),
44        format_tokens(usage.used_tokens),
45        format_tokens(usage.limit_tokens)
46    )
47}
48
49/// Returns the appropriate theme color for the given context usage.
50///
51/// Tiers (used capacity):
52/// - `<=70%` used → `text_secondary` (subtle awareness)
53/// - `71-85%` used → `warning` (yellow)
54/// - `>=86%` used → `error` (red, attention needed)
55pub(crate) fn context_color(usage: ContextUsageDisplay, theme: &Theme) -> Color {
56    let used_pct = usage.used_ratio() * 100.0;
57    if used_pct >= 86.0 {
58        theme.error()
59    } else if used_pct >= 71.0 {
60        theme.warning()
61    } else {
62        theme.text_secondary()
63    }
64}
65
66/// Formats a token count compactly: `999`, `1k`, `1.2k`, `12k`, `150k`, `1.2M`.
67pub(crate) fn format_tokens(n: u32) -> String {
68    match n {
69        n if n < 1_000 => n.to_string(),
70        n if n < 1_000_000 => format_with_unit(f64::from(n) / 1_000.0, "k"),
71        n => format_with_unit(f64::from(n) / 1_000_000.0, "M"),
72    }
73}
74
75fn format_with_unit(value: f64, unit: &str) -> String {
76    let rounded_one = (value * 10.0).round() / 10.0;
77    if (rounded_one - rounded_one.trunc()).abs() < f64::EPSILON {
78        format!("{rounded_one:.0}{unit}")
79    } else {
80        format!("{rounded_one:.1}{unit}")
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    fn usage(used: u32, limit: u32) -> ContextUsageDisplay {
89        ContextUsageDisplay::new(used, limit)
90    }
91
92    #[test]
93    fn slot_bar_empty() {
94        assert_eq!(slot_bar(0, 3), "[···]");
95    }
96
97    #[test]
98    fn slot_bar_partial() {
99        assert_eq!(slot_bar(2, 3), "[■■·]");
100    }
101
102    #[test]
103    fn slot_bar_full() {
104        assert_eq!(slot_bar(3, 3), "[■■■]");
105    }
106
107    #[test]
108    fn bar_full() {
109        assert_eq!(context_bar(usage(200_000, 200_000)), "ctx [■■■] 200k / 200k");
110    }
111
112    #[test]
113    fn bar_empty() {
114        assert_eq!(context_bar(usage(0, 200_000)), "ctx [···] 0 / 200k");
115    }
116
117    #[test]
118    fn bar_low() {
119        assert_eq!(context_bar(usage(1_200, 200_000)), "ctx [···] 1.2k / 200k");
120    }
121
122    #[test]
123    fn bar_half() {
124        assert_eq!(context_bar(usage(100_000, 200_000)), "ctx [■■·] 100k / 200k");
125    }
126
127    #[test]
128    fn bar_near_full() {
129        assert_eq!(context_bar(usage(190_000, 200_000)), "ctx [■■■] 190k / 200k");
130    }
131
132    #[test]
133    fn bar_fills_with_usage() {
134        let empty = context_bar(usage(0, 200_000));
135        let half = context_bar(usage(100_000, 200_000));
136        let full = context_bar(usage(200_000, 200_000));
137        assert!(empty.contains("[···]"));
138        assert!(half.contains("[■■·]"));
139        assert!(full.contains("[■■■]"));
140    }
141
142    #[test]
143    fn color_tiers() {
144        let theme = Theme::default();
145        assert_eq!(context_color(usage(0, 200_000), &theme), theme.text_secondary());
146        assert_eq!(context_color(usage(140_000, 200_000), &theme), theme.text_secondary());
147        assert_eq!(context_color(usage(142_000, 200_000), &theme), theme.warning());
148        assert_eq!(context_color(usage(170_000, 200_000), &theme), theme.warning());
149        assert_eq!(context_color(usage(172_000, 200_000), &theme), theme.error());
150        assert_eq!(context_color(usage(200_000, 200_000), &theme), theme.error());
151    }
152
153    #[test]
154    fn format_tokens_examples() {
155        assert_eq!(format_tokens(0), "0");
156        assert_eq!(format_tokens(999), "999");
157        assert_eq!(format_tokens(1_000), "1k");
158        assert_eq!(format_tokens(1_200), "1.2k");
159        assert_eq!(format_tokens(12_000), "12k");
160        assert_eq!(format_tokens(150_000), "150k");
161        assert_eq!(format_tokens(1_200_000), "1.2M");
162    }
163}