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).
433 lines
16 KiB
Rust
433 lines
16 KiB
Rust
//! 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<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()
|
||
);
|
||
}
|
||
}
|