//! Context usage bar — shows token usage in the status bar. //! //! Default builds a `Line<'static>` of styled spans: `8.5K / 1.0M` (actual tokens, //! colored by usage percentage). On hover, replaces the tokens with a progress //! bar + percentage, e.g. `█████ 42.0%`. The bar width is derived from the //! default string length so the hover line is the same total width — no layout //! shift on hover. The default is right-padded to a minimum of 6 columns so the //! width invariant holds even for degenerate inputs like `0 / 9`. use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use super::progress_bar::progress_bar_spans; use crate::theme::Theme; // --------------------------------------------------------------------------- // Formatting utilities // --------------------------------------------------------------------------- /// Format a percentage as a fixed-width 5-char string. /// /// - `< 10`: `"X.XX%"` (e.g. `"0.00%"`, `"5.12%"`) /// - `10–99`: `"XX.X%"` (e.g. `"20.1%"`, `"99.9%"`) /// - `≥ 100`: `"MAX %"` pub fn fmt_pct5(pct: f64) -> String { if pct >= 100.0 { "MAX %".to_string() } else if pct < 10.0 { format!("{pct:.2}%") } else { format!("{pct:.1}%") } } /// Format a token count as a compact string (≤4 chars). /// /// - `0–999`: `"0"`, `"12"`, `"999"` /// - `1K–9.9K`: `"1.2K"` (4 chars) /// - `10K–999K`: `"12K"`, `"999K"` (≤4 chars) /// - `1M–9.9M`: `"1.2M"` (4 chars) /// - `10M+`: `"12M"`, `"123M"` (≤4 chars) pub fn fmt_tokens(n: u64) -> String { if n < 1_000 { n.to_string() } else if n < 10_000 { format!("{:.1}K", n as f64 / 1_000.0) } else if n < 1_000_000 { format!("{}K", n / 1_000) } else if n < 10_000_000 { format!("{:.1}M", n as f64 / 1_000_000.0) } else { format!("{}M", n / 1_000_000) } } // --------------------------------------------------------------------------- // Color blending // --------------------------------------------------------------------------- /// A breakpoint for color blending: at `pct` percent, the bar color is `color`. #[derive(Debug, Clone, Copy)] pub struct ColorBreakpoint { pub pct: f64, pub color: Color, } /// Default breakpoints: text_primary → accent_user → warning → accent_error. /// /// Breakpoint colors are raw RGB. The final color produced by [`blend_color`] /// is quantized by the caller (see [`context_bar_line`]) so the output always /// matches the terminal's capability level. pub fn default_breakpoints(theme: &Theme) -> Vec { vec![ ColorBreakpoint { pct: 0.0, color: theme.text_primary, }, ColorBreakpoint { pct: 50.0, color: theme.accent_user, }, ColorBreakpoint { pct: 65.0, color: theme.accent_user, }, ColorBreakpoint { pct: 75.0, color: theme.warning, }, ColorBreakpoint { pct: 85.0, color: theme.warning, }, ColorBreakpoint { pct: 95.0, color: theme.accent_error, }, ] } /// Blend between breakpoints for a given percentage. pub fn blend_color(pct: f64, breakpoints: &[ColorBreakpoint]) -> Color { if breakpoints.is_empty() { return Color::Reset; } if pct <= breakpoints[0].pct { return breakpoints[0].color; } for i in 1..breakpoints.len() { if pct <= breakpoints[i].pct { let t = (pct - breakpoints[i - 1].pct) / (breakpoints[i].pct - breakpoints[i - 1].pct); return lerp_color(breakpoints[i - 1].color, breakpoints[i].color, t as f32); } } breakpoints.last().unwrap().color } /// Linear interpolation between two colors. /// /// When either input is `Color::Indexed`, the result is quantized back to /// the nearest indexed color so the output stays terminal-compatible. fn lerp_color(a: Color, b: Color, t: f32) -> Color { let (ar, ag, ab) = color_to_rgb(a); let (br, bg, bb) = color_to_rgb(b); let t = t.clamp(0.0, 1.0); let r = (ar as f32 + (br as f32 - ar as f32) * t).round() as u8; let g = (ag as f32 + (bg as f32 - ag as f32) * t).round() as u8; let b_ch = (ab as f32 + (bb as f32 - ab as f32) * t).round() as u8; match (a, b) { (Color::Indexed(_), _) | (_, Color::Indexed(_)) => { Color::Indexed(crate::render::color::nearest_indexed(r, g, b_ch)) } _ => Color::Rgb(r, g, b_ch), } } /// RGB for any color variant, using a neutral fallback for `Reset`. /// /// Necessary so a gradient that lerps across named breakpoints (after /// the theme has quantized to ANSI on lower-color terminals) still /// produces meaningful intermediate colors instead of collapsing all /// inputs onto one fallback. fn color_to_rgb(c: Color) -> (u8, u8, u8) { // (198, 198, 198) matches the FG-equivalent used elsewhere when the // terminal owns the actual default fg color. crate::render::color::resolve_to_rgb(c).unwrap_or((198, 198, 198)) } // --------------------------------------------------------------------------- // Status bar separator // --------------------------------------------------------------------------- /// The separator character between status bar items. pub const SEPARATOR: &str = "│"; // --------------------------------------------------------------------------- // Context bar line builder // --------------------------------------------------------------------------- /// Width of the percentage field on hover (`fmt_pct5` always returns 5 chars). const PCT_WIDTH: u16 = 5; /// Width of the gap between the progress bar and the percentage on hover. const BAR_PCT_GAP: u16 = 1; // BAR_BG removed — use theme.bg_highlight directly (already quantized). /// Build the context usage bar as a `Line<'static>`. /// /// Normal: `8.5K / 1.0M` — actual token usage, colored by the same percentage /// gradient the hover bar uses so the urgency signal stays visible at a glance. /// Hovered: `█████ 42.0%` — progress bar + colored percentage, sized to match. /// /// The bar width is derived from the default token string length so the /// hovered line has the same total width as the default (no layout shift on /// hover). The default is right-padded to a minimum of 6 columns /// (`BAR_PCT_GAP + PCT_WIDTH`) so the invariant holds for every input — without /// the pad, degenerate cases like `0 / 9` (5 chars) would mismatch the hovered /// line, which always rounds up to 6 (zero-width bar + gap + percentage). /// /// Returns `None` if token data is unavailable. /// /// Gateway light-frontend (`kind: "chat"`) sessions must not display Build / /// local sampler context usage — call with `gateway_chat = true` to suppress /// the bar entirely (remote owns context; no mapped totals yet). remote settings /// opt-in for chat entry can reuse the same gate later. pub fn context_bar_line( used_tokens: Option, total_tokens: Option, hovered: bool, theme: &Theme, ) -> Option> { context_bar_line_for_session(used_tokens, total_tokens, hovered, theme, false) } /// Like [`context_bar_line`], but omits the bar for gateway/chat-kind sessions. pub fn context_bar_line_for_session( used_tokens: Option, total_tokens: Option, hovered: bool, theme: &Theme, gateway_chat: bool, ) -> Option> { if gateway_chat { return None; } let used = used_tokens?; let total = total_tokens.filter(|&t| t > 0)?; let pct = kigi_token_estimation::usage_percentage(used, total); // Default form drives the line width: `used / total`, right-padded to the // minimum hover width so the two states always render at the same width. let mut token_str = format!("{} / {}", fmt_tokens(used), fmt_tokens(total)); let natural_width = token_str.chars().count() as u16; let min_width = BAR_PCT_GAP + PCT_WIDTH; if natural_width < min_width { token_str.push_str(&" ".repeat((min_width - natural_width) as usize)); } let total_width = natural_width.max(min_width); // Urgency color shared by both branches so the default still surfaces // high-usage warnings without requiring the user to hover. let breakpoints = default_breakpoints(theme); let color = crate::theme::quantize(blend_color(pct, &breakpoints)); if hovered { // Bar fills the space the default tokens would occupy, minus the gap // and the percentage. `total_width >= min_width` by construction, so // this subtraction is safe. let bar_width = total_width - min_width; let mut spans = progress_bar_spans(bar_width, pct as f32 / 100.0, color, theme.bg_highlight); spans.push(Span::styled(" ", Style::default().bg(theme.bg_base))); spans.push(Span::styled( fmt_pct5(pct), Style::default().fg(theme.text_secondary).bg(theme.bg_base), )); Some(Line::from(spans)) } else { Some(Line::from(Span::styled( token_str, Style::default().fg(color).bg(theme.bg_base), ))) } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn test_fmt_pct5_under_10() { assert_eq!(fmt_pct5(0.0), "0.00%"); assert_eq!(fmt_pct5(5.123), "5.12%"); assert_eq!(fmt_pct5(9.99), "9.99%"); } #[test] fn test_fmt_pct5_10_to_99() { assert_eq!(fmt_pct5(10.0), "10.0%"); assert_eq!(fmt_pct5(20.16), "20.2%"); // rounds assert_eq!(fmt_pct5(99.9), "99.9%"); } #[test] fn test_fmt_pct5_max() { assert_eq!(fmt_pct5(100.0), "MAX %"); assert_eq!(fmt_pct5(150.0), "MAX %"); } #[test] fn test_fmt_pct5_all_5_chars() { for pct in [0.0, 0.01, 1.0, 5.55, 9.99, 10.0, 50.0, 99.9, 100.0] { let s = fmt_pct5(pct); assert_eq!(s.len(), 5, "fmt_pct5({pct}) = {s:?} should be 5 chars"); } } #[test] fn test_fmt_tokens_small() { assert_eq!(fmt_tokens(0), "0"); assert_eq!(fmt_tokens(12), "12"); assert_eq!(fmt_tokens(999), "999"); } #[test] fn test_fmt_tokens_thousands() { assert_eq!(fmt_tokens(1_200), "1.2K"); assert_eq!(fmt_tokens(9_960), "10.0K"); // rounds up assert_eq!(fmt_tokens(9_940), "9.9K"); assert_eq!(fmt_tokens(12_000), "12K"); assert_eq!(fmt_tokens(123_000), "123K"); assert_eq!(fmt_tokens(999_000), "999K"); } #[test] fn test_fmt_tokens_millions() { assert_eq!(fmt_tokens(1_200_000), "1.2M"); assert_eq!(fmt_tokens(12_000_000), "12M"); assert_eq!(fmt_tokens(123_000_000), "123M"); } #[test] fn test_fmt_tokens_max_4_chars() { for n in [ 0, 1, 999, 1_200, 9_900, 12_000, 999_000, 1_200_000, 12_000_000, ] { let s = fmt_tokens(n); assert!(s.len() <= 4, "fmt_tokens({n}) = {s:?} should be ≤4 chars"); } } #[test] fn test_blend_color_at_breakpoints() { // Use unquantized theme — blend_color needs raw RGB values for lerp math. let theme = Theme::default(); let bps = default_breakpoints(&theme); // At 0%, should be theme.text_primary let c0 = blend_color(0.0, &bps); assert_eq!(c0, theme.text_primary); // At 95%, should be theme.accent_error let c95 = blend_color(95.0, &bps); assert_eq!(c95, theme.accent_error); } /// Concatenate all span content into one string for assertions. fn line_text(line: &Line<'static>) -> String { line.spans.iter().map(|s| s.content.as_ref()).collect() } #[test] fn test_context_bar_default_shows_compact_token_usage() { // Default (non-hovered) state shows `used / total` with no padding. let theme = Theme::default(); let line = context_bar_line(Some(8_500), Some(1_000_000), false, &theme) .expect("token data provided"); let text = line_text(&line); assert_eq!(text, "8.5K / 1.0M"); } #[test] fn test_context_bar_hover_shows_bar_and_percentage() { // Hovered state shows the progress bar followed by the percentage. let theme = Theme::default(); let line = context_bar_line(Some(420_000), Some(1_000_000), true, &theme).expect("token data"); let text = line_text(&line); assert!( text.ends_with("42.0%"), "expected hovered line to end with '42.0%', got: {text:?}" ); } #[test] fn test_context_bar_hover_width_matches_default() { // For each (used, total) combo, the hovered line must be the same // width as the default — toggling hover should never shift layout. let theme = Theme::default(); for (used, total) in [ (8_500u64, 1_000_000u64), (500, 1_000_000), (123_456, 1_000_000), (999_999, 999_999), (12_000_000, 12_000_000), // Degenerate sub-min-width case: default natural width is 5 // ("0 / 9"), padded to 6 so the hover line still matches. (0, 9), ] { let default_line = context_bar_line(Some(used), Some(total), false, &theme) .expect("token data provided"); let hover_line = context_bar_line(Some(used), Some(total), true, &theme) .expect("token data provided"); assert_eq!( default_line.width(), hover_line.width(), "default vs hover width mismatch for used={used} total={total}: \ default={:?} hover={:?}", line_text(&default_line), line_text(&hover_line), ); } } #[test] fn test_context_bar_hover_bar_grows_with_token_string() { // The bar size should scale with the default string length. // `500 / 1.0M` (10 chars) → bar = 10 - 6 = 4 chars. // `8.5K / 1.0M` (11 chars) → bar = 11 - 6 = 5 chars. let theme = Theme::default(); let short = context_bar_line(Some(500), Some(1_000_000), true, &theme).unwrap(); let long = context_bar_line(Some(8_500), Some(1_000_000), true, &theme).unwrap(); assert!( short.width() < long.width(), "expected shorter default to produce shorter hover line; \ short={:?} ({} cols), long={:?} ({} cols)", line_text(&short), short.width(), line_text(&long), long.width(), ); } #[test] fn test_context_bar_returns_none_without_tokens() { // Mirror across hover states so a future refactor that moves the // unavailability checks into per-branch arms can't silently regress // one path. let theme = Theme::default(); for hovered in [false, true] { assert!(context_bar_line(None, Some(1_000_000), hovered, &theme).is_none()); assert!(context_bar_line(Some(1_000), None, hovered, &theme).is_none()); // Zero total is treated as missing. assert!(context_bar_line(Some(1_000), Some(0), hovered, &theme).is_none()); } } #[test] fn gateway_chat_suppresses_context_bar_even_with_tokens() { let theme = Theme::default(); assert!( context_bar_line_for_session(Some(1_000), Some(1_000_000), false, &theme, true) .is_none() ); assert!( context_bar_line_for_session(Some(1_000), Some(1_000_000), false, &theme, false) .is_some() ); } }