Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/context_bar.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

433 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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%"`)
/// - `1099`: `"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).
///
/// - `0999`: `"0"`, `"12"`, `"999"`
/// - `1K9.9K`: `"1.2K"` (4 chars)
/// - `10K999K`: `"12K"`, `"999K"` (≤4 chars)
/// - `1M9.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<ColorBreakpoint> {
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<u64>,
total_tokens: Option<u64>,
hovered: bool,
theme: &Theme,
) -> Option<Line<'static>> {
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<u64>,
total_tokens: Option<u64>,
hovered: bool,
theme: &Theme,
gateway_chat: bool,
) -> Option<Line<'static>> {
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()
);
}
}