feat: add environment sections, visual enhancements, enhanced tools/beads, and /clear detection
The feature layer that builds on the new infrastructure modules. Adds
4 new environment-aware sections, rewrites the tools/beads/turns sections,
introduces gradient sparklines and block-style context bars, and wires
/clear detection into the main binary.
New sections (4):
cloud_profile — Shows active cloud provider profile from env vars
($AWS_PROFILE, $CLOUDSDK_CORE_PROJECT, $AZURE_SUBSCRIPTION_ID).
Provider-specific coloring (AWS orange, GCP blue, Azure blue).
k8s_context — Parses kubeconfig for current-context and namespace.
Minimal YAML scanning (no yaml dependency). 30s TTL cache.
Shows "context/namespace" with split coloring.
python_env — Detects active virtualenv ($VIRTUAL_ENV) or conda
($CONDA_DEFAULT_ENV, excluding "base"). Shows just the env name.
toolchain — Detects Rust (rust-toolchain.toml) and Node.js (.nvmrc,
.node-version) versions. Compares expected vs actual ($RUSTUP_TOOLCHAIN,
$NODE_VERSION) and highlights mismatches in yellow.
Tools section rewrite:
Progressive disclosure based on terminal width:
- Narrow: just the count ("245")
- Medium: count + last tool name ("245 tools (Bash)")
- Wide: per-tool color-coded breakdown ("245 tools (Bash: 84/Read: 35/...)")
Adaptive width budgeting: breakdown reduces tool count until it fits
within 1/3 of terminal width. Color palette priority: config > terminal
ANSI palette (via OSC 4) > built-in Dracula palette.
Beads section rewrite:
Switched from `br ready --json` to `br stats --json` to show all
statuses. Now renders multi-status breakdown: "3 ready 1 wip 2 open"
with per-status visibility toggles in config.
Turns section:
Falls back to transcript-derived turn count when cost.total_turns is
absent. Requires at least one data source to render (vanishes when
no session data exists at all).
Visual enhancements:
trend.rs:
- append_delta(): tracks rate-of-change (delta between cumulative
samples) so sparklines show burn intensity, not monotonic growth
- sparkline(): now renders exactly `width` chars with left-padding
for missing data. Baseline (space) vs flatline (lowest bar) chars.
- sparkline_colored(): per-character gradient coloring via colorgrad,
returns (raw, ansi) tuple for layout compatibility.
context_bar.rs:
- Block style: Unicode full-block fill + light-shade empty chars
- Per-character green->yellow->red gradient for block style
- Classic style preserved (= and - chars) with single threshold color
- Configurable fill_char/empty_char overrides
context_trend + cost_trend:
Switched to append_delta for rate-based sparklines. Gradient coloring
with green->yellow->red via sparkline_colored().
format.rs:
Background color support via resolve_background(). Accepts named
colors, hex, and palette refs. Applied as ANSI bg wrap around section
output, preserving foreground colors.
layout/mod.rs:
- Separator styles: text (default), powerline (Nerd Font), arrow
(Unicode), none (spaces). Powerline auto-falls-back to arrow when
glyphs disabled.
- Placeholder support: when an enabled section returns None (no data),
substitutes a configurable placeholder character (default: box-draw)
to maintain layout stability during justify/flex.
Section refinements:
cost, cost_velocity, token_velocity, duration, tokens_raw — now show
zero/baseline values instead of hiding entirely. This prevents layout
jumps when sessions start or after /clear.
context_usage — uses current_usage fields (input_tokens +
cache_creation + cache_read) for precise token counts instead of
percentage-derived estimates. Shows one decimal place on percentage.
metrics.rs — prefers total_api_duration_ms over total_duration_ms for
velocity calculations (active processing vs wall clock with idle time).
Cache efficiency now divides by total input (not just cache tokens).
Config additions (config.rs):
SeparatorStyle enum (text/powerline/arrow/none), BarStyle enum
(classic/block), gradient toggle on trends + context_bar, background
and placeholder on SectionBase, tools breakdown config (show_breakdown,
top_n, palette), 4 new section structs.
Main binary (/clear detection + wiring):
detect_clear() — watches for significant context usage drops (>15%
to <5%, >20pp drop) to identify /clear. On detection: saves transcript
offset so derived stats only count post-clear entries, flushes trend
caches for fresh sparklines.
resolve_transcript_stats() — cached transcript parsing with 5s TTL,
respects clear offset, skipped when cost already has tool counts.
resolve_terminal_palette() — cached palette detection with 1h TTL.
Debug: CLAUDE_STATUSLINE_DEBUG env var dumps raw input JSON to
/tmp/claude-statusline-input.json. dump-state now includes input data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
use claude_statusline::section::RenderContext;
|
||||
use claude_statusline::shell::{self, ShellConfig};
|
||||
use claude_statusline::transcript;
|
||||
use claude_statusline::{
|
||||
cache, color, config, format as sl_format, input, layout, metrics, section, theme, width,
|
||||
cache, color, config, format as sl_format, input, layout, metrics, section, terminal, theme,
|
||||
width,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
@@ -111,6 +113,9 @@ fn main() {
|
||||
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
if std::env::var("CLAUDE_STATUSLINE_DEBUG").is_ok() {
|
||||
let _ = std::fs::write("/tmp/claude-statusline-input.json", &buf);
|
||||
}
|
||||
match serde_json::from_str(&buf) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
@@ -174,6 +179,10 @@ fn main() {
|
||||
)
|
||||
};
|
||||
|
||||
// Detect /clear: context usage drops dramatically while session continues.
|
||||
// When detected, flush trends and save transcript offset so derived stats reset.
|
||||
detect_clear(&input_data, &cache);
|
||||
|
||||
let shell_config = ShellConfig {
|
||||
enabled: config.global.shell_enabled && !no_shell,
|
||||
allowlist: config.global.shell_allowlist.clone(),
|
||||
@@ -200,6 +209,12 @@ fn main() {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
// Parse transcript for tool/turn counts (cached)
|
||||
let transcript_stats = resolve_transcript_stats(&input_data, &cache);
|
||||
|
||||
// Query terminal ANSI palette for theme-aware tool colors (cached 1h)
|
||||
let terminal_palette = resolve_terminal_palette(&cache);
|
||||
|
||||
let ctx = RenderContext {
|
||||
input: &input_data,
|
||||
config: &config,
|
||||
@@ -221,6 +236,8 @@ fn main() {
|
||||
},
|
||||
budget_ms: config.global.render_budget_ms,
|
||||
shell_results,
|
||||
transcript_stats,
|
||||
terminal_palette,
|
||||
};
|
||||
|
||||
// Handle --dump-state (after building ctx so we can collect diagnostics)
|
||||
@@ -277,7 +294,7 @@ fn prefetch_shell_outs(
|
||||
let beads_ttl = Duration::from_secs(config.sections.beads.ttl);
|
||||
let needs_beads = config.sections.beads.base.enabled
|
||||
&& std::path::Path::new(project_dir).join(".beads").is_dir()
|
||||
&& cache.get("beads_summary", beads_ttl).is_none();
|
||||
&& cache.get("beads_stats", beads_ttl).is_none();
|
||||
|
||||
// If nothing needs refreshing, skip thread::scope entirely
|
||||
if !needs_vcs && !needs_load && !needs_beads {
|
||||
@@ -349,7 +366,7 @@ fn prefetch_shell_outs(
|
||||
let beads_handle = if needs_beads {
|
||||
let dir = project_dir.to_string();
|
||||
Some(s.spawn(move || {
|
||||
shell::exec_gated(shell_config, "br", &["ready", "--json"], Some(&dir))
|
||||
shell::exec_gated(shell_config, "br", &["stats", "--json"], Some(&dir))
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
@@ -369,6 +386,103 @@ fn prefetch_shell_outs(
|
||||
results
|
||||
}
|
||||
|
||||
/// Detect /clear by watching for a significant context usage drop.
|
||||
/// When detected: flush trend caches and save transcript line offset
|
||||
/// so derived stats (tools, turns) only count post-clear entries.
|
||||
fn detect_clear(input: &input::InputData, cache: &cache::Cache) {
|
||||
// Only run detection when context_window data is actually present.
|
||||
// Missing data (e.g. during slash-command menu or transient redraws)
|
||||
// must NOT be treated as "context dropped to 0" — that false-positive
|
||||
// flushes all trends and transcript stats.
|
||||
let current_pct = match input
|
||||
.context_window
|
||||
.as_ref()
|
||||
.and_then(|cw| cw.used_percentage)
|
||||
{
|
||||
Some(pct) => pct,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let prev_pct: f64 = cache
|
||||
.get_stale("last_context_pct")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
cache.set("last_context_pct", &format!("{current_pct:.1}"));
|
||||
|
||||
// Detect clear: previous context was significant (>15%) and current is near-zero (<5%),
|
||||
// with a drop of at least 20 percentage points.
|
||||
let drop = prev_pct - current_pct;
|
||||
if prev_pct > 15.0 && current_pct < 5.0 && drop > 20.0 {
|
||||
// Save current transcript line count as the clear offset
|
||||
if let Some(path_str) = input.transcript_path.as_deref() {
|
||||
let path = std::path::Path::new(path_str);
|
||||
if path.exists() {
|
||||
if let Ok(file) = std::fs::File::open(path) {
|
||||
use std::io::BufRead;
|
||||
let line_count = std::io::BufReader::new(file).lines().count();
|
||||
cache.set("clear_transcript_offset", &line_count.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush trends and cached transcript stats
|
||||
cache.flush_prefix("trend_");
|
||||
cache.flush_prefix("transcript_");
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve transcript stats: check cache first, then parse the transcript JSONL.
|
||||
/// Uses a 5-second TTL so we re-parse at most every 5 seconds.
|
||||
/// Respects the clear_transcript_offset: only counts entries after the offset line.
|
||||
fn resolve_transcript_stats(
|
||||
input: &input::InputData,
|
||||
cache: &cache::Cache,
|
||||
) -> Option<transcript::TranscriptStats> {
|
||||
// If cost already has tool counts, skip transcript parsing entirely
|
||||
if input
|
||||
.cost
|
||||
.as_ref()
|
||||
.and_then(|c| c.total_tool_uses)
|
||||
.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let path_str = input.transcript_path.as_deref()?;
|
||||
let path = std::path::Path::new(path_str);
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(5);
|
||||
if let Some(cached) = cache.get("transcript_stats", ttl) {
|
||||
return transcript::TranscriptStats::from_cache_string(&cached);
|
||||
}
|
||||
|
||||
let skip_lines: usize = cache
|
||||
.get_stale("clear_transcript_offset")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let stats = transcript::parse_transcript(path, skip_lines)?;
|
||||
cache.set("transcript_stats", &stats.to_cache_string());
|
||||
Some(stats)
|
||||
}
|
||||
|
||||
/// Resolve the terminal's color palette from config file or OSC 4 queries.
|
||||
/// Cached for 1 hour — terminal colors don't change mid-session.
|
||||
fn resolve_terminal_palette(cache: &cache::Cache) -> Option<Vec<(u8, u8, u8)>> {
|
||||
let ttl = Duration::from_secs(3600);
|
||||
if let Some(cached) = cache.get("terminal_palette", ttl) {
|
||||
return terminal::palette_from_cache(&cached);
|
||||
}
|
||||
|
||||
let palette = terminal::detect_palette()?;
|
||||
cache.set("terminal_palette", &terminal::palette_to_cache(&palette));
|
||||
Some(palette)
|
||||
}
|
||||
|
||||
fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType {
|
||||
let prefer = config.sections.vcs.prefer.as_str();
|
||||
let path = std::path::Path::new(dir);
|
||||
@@ -482,6 +596,7 @@ fn dump_state_output(
|
||||
.replace("{config_hash}", config_hash),
|
||||
},
|
||||
"session_id": session_id,
|
||||
"input": &ctx.input,
|
||||
});
|
||||
|
||||
match format {
|
||||
|
||||
@@ -45,6 +45,7 @@ impl Default for LayoutValue {
|
||||
#[serde(default)]
|
||||
pub struct GlobalConfig {
|
||||
pub separator: String,
|
||||
pub separator_style: SeparatorStyle,
|
||||
pub justify: JustifyMode,
|
||||
pub vcs: String,
|
||||
pub width: Option<u16>,
|
||||
@@ -75,6 +76,7 @@ impl Default for GlobalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
separator: " | ".into(),
|
||||
separator_style: SeparatorStyle::Text,
|
||||
justify: JustifyMode::Left,
|
||||
vcs: "auto".into(),
|
||||
width: None,
|
||||
@@ -121,6 +123,24 @@ pub enum ColorMode {
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SeparatorStyle {
|
||||
#[default]
|
||||
Text,
|
||||
Powerline,
|
||||
Arrow,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BarStyle {
|
||||
Classic,
|
||||
#[default]
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Breakpoints {
|
||||
@@ -172,6 +192,8 @@ pub struct SectionBase {
|
||||
pub pad: Option<u16>,
|
||||
pub align: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub background: Option<String>,
|
||||
pub placeholder: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SectionBase {
|
||||
@@ -186,6 +208,8 @@ impl Default for SectionBase {
|
||||
pad: None,
|
||||
align: None,
|
||||
color: None,
|
||||
background: None,
|
||||
placeholder: Some("\u{2500}".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,6 +243,10 @@ pub struct Sections {
|
||||
pub time: TimeSection,
|
||||
pub output_style: SectionBase,
|
||||
pub hostname: SectionBase,
|
||||
pub cloud_profile: SectionBase,
|
||||
pub k8s_context: CachedSection,
|
||||
pub python_env: SectionBase,
|
||||
pub toolchain: SectionBase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -356,6 +384,10 @@ pub struct ContextBarSection {
|
||||
#[serde(flatten)]
|
||||
pub base: SectionBase,
|
||||
pub bar_width: u16,
|
||||
pub bar_style: BarStyle,
|
||||
pub gradient: bool,
|
||||
pub fill_char: Option<String>,
|
||||
pub empty_char: Option<String>,
|
||||
pub thresholds: Thresholds,
|
||||
}
|
||||
|
||||
@@ -369,6 +401,10 @@ impl Default for ContextBarSection {
|
||||
..Default::default()
|
||||
},
|
||||
bar_width: 10,
|
||||
bar_style: BarStyle::Block,
|
||||
gradient: true,
|
||||
fill_char: None,
|
||||
empty_char: None,
|
||||
thresholds: Thresholds {
|
||||
warn: 50.0,
|
||||
danger: 70.0,
|
||||
@@ -492,6 +528,7 @@ pub struct TrendSection {
|
||||
#[serde(flatten)]
|
||||
pub base: SectionBase,
|
||||
pub width: u8,
|
||||
pub gradient: bool,
|
||||
}
|
||||
|
||||
impl Default for TrendSection {
|
||||
@@ -502,6 +539,7 @@ impl Default for TrendSection {
|
||||
..Default::default()
|
||||
},
|
||||
width: 8,
|
||||
gradient: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,6 +550,7 @@ pub struct ContextTrendSection {
|
||||
#[serde(flatten)]
|
||||
pub base: SectionBase,
|
||||
pub width: u8,
|
||||
pub gradient: bool,
|
||||
pub thresholds: Thresholds,
|
||||
}
|
||||
|
||||
@@ -523,6 +562,7 @@ impl Default for ContextTrendSection {
|
||||
..Default::default()
|
||||
},
|
||||
width: 8,
|
||||
gradient: true,
|
||||
thresholds: Thresholds::default(),
|
||||
}
|
||||
}
|
||||
@@ -534,6 +574,13 @@ pub struct ToolsSection {
|
||||
#[serde(flatten)]
|
||||
pub base: SectionBase,
|
||||
pub show_last_name: bool,
|
||||
/// Show per-tool breakdown (e.g., "Bash:84 Read:35 Edit:34").
|
||||
pub show_breakdown: bool,
|
||||
/// Max number of tools to show in breakdown (0 = all).
|
||||
pub top_n: usize,
|
||||
/// Rotating color palette for tool names (hex strings, e.g. "#8be9fd").
|
||||
/// Falls back to built-in Dracula palette when empty.
|
||||
pub palette: Vec<String>,
|
||||
pub ttl: u64,
|
||||
}
|
||||
|
||||
@@ -546,6 +593,9 @@ impl Default for ToolsSection {
|
||||
..Default::default()
|
||||
},
|
||||
show_last_name: true,
|
||||
show_breakdown: true,
|
||||
top_n: 7,
|
||||
palette: Vec::new(),
|
||||
ttl: 2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,13 @@ pub fn apply_formatting(
|
||||
*ansi = format!("{c}{raw}{}", color::RESET);
|
||||
}
|
||||
|
||||
if let Some(ref bg_name) = base.background {
|
||||
let bg = resolve_background(bg_name, theme, palette);
|
||||
if !bg.is_empty() {
|
||||
*ansi = format!("{bg}{ansi}{}", color::RESET);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pad) = base.pad {
|
||||
let pad = pad as usize;
|
||||
let raw_w = display_width(raw);
|
||||
@@ -175,3 +182,30 @@ pub fn apply_formatting(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a background color specifier to an ANSI background escape.
|
||||
/// Accepts: named colors (green, red), hex (#50fa7b), or palette refs (p:success).
|
||||
/// The value is auto-prefixed with "bg:" if not already a background specifier.
|
||||
fn resolve_background(name: &str, theme: Theme, palette: &crate::config::ThemeColors) -> String {
|
||||
// If already a bg: specifier, resolve directly
|
||||
if name.starts_with("bg:") {
|
||||
return color::resolve_color(name, theme, palette);
|
||||
}
|
||||
// Palette references: resolve first, then convert to bg
|
||||
if let Some(key) = name.strip_prefix("p:") {
|
||||
let map = match theme {
|
||||
Theme::Dark => &palette.dark,
|
||||
Theme::Light => &palette.light,
|
||||
};
|
||||
if let Some(resolved) = map.get(key) {
|
||||
return resolve_background(resolved, theme, palette);
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
// Hex color -> bg:hex
|
||||
if name.starts_with('#') {
|
||||
return color::resolve_color(&format!("bg:{name}"), theme, palette);
|
||||
}
|
||||
// Named color -> bg:name
|
||||
color::resolve_color(&format!("bg:{name}"), theme, palette)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ pub mod flex;
|
||||
pub mod justify;
|
||||
pub mod priority;
|
||||
|
||||
use crate::config::{Config, JustifyMode, LayoutValue, SectionBase};
|
||||
use crate::color;
|
||||
use crate::config::{Config, JustifyMode, LayoutValue, SectionBase, SeparatorStyle};
|
||||
use crate::format;
|
||||
use crate::section::{self, RenderContext, SectionOutput};
|
||||
|
||||
@@ -97,14 +98,29 @@ fn is_valid_preset(
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the effective separator string based on separator_style config.
|
||||
/// Powerline auto-falls-back to arrow when glyphs are disabled.
|
||||
fn resolve_separator(config: &Config) -> String {
|
||||
let style = match config.global.separator_style {
|
||||
SeparatorStyle::Powerline if !config.glyphs.enabled => SeparatorStyle::Arrow,
|
||||
other => other,
|
||||
};
|
||||
match style {
|
||||
SeparatorStyle::Text => config.global.separator.clone(),
|
||||
SeparatorStyle::Powerline => " \u{E0B0} ".into(), // Nerd Font triangle
|
||||
SeparatorStyle::Arrow => " \u{276F} ".into(), // Heavy right-pointing angle quotation
|
||||
SeparatorStyle::None => " ".into(), // Double space
|
||||
}
|
||||
}
|
||||
|
||||
/// Full render: resolve layout, render each line, join with newlines.
|
||||
pub fn render_all(ctx: &RenderContext) -> String {
|
||||
let layout = resolve_layout(ctx.config, ctx.term_width);
|
||||
let separator = &ctx.config.global.separator;
|
||||
let separator = resolve_separator(ctx.config);
|
||||
|
||||
let lines: Vec<String> = layout
|
||||
.iter()
|
||||
.filter_map(|line_ids| render_line(line_ids, ctx, separator))
|
||||
.filter_map(|line_ids| render_line(line_ids, ctx, &separator))
|
||||
.collect();
|
||||
|
||||
lines.join("\n")
|
||||
@@ -149,6 +165,54 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
|
||||
is_spacer: section::is_spacer(id),
|
||||
is_flex,
|
||||
});
|
||||
} else if !section::is_spacer(id) {
|
||||
// Enabled section returned None (no data) — substitute placeholder
|
||||
// to preserve section count for justify/flex layout stability.
|
||||
if let Some(base) = section_base(id, ctx.config) {
|
||||
if base.enabled {
|
||||
if let Some(ref ph) = base.placeholder {
|
||||
if !ph.is_empty() {
|
||||
// Repeat the placeholder char to fill min_width
|
||||
// (minus prefix/suffix overhead). This produces a
|
||||
// seamless line like "───────" instead of "─ ".
|
||||
let fill_char = ph.chars().next().unwrap_or('\u{2500}');
|
||||
let pfx_w =
|
||||
base.prefix.as_ref().map_or(0, |p| format::display_width(p));
|
||||
let sfx_w =
|
||||
base.suffix.as_ref().map_or(0, |s| format::display_width(s));
|
||||
let overhead = pfx_w + sfx_w;
|
||||
let inner_w = base.min_width.map_or(ph.len(), |mw| {
|
||||
(mw as usize).saturating_sub(overhead).max(1)
|
||||
});
|
||||
let fill: String = std::iter::repeat_n(fill_char, inner_w).collect();
|
||||
|
||||
let mut output = SectionOutput {
|
||||
raw: fill.clone(),
|
||||
ansi: if ctx.color_enabled {
|
||||
format!("{}{fill}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
fill
|
||||
},
|
||||
};
|
||||
format::apply_formatting(
|
||||
&mut output.raw,
|
||||
&mut output.ansi,
|
||||
base,
|
||||
ctx.theme,
|
||||
&ctx.config.colors,
|
||||
);
|
||||
let (prio, is_flex) = section_meta(id, ctx.config);
|
||||
active.push(ActiveSection {
|
||||
id: id.clone(),
|
||||
output,
|
||||
priority: prio,
|
||||
is_spacer: false,
|
||||
is_flex,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,19 +21,21 @@ impl ComputedMetrics {
|
||||
m.usage_pct = cw.used_percentage.unwrap_or(0.0);
|
||||
|
||||
if let Some(ref usage) = cw.current_usage {
|
||||
let input = usage.input_tokens.unwrap_or(0);
|
||||
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
|
||||
let cache_create = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
let total_cache = cache_read + cache_create;
|
||||
if total_cache > 0 {
|
||||
m.cache_efficiency_pct = Some(cache_read as f64 / total_cache as f64 * 100.0);
|
||||
let total_input = input + cache_create + cache_read;
|
||||
if total_input > 0 {
|
||||
m.cache_efficiency_pct = Some(cache_read as f64 / total_input as f64 * 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref cost) = input.cost {
|
||||
if let (Some(cost_usd), Some(duration_ms)) =
|
||||
(cost.total_cost_usd, cost.total_duration_ms)
|
||||
{
|
||||
// Prefer API duration (active processing time) over wall-clock
|
||||
// duration, which includes all idle time between turns.
|
||||
let active_ms = cost.total_api_duration_ms.or(cost.total_duration_ms);
|
||||
if let (Some(cost_usd), Some(duration_ms)) = (cost.total_cost_usd, active_ms) {
|
||||
if duration_ms > 0 {
|
||||
let minutes = duration_ms as f64 / 60_000.0;
|
||||
m.cost_velocity = Some(cost_usd / minutes);
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::section::{RenderContext, SectionOutput};
|
||||
use crate::shell;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Cached format: "open:N,wip:N,ready:N,closed:N"
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.beads.base.enabled {
|
||||
return None;
|
||||
@@ -15,35 +16,82 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
|
||||
// --no-shell: serve stale cache only
|
||||
if ctx.no_shell {
|
||||
return render_from_cache(ctx, ctx.cache.get_stale("beads_summary")?);
|
||||
return render_from_summary(ctx, &ctx.cache.get_stale("beads_stats")?);
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(ctx.config.sections.beads.ttl);
|
||||
|
||||
let cached = ctx.cache.get("beads_summary", ttl);
|
||||
let cached = ctx.cache.get("beads_stats", ttl);
|
||||
let summary = cached.or_else(|| {
|
||||
// Use prefetched result if available, otherwise exec
|
||||
let out = ctx.shell_results.get("beads").cloned().unwrap_or_else(|| {
|
||||
shell::exec_gated(
|
||||
ctx.shell_config,
|
||||
"br",
|
||||
&["ready", "--json"],
|
||||
&["stats", "--json"],
|
||||
Some(ctx.project_dir.to_str()?),
|
||||
)
|
||||
})?;
|
||||
// Count JSON array items (simple: count opening braces at indent level 1)
|
||||
let count = out.matches("\"id\"").count();
|
||||
let summary = format!("{count}");
|
||||
ctx.cache.set("beads_summary", &summary);
|
||||
let summary = parse_stats(&out)?;
|
||||
ctx.cache.set("beads_stats", &summary);
|
||||
Some(summary)
|
||||
})?;
|
||||
|
||||
let count: usize = summary.trim().parse().unwrap_or(0);
|
||||
if count == 0 {
|
||||
render_from_summary(ctx, &summary)
|
||||
}
|
||||
|
||||
/// Parse `br stats --json` output into "open:N,wip:N,ready:N,closed:N"
|
||||
fn parse_stats(json: &str) -> Option<String> {
|
||||
let v: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let s = v.get("summary")?;
|
||||
let open = s.get("open_issues")?.as_u64().unwrap_or(0);
|
||||
let wip = s.get("in_progress_issues")?.as_u64().unwrap_or(0);
|
||||
let ready = s.get("ready_issues")?.as_u64().unwrap_or(0);
|
||||
let closed = s.get("closed_issues")?.as_u64().unwrap_or(0);
|
||||
Some(format!(
|
||||
"open:{open},wip:{wip},ready:{ready},closed:{closed}"
|
||||
))
|
||||
}
|
||||
|
||||
fn render_from_summary(ctx: &RenderContext, summary: &str) -> Option<SectionOutput> {
|
||||
let mut open = 0u64;
|
||||
let mut wip = 0u64;
|
||||
let mut ready = 0u64;
|
||||
let mut closed = 0u64;
|
||||
|
||||
for part in summary.split(',') {
|
||||
if let Some((key, val)) = part.split_once(':') {
|
||||
let n: u64 = val.parse().unwrap_or(0);
|
||||
match key {
|
||||
"open" => open = n,
|
||||
"wip" => wip = n,
|
||||
"ready" => ready = n,
|
||||
"closed" => closed = n,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = &ctx.config.sections.beads;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if cfg.show_ready_count && ready > 0 {
|
||||
parts.push(format!("{ready} ready"));
|
||||
}
|
||||
if cfg.show_wip_count && wip > 0 {
|
||||
parts.push(format!("{wip} wip"));
|
||||
}
|
||||
if cfg.show_open_count && open > 0 {
|
||||
parts.push(format!("{open} open"));
|
||||
}
|
||||
if cfg.show_closed_count && closed > 0 {
|
||||
parts.push(format!("{closed} done"));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = format!("{count} ready");
|
||||
let raw = parts.join(" ");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
@@ -52,17 +100,3 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
fn render_from_cache(ctx: &RenderContext, summary: String) -> Option<SectionOutput> {
|
||||
let count: usize = summary.trim().parse().unwrap_or(0);
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
let raw = format!("{count} ready");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
59
src/section/cloud_profile.rs
Normal file
59
src/section/cloud_profile.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.cloud_profile.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// AWS: $AWS_PROFILE (most common)
|
||||
if let Ok(profile) = std::env::var("AWS_PROFILE") {
|
||||
if !profile.is_empty() {
|
||||
return render_cloud(ctx, "aws", &profile);
|
||||
}
|
||||
}
|
||||
|
||||
// GCP: $CLOUDSDK_CORE_PROJECT or $GCLOUD_PROJECT
|
||||
for var in &["CLOUDSDK_CORE_PROJECT", "GCLOUD_PROJECT"] {
|
||||
if let Ok(project) = std::env::var(var) {
|
||||
if !project.is_empty() {
|
||||
return render_cloud(ctx, "gcp", &project);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Azure: $AZURE_SUBSCRIPTION_ID (lightweight check)
|
||||
if let Ok(sub) = std::env::var("AZURE_SUBSCRIPTION_ID") {
|
||||
if !sub.is_empty() {
|
||||
// Truncate subscription ID — they're long UUIDs
|
||||
let display = if sub.len() > 12 {
|
||||
format!("{}...", &sub[..8])
|
||||
} else {
|
||||
sub
|
||||
};
|
||||
return render_cloud(ctx, "az", &display);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn render_cloud(ctx: &RenderContext, provider: &str, name: &str) -> Option<SectionOutput> {
|
||||
let raw = format!("{provider}:{name}");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
let provider_color = match provider {
|
||||
"aws" => "\x1b[38;2;255;153;0m", // AWS orange
|
||||
"gcp" => "\x1b[38;2;66;133;244m", // GCP blue
|
||||
"az" => "\x1b[38;2;0;120;212m", // Azure blue
|
||||
_ => color::DIM,
|
||||
};
|
||||
let reset = color::RESET;
|
||||
let dim = color::DIM;
|
||||
format!("{provider_color}{provider}{reset}{dim}:{reset}{name}")
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
@@ -1,23 +1,51 @@
|
||||
use crate::color;
|
||||
use crate::config::BarStyle;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
/// Render context bar at a given bar_width. Called both at initial render
|
||||
/// and during flex expansion (with wider bar_width).
|
||||
pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOutput> {
|
||||
let pct = ctx.input.context_window.as_ref()?.used_percentage?;
|
||||
let pct = ctx
|
||||
.input
|
||||
.context_window
|
||||
.as_ref()?
|
||||
.used_percentage
|
||||
.unwrap_or(0.0);
|
||||
let pct_int = pct.round() as u16;
|
||||
|
||||
let filled = (u32::from(pct_int) * u32::from(bar_width) / 100) as usize;
|
||||
let empty = bar_width as usize - filled;
|
||||
|
||||
let bar = "=".repeat(filled) + &"-".repeat(empty);
|
||||
let raw = format!("[{bar}] {pct_int}%");
|
||||
let cfg = &ctx.config.sections.context_bar;
|
||||
let thresh = &cfg.thresholds;
|
||||
|
||||
let thresh = &ctx.config.sections.context_bar.thresholds;
|
||||
let color_code = threshold_color(pct, thresh);
|
||||
// Determine characters based on bar_style
|
||||
let (fill_ch, empty_ch) = match cfg.bar_style {
|
||||
BarStyle::Block => {
|
||||
let f = cfg.fill_char.as_deref().unwrap_or("\u{2588}");
|
||||
let e = cfg.empty_char.as_deref().unwrap_or("\u{2591}");
|
||||
(f, e)
|
||||
}
|
||||
BarStyle::Classic => {
|
||||
let f = cfg.fill_char.as_deref().unwrap_or("=");
|
||||
let e = cfg.empty_char.as_deref().unwrap_or("-");
|
||||
(f, e)
|
||||
}
|
||||
};
|
||||
|
||||
// Build raw string (always plain, no ANSI)
|
||||
let bar_raw = fill_ch.repeat(filled) + &empty_ch.repeat(empty);
|
||||
let raw = format!("[{bar_raw}] {pct_int}%");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{color_code}[{bar}] {pct_int}%{}", color::RESET)
|
||||
if cfg.gradient && matches!(cfg.bar_style, BarStyle::Block) {
|
||||
// Per-character gradient coloring
|
||||
render_gradient_bar(filled, empty, bar_width, fill_ch, empty_ch, pct_int)
|
||||
} else {
|
||||
// Single threshold color for entire bar
|
||||
let color_code = threshold_color(pct, thresh);
|
||||
format!("{color_code}[{bar_raw}] {pct_int}%{}", color::RESET)
|
||||
}
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
@@ -25,6 +53,41 @@ pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOut
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
/// Build a gradient-colored context bar. Each filled char gets a color
|
||||
/// from a green→yellow→red gradient based on its position.
|
||||
fn render_gradient_bar(
|
||||
filled: usize,
|
||||
empty: usize,
|
||||
bar_width: u16,
|
||||
fill_ch: &str,
|
||||
empty_ch: &str,
|
||||
pct_int: u16,
|
||||
) -> String {
|
||||
let grad = color::make_gradient(&["#50fa7b", "#f1fa8c", "#ff5555"]);
|
||||
let bw = bar_width as f32;
|
||||
|
||||
let mut result = String::with_capacity(filled * 20 + empty * 10 + 20);
|
||||
result.push('[');
|
||||
|
||||
for i in 0..filled {
|
||||
let t = if bw > 1.0 { i as f32 / (bw - 1.0) } else { 0.0 };
|
||||
result.push_str(&color::sample_fg(&grad, t));
|
||||
result.push_str(fill_ch);
|
||||
}
|
||||
|
||||
if empty > 0 {
|
||||
result.push_str(color::DIM);
|
||||
for _ in 0..empty {
|
||||
result.push_str(empty_ch);
|
||||
}
|
||||
}
|
||||
|
||||
result.push_str(color::RESET);
|
||||
result.push_str(&format!("] {pct_int}%"));
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.context_bar.base.enabled {
|
||||
return None;
|
||||
|
||||
@@ -8,19 +8,38 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pct = ctx.input.context_window.as_ref()?.used_percentage?;
|
||||
let pct = ctx
|
||||
.input
|
||||
.context_window
|
||||
.as_ref()?
|
||||
.used_percentage
|
||||
.unwrap_or(0.0);
|
||||
let pct_int = pct.round() as i64;
|
||||
|
||||
let width = ctx.config.sections.context_trend.width as usize;
|
||||
let csv = trend::append(
|
||||
|
||||
// Track context consumption *rate* (delta between samples) so the
|
||||
// sparkline shows how fast context is being eaten, not just "it goes up".
|
||||
let csv = trend::append_delta(
|
||||
ctx.cache,
|
||||
"context",
|
||||
pct_int,
|
||||
width,
|
||||
Duration::from_secs(30),
|
||||
)?;
|
||||
let spark = trend::sparkline(&csv, width);
|
||||
|
||||
// Try gradient sparkline first (green→yellow→red for context consumption rate)
|
||||
if ctx.color_enabled && ctx.config.sections.context_trend.gradient {
|
||||
let grad = color::make_gradient(&["#50fa7b", "#f1fa8c", "#ff5555"]);
|
||||
if let Some((raw, ansi)) = trend::sparkline_colored(&csv, width, &grad) {
|
||||
if !raw.is_empty() {
|
||||
return Some(SectionOutput { raw, ansi });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain sparkline with single threshold color
|
||||
let spark = trend::sparkline(&csv, width);
|
||||
if spark.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -8,18 +8,32 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
}
|
||||
|
||||
let cw = ctx.input.context_window.as_ref()?;
|
||||
let pct = cw.used_percentage?;
|
||||
let pct_int = pct.round() as u64;
|
||||
let pct = cw.used_percentage.unwrap_or(0.0);
|
||||
|
||||
let total_input = cw.total_input_tokens.unwrap_or(0);
|
||||
let total_output = cw.total_output_tokens.unwrap_or(0);
|
||||
let used = total_input + total_output;
|
||||
let capacity = cw
|
||||
.context_window_size
|
||||
.unwrap_or(ctx.config.sections.context_usage.capacity);
|
||||
|
||||
// Prefer current_usage fields (input + cache tokens = actual context size).
|
||||
// Fall back to percentage-derived estimate when current_usage is absent.
|
||||
let used = cw
|
||||
.current_usage
|
||||
.as_ref()
|
||||
.and_then(|cu| {
|
||||
let input = cu.input_tokens.unwrap_or(0);
|
||||
let cache_create = cu.cache_creation_input_tokens.unwrap_or(0);
|
||||
let cache_read = cu.cache_read_input_tokens.unwrap_or(0);
|
||||
let sum = input + cache_create + cache_read;
|
||||
if sum > 0 {
|
||||
Some(sum)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| (pct / 100.0 * capacity as f64).round() as u64);
|
||||
|
||||
let raw = format!(
|
||||
"{}/{} ({pct_int}%)",
|
||||
"{}/{} ({pct:.1}%)",
|
||||
format::human_tokens(used),
|
||||
format::human_tokens(capacity),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?;
|
||||
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd.unwrap_or(0.0);
|
||||
|
||||
let decimals = match ctx.width_tier {
|
||||
WidthTier::Narrow => 0,
|
||||
|
||||
@@ -8,19 +8,35 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?;
|
||||
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd.unwrap_or(0.0);
|
||||
let cost_cents = (cost_val * 100.0) as i64;
|
||||
|
||||
let width = ctx.config.sections.cost_trend.width as usize;
|
||||
let csv = trend::append(
|
||||
|
||||
// Track cost *rate* (delta between samples) so the sparkline shows
|
||||
// burn intensity over time, not just "cost goes up".
|
||||
let csv = trend::append_delta(
|
||||
ctx.cache,
|
||||
"cost",
|
||||
cost_cents,
|
||||
width,
|
||||
Duration::from_secs(30),
|
||||
)?;
|
||||
let spark = trend::sparkline(&csv, width);
|
||||
|
||||
// Try gradient sparkline (green→yellow→red — higher burn = more red)
|
||||
if ctx.color_enabled && ctx.config.sections.cost_trend.gradient {
|
||||
let grad = color::make_gradient(&["#50fa7b", "#f1fa8c", "#ff5555"]);
|
||||
if let Some((spark_raw, spark_ansi)) = trend::sparkline_colored(&csv, width, &grad) {
|
||||
if !spark_raw.is_empty() {
|
||||
let raw = format!("${spark_raw}");
|
||||
let ansi = format!("${spark_ansi}");
|
||||
return Some(SectionOutput { raw, ansi });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: plain sparkline with DIM
|
||||
let spark = trend::sparkline(&csv, width);
|
||||
if spark.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let velocity = ctx.metrics.cost_velocity?;
|
||||
ctx.input.cost.as_ref()?;
|
||||
let velocity = ctx.metrics.cost_velocity.unwrap_or(0.0);
|
||||
let raw = format!("${velocity:.2}/min");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
|
||||
@@ -7,10 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ms = ctx.input.cost.as_ref()?.total_duration_ms?;
|
||||
if ms == 0 {
|
||||
return None;
|
||||
}
|
||||
let ms = ctx.input.cost.as_ref()?.total_duration_ms.unwrap_or(0);
|
||||
|
||||
let raw = format::human_duration(ms);
|
||||
let ansi = if ctx.color_enabled {
|
||||
|
||||
134
src/section/k8s_context.rs
Normal file
134
src/section/k8s_context.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.k8s_context.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(ctx.config.sections.k8s_context.ttl);
|
||||
let cached = ctx.cache.get("k8s_context", ttl);
|
||||
|
||||
let context_str = cached.or_else(|| {
|
||||
let val = read_kubeconfig_context()?;
|
||||
ctx.cache.set("k8s_context", &val);
|
||||
Some(val)
|
||||
})?;
|
||||
|
||||
// context_str format: "context" or "context/namespace"
|
||||
if context_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = context_str.clone();
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
// Split context and namespace for different coloring
|
||||
if let Some((context, ns)) = context_str.split_once('/') {
|
||||
let reset = color::RESET;
|
||||
let dim = color::DIM;
|
||||
format!("\x1b[38;2;50;150;250m{context}{reset}{dim}/{ns}{reset}")
|
||||
} else {
|
||||
format!("\x1b[38;2;50;150;250m{context_str}{}", color::RESET)
|
||||
}
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
/// Read current-context (and optionally namespace) from kubeconfig.
|
||||
/// Parses YAML minimally without a full YAML parser dependency.
|
||||
fn read_kubeconfig_context() -> Option<String> {
|
||||
let home = std::env::var("HOME").ok()?;
|
||||
let kubeconfig = std::env::var("KUBECONFIG")
|
||||
.ok()
|
||||
.unwrap_or_else(|| format!("{home}/.kube/config"));
|
||||
|
||||
// Only read the first kubeconfig file if multiple are specified
|
||||
let path = kubeconfig.split(':').next()?;
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
|
||||
// Extract current-context with simple line scanning (no YAML dep)
|
||||
let current_context = content
|
||||
.lines()
|
||||
.find(|line| line.starts_with("current-context:"))?
|
||||
.trim_start_matches("current-context:")
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.to_string();
|
||||
|
||||
if current_context.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try to find the namespace for this context in the contexts list.
|
||||
// Look for the context entry and its namespace field.
|
||||
let ns = find_context_namespace(&content, ¤t_context);
|
||||
|
||||
if let Some(ns) = ns {
|
||||
if !ns.is_empty() && ns != "default" {
|
||||
return Some(format!("{current_context}/{ns}"));
|
||||
}
|
||||
}
|
||||
|
||||
Some(current_context)
|
||||
}
|
||||
|
||||
/// Minimal YAML scanning to find namespace for a given context name.
|
||||
fn find_context_namespace(content: &str, context_name: &str) -> Option<String> {
|
||||
let mut in_contexts = false;
|
||||
let mut found_context = false;
|
||||
let mut in_context_block = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed == "contexts:" {
|
||||
in_contexts = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_contexts && !line.starts_with(' ') && !line.starts_with('-') && !trimmed.is_empty() {
|
||||
// Left the contexts block
|
||||
break;
|
||||
}
|
||||
|
||||
if in_contexts {
|
||||
// Look for "- name: <context_name>"
|
||||
if trimmed.starts_with("- name:") || trimmed == "- name:" {
|
||||
let name = trimmed
|
||||
.trim_start_matches("- name:")
|
||||
.trim()
|
||||
.trim_matches('"');
|
||||
found_context = name == context_name;
|
||||
in_context_block = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if found_context && trimmed.starts_with("context:") {
|
||||
in_context_block = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if found_context && in_context_block && trimmed.starts_with("namespace:") {
|
||||
return Some(
|
||||
trimmed
|
||||
.trim_start_matches("namespace:")
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// New entry starts
|
||||
if trimmed.starts_with("- ") && found_context {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -4,12 +4,14 @@ use crate::input::InputData;
|
||||
use crate::metrics::ComputedMetrics;
|
||||
use crate::shell::ShellConfig;
|
||||
use crate::theme::Theme;
|
||||
use crate::transcript::TranscriptStats;
|
||||
use crate::width::WidthTier;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub mod beads;
|
||||
pub mod cache_efficiency;
|
||||
pub mod cloud_profile;
|
||||
pub mod context_bar;
|
||||
pub mod context_remaining;
|
||||
pub mod context_trend;
|
||||
@@ -20,15 +22,18 @@ pub mod cost_velocity;
|
||||
pub mod custom;
|
||||
pub mod duration;
|
||||
pub mod hostname;
|
||||
pub mod k8s_context;
|
||||
pub mod lines_changed;
|
||||
pub mod load;
|
||||
pub mod model;
|
||||
pub mod output_style;
|
||||
pub mod project;
|
||||
pub mod provider;
|
||||
pub mod python_env;
|
||||
pub mod time;
|
||||
pub mod token_velocity;
|
||||
pub mod tokens_raw;
|
||||
pub mod toolchain;
|
||||
pub mod tools;
|
||||
pub mod turns;
|
||||
pub mod vcs;
|
||||
@@ -69,6 +74,8 @@ pub struct RenderContext<'a> {
|
||||
pub budget_start: Option<std::time::Instant>,
|
||||
pub budget_ms: u64,
|
||||
pub shell_results: std::collections::HashMap<String, Option<String>>,
|
||||
pub transcript_stats: Option<TranscriptStats>,
|
||||
pub terminal_palette: Option<Vec<(u8, u8, u8)>>,
|
||||
}
|
||||
|
||||
impl RenderContext<'_> {
|
||||
@@ -316,6 +323,42 @@ pub fn registry() -> Vec<SectionDescriptor> {
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "cloud_profile",
|
||||
render: cloud_profile::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 15,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "k8s_context",
|
||||
render: k8s_context::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 15,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "python_env",
|
||||
render: python_env::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 12,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "toolchain",
|
||||
render: toolchain::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 12,
|
||||
shell_out: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
40
src/section/python_env.rs
Normal file
40
src/section/python_env.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.python_env.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// $VIRTUAL_ENV — standard venv/virtualenv/pipenv
|
||||
if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
|
||||
if !venv.is_empty() {
|
||||
let name = std::path::Path::new(&venv)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("venv");
|
||||
return render_env(ctx, name);
|
||||
}
|
||||
}
|
||||
|
||||
// $CONDA_DEFAULT_ENV — conda environments
|
||||
if let Ok(conda) = std::env::var("CONDA_DEFAULT_ENV") {
|
||||
if !conda.is_empty() && conda != "base" {
|
||||
return render_env(ctx, &conda);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn render_env(ctx: &RenderContext, name: &str) -> Option<SectionOutput> {
|
||||
let raw = format!("({name})");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
@@ -7,7 +7,8 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let velocity = ctx.metrics.token_velocity?;
|
||||
ctx.input.cost.as_ref()?;
|
||||
let velocity = ctx.metrics.token_velocity.unwrap_or(0.0);
|
||||
let raw = format!("{} tok/min", format::human_tokens(velocity as u64));
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
|
||||
@@ -11,10 +11,6 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
let input_tok = cw.total_input_tokens.unwrap_or(0);
|
||||
let output_tok = cw.total_output_tokens.unwrap_or(0);
|
||||
|
||||
if input_tok == 0 && output_tok == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = ctx
|
||||
.config
|
||||
.sections
|
||||
|
||||
127
src/section/toolchain.rs
Normal file
127
src/section/toolchain.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.toolchain.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dir = ctx.project_dir;
|
||||
|
||||
// Try Rust toolchain detection
|
||||
if let Some(out) = detect_rust(dir) {
|
||||
return Some(out.render(ctx));
|
||||
}
|
||||
|
||||
// Try Node.js version detection
|
||||
if let Some(out) = detect_node(dir) {
|
||||
return Some(out.render(ctx));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
struct ToolchainInfo {
|
||||
lang: &'static str,
|
||||
expected: String,
|
||||
actual: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolchainInfo {
|
||||
fn render(&self, ctx: &RenderContext) -> SectionOutput {
|
||||
let mismatch = self
|
||||
.actual
|
||||
.as_ref()
|
||||
.is_some_and(|a| !self.expected.contains(a.as_str()));
|
||||
|
||||
let raw = if mismatch {
|
||||
format!(
|
||||
"{} {} (want {})",
|
||||
self.lang,
|
||||
self.actual.as_deref().unwrap_or("?"),
|
||||
self.expected
|
||||
)
|
||||
} else {
|
||||
format!("{} {}", self.lang, self.expected)
|
||||
};
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
if mismatch {
|
||||
format!("{}{raw}{}", color::YELLOW, color::RESET)
|
||||
} else {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
}
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
SectionOutput { raw, ansi }
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_rust(dir: &std::path::Path) -> Option<ToolchainInfo> {
|
||||
// Check rust-toolchain.toml first, then rust-toolchain
|
||||
let toml_path = dir.join("rust-toolchain.toml");
|
||||
let legacy_path = dir.join("rust-toolchain");
|
||||
|
||||
let expected = if toml_path.is_file() {
|
||||
parse_rust_toolchain_toml(&toml_path)?
|
||||
} else if legacy_path.is_file() {
|
||||
std::fs::read_to_string(&legacy_path)
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let actual = std::env::var("RUSTUP_TOOLCHAIN").ok();
|
||||
|
||||
Some(ToolchainInfo {
|
||||
lang: "rs",
|
||||
expected,
|
||||
actual,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse channel from rust-toolchain.toml (e.g., `channel = "nightly-2025-01-01"`)
|
||||
fn parse_rust_toolchain_toml(path: &std::path::Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("channel") {
|
||||
let val = trimmed.split('=').nth(1)?.trim().trim_matches('"');
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_node(dir: &std::path::Path) -> Option<ToolchainInfo> {
|
||||
// Check .nvmrc, .node-version, or package.json engines.node
|
||||
let expected = read_file_trimmed(&dir.join(".nvmrc"))
|
||||
.or_else(|| read_file_trimmed(&dir.join(".node-version")))?;
|
||||
|
||||
// Strip leading 'v' for comparison
|
||||
let expected_clean = expected.strip_prefix('v').unwrap_or(&expected).to_string();
|
||||
|
||||
let actual = std::env::var("NODE_VERSION")
|
||||
.ok()
|
||||
.map(|v| v.strip_prefix('v').unwrap_or(&v).to_string());
|
||||
|
||||
Some(ToolchainInfo {
|
||||
lang: "node",
|
||||
expected: expected_clean,
|
||||
actual,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_file_trimmed(path: &std::path::Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let trimmed = content.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,229 @@
|
||||
use crate::color;
|
||||
use crate::format;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use crate::width::WidthTier;
|
||||
|
||||
const DEFAULT_PALETTE: &[(u8, u8, u8)] = &[
|
||||
(139, 233, 253), // cyan
|
||||
(80, 250, 123), // green
|
||||
(189, 147, 249), // purple
|
||||
(255, 121, 198), // pink
|
||||
(255, 184, 108), // orange
|
||||
(241, 250, 140), // yellow
|
||||
(255, 85, 85), // red
|
||||
(98, 173, 255), // blue
|
||||
];
|
||||
|
||||
/// Resolve the tool color palette.
|
||||
/// Priority: config palette (explicit user choice) > terminal ANSI colors > built-in default.
|
||||
fn resolve_palette(
|
||||
cfg: &crate::config::ToolsSection,
|
||||
terminal_palette: &Option<Vec<(u8, u8, u8)>>,
|
||||
) -> Vec<(u8, u8, u8)> {
|
||||
// Explicit config palette takes priority (user chose these colors)
|
||||
if !cfg.palette.is_empty() {
|
||||
let parsed: Vec<(u8, u8, u8)> = cfg
|
||||
.palette
|
||||
.iter()
|
||||
.filter_map(|s| color::parse_hex(s))
|
||||
.collect();
|
||||
if !parsed.is_empty() {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal-queried ANSI palette (auto-matches theme)
|
||||
if let Some(tp) = terminal_palette {
|
||||
if !tp.is_empty() {
|
||||
return tp.clone();
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_PALETTE.to_vec()
|
||||
}
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.tools.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cost = ctx.input.cost.as_ref()?;
|
||||
let count = cost.total_tool_uses.unwrap_or(0);
|
||||
let cfg = &ctx.config.sections.tools;
|
||||
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let last = if ctx.config.sections.tools.show_last_name {
|
||||
cost.last_tool_name
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default()
|
||||
// Prefer cost.total_tool_uses (forward compat if Claude Code adds it),
|
||||
// fall back to transcript-derived stats.
|
||||
let (count, last_name, tool_counts) = if let Some(cost) = ctx.input.cost.as_ref() {
|
||||
if let Some(n) = cost.total_tool_uses {
|
||||
(n, cost.last_tool_name.clone(), Vec::new())
|
||||
} else if let Some(ts) = &ctx.transcript_stats {
|
||||
(
|
||||
ts.total_tool_uses,
|
||||
ts.last_tool_name.clone(),
|
||||
ts.tool_counts.clone(),
|
||||
)
|
||||
} else {
|
||||
(0, None, Vec::new())
|
||||
}
|
||||
} else if let Some(ts) = &ctx.transcript_stats {
|
||||
(
|
||||
ts.total_tool_uses,
|
||||
ts.last_tool_name.clone(),
|
||||
ts.tool_counts.clone(),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
return None;
|
||||
};
|
||||
|
||||
let label = if count == 1 { "tool" } else { "tools" };
|
||||
let raw = format!("{count} {label}{last}");
|
||||
|
||||
if count == 0 {
|
||||
let raw = "0 tools".to_string();
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
return Some(SectionOutput { raw, ansi });
|
||||
}
|
||||
|
||||
// Progressive disclosure based on terminal width:
|
||||
// narrow: "245"
|
||||
// medium: "245 tools (Bash)" — last tool only
|
||||
// wide: "245 tools (Bash: 93/Read: 45/...)" — full breakdown, adaptive count
|
||||
match ctx.width_tier {
|
||||
WidthTier::Narrow => {
|
||||
let raw = count.to_string();
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
WidthTier::Medium => {
|
||||
let detail = if cfg.show_last_name {
|
||||
last_name
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let raw = format!("{count} {label}{detail}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
WidthTier::Wide => {
|
||||
if cfg.show_breakdown && !tool_counts.is_empty() {
|
||||
render_breakdown(ctx, count, label, &tool_counts, cfg)
|
||||
} else {
|
||||
let detail = if cfg.show_last_name {
|
||||
last_name
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let raw = format!("{count} {label}{detail}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the full breakdown, adaptively reducing the number of tools shown
|
||||
/// until it fits within a width budget (1/3 of terminal width).
|
||||
fn render_breakdown(
|
||||
ctx: &RenderContext,
|
||||
count: u64,
|
||||
label: &str,
|
||||
tool_counts: &[(String, u64)],
|
||||
cfg: &crate::config::ToolsSection,
|
||||
) -> Option<SectionOutput> {
|
||||
let max_limit = if cfg.top_n == 0 {
|
||||
tool_counts.len()
|
||||
} else {
|
||||
cfg.top_n.min(tool_counts.len())
|
||||
};
|
||||
|
||||
// Width budget: tools section shouldn't dominate the line
|
||||
let budget = (ctx.term_width as usize) / 3;
|
||||
|
||||
// Try from max_limit down to 1, find the largest that fits the budget
|
||||
let mut limit = max_limit;
|
||||
loop {
|
||||
let raw = build_raw(count, label, tool_counts, limit);
|
||||
let width = format::display_width(&raw);
|
||||
if width <= budget || limit <= 1 {
|
||||
break;
|
||||
}
|
||||
limit -= 1;
|
||||
}
|
||||
|
||||
let raw = build_raw(count, label, tool_counts, limit);
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
let palette = resolve_palette(cfg, &ctx.terminal_palette);
|
||||
build_ansi(count, label, tool_counts, limit, &palette)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
fn build_raw(count: u64, label: &str, tool_counts: &[(String, u64)], limit: usize) -> String {
|
||||
let parts: Vec<String> = tool_counts[..limit]
|
||||
.iter()
|
||||
.map(|(name, c)| format!("{name}: {c}"))
|
||||
.collect();
|
||||
let shown: u64 = tool_counts[..limit].iter().map(|(_, c)| *c).sum();
|
||||
let rest = count.saturating_sub(shown);
|
||||
let mut detail = parts.join("/");
|
||||
if rest > 0 {
|
||||
detail.push_str(&format!(" +{rest}"));
|
||||
}
|
||||
format!("{count} {label} ({detail})")
|
||||
}
|
||||
|
||||
fn build_ansi(
|
||||
count: u64,
|
||||
label: &str,
|
||||
tool_counts: &[(String, u64)],
|
||||
limit: usize,
|
||||
palette: &[(u8, u8, u8)],
|
||||
) -> String {
|
||||
let ansi_parts: Vec<String> = tool_counts[..limit]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, c))| {
|
||||
let (r, g, b) = palette[i % palette.len()];
|
||||
format!("\x1b[38;2;{r};{g};{b}m{name}{}: {c}", color::RESET)
|
||||
})
|
||||
.collect();
|
||||
let sep = format!("{}/{}", color::DIM, color::RESET);
|
||||
let mut ansi_detail = ansi_parts.join(&sep);
|
||||
|
||||
let shown: u64 = tool_counts[..limit].iter().map(|(_, c)| *c).sum();
|
||||
let rest = count.saturating_sub(shown);
|
||||
if rest > 0 {
|
||||
ansi_detail.push_str(&format!("{} +{rest}{}", color::DIM, color::RESET));
|
||||
}
|
||||
|
||||
format!(
|
||||
"{}{count} {label}{} ({ansi_detail}{}){}",
|
||||
color::DIM,
|
||||
color::RESET,
|
||||
color::DIM,
|
||||
color::RESET
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,22 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let count = ctx.input.cost.as_ref()?.total_turns?;
|
||||
if count == 0 {
|
||||
// Prefer cost.total_turns (forward compat if Claude Code adds it),
|
||||
// fall back to transcript-derived stats.
|
||||
// Require at least one data source to exist (cost or transcript).
|
||||
// When neither exists (no session data at all), vanish.
|
||||
if ctx.input.cost.is_none() && ctx.transcript_stats.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let count = ctx
|
||||
.input
|
||||
.cost
|
||||
.as_ref()
|
||||
.and_then(|c| c.total_turns)
|
||||
.or_else(|| ctx.transcript_stats.as_ref().map(|ts| ts.total_turns))
|
||||
.unwrap_or(0);
|
||||
|
||||
let label = if count == 1 { "turn" } else { "turns" };
|
||||
let raw = format!("{count} {label}");
|
||||
|
||||
|
||||
177
src/trend.rs
177
src/trend.rs
@@ -1,10 +1,19 @@
|
||||
use crate::cache::Cache;
|
||||
use crate::color;
|
||||
use std::time::Duration;
|
||||
|
||||
const SPARKLINE_CHARS: &[char] = &[
|
||||
'\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}',
|
||||
];
|
||||
|
||||
/// Baseline sparkline placeholder (space — "no data yet").
|
||||
const BASELINE_CHAR: &str = " ";
|
||||
const BASELINE_CHAR_CH: char = ' ';
|
||||
|
||||
/// Flatline sparkline character (lowest bar — "data exists but is flat/zero").
|
||||
const FLATLINE_CHAR: char = '\u{2581}';
|
||||
const FLATLINE_STR: &str = "\u{2581}";
|
||||
|
||||
/// Append a value to a trend file. Throttled to at most once per `interval`.
|
||||
/// Returns the full comma-separated series (for immediate sparkline rendering).
|
||||
pub fn append(
|
||||
@@ -57,7 +66,62 @@ pub fn append(
|
||||
Some(csv)
|
||||
}
|
||||
|
||||
/// Append a **delta** (difference from previous cumulative value) to a trend.
|
||||
/// Useful for rate-of-change sparklines (e.g., cost burn rate).
|
||||
/// Stores the previous cumulative value in a separate cache key for delta computation.
|
||||
pub fn append_delta(
|
||||
cache: &Cache,
|
||||
key: &str,
|
||||
cumulative_value: i64,
|
||||
max_points: usize,
|
||||
interval: Duration,
|
||||
) -> Option<String> {
|
||||
let trend_key = format!("trend_{key}");
|
||||
let prev_key = format!("trend_{key}_prev");
|
||||
|
||||
// Check throttle: skip if last write was within interval
|
||||
if let Some(existing) = cache.get(&trend_key, interval) {
|
||||
return Some(existing);
|
||||
}
|
||||
|
||||
// Get previous cumulative value for delta computation
|
||||
let prev = cache
|
||||
.get_stale(&prev_key)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(cumulative_value);
|
||||
|
||||
let delta = cumulative_value - prev;
|
||||
|
||||
// Store current cumulative for next delta
|
||||
cache.set(&prev_key, &cumulative_value.to_string());
|
||||
|
||||
// Read existing series (ignoring TTL)
|
||||
let mut series: Vec<i64> = cache
|
||||
.get_stale(&trend_key)
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect();
|
||||
|
||||
series.push(delta);
|
||||
|
||||
// Trim from left to max_points
|
||||
if series.len() > max_points {
|
||||
series = series[series.len() - max_points..].to_vec();
|
||||
}
|
||||
|
||||
let csv = series
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
cache.set(&trend_key, &csv);
|
||||
Some(csv)
|
||||
}
|
||||
|
||||
/// Render a sparkline from comma-separated values.
|
||||
/// Always renders exactly `width` characters — pads with baseline chars
|
||||
/// on the left when fewer data points exist.
|
||||
pub fn sparkline(csv: &str, width: usize) -> String {
|
||||
let vals: Vec<i64> = csv
|
||||
.split(',')
|
||||
@@ -65,23 +129,116 @@ pub fn sparkline(csv: &str, width: usize) -> String {
|
||||
.collect();
|
||||
|
||||
if vals.is_empty() {
|
||||
return String::new();
|
||||
return BASELINE_CHAR.repeat(width);
|
||||
}
|
||||
|
||||
let min = *vals.iter().min().unwrap();
|
||||
let max = *vals.iter().max().unwrap();
|
||||
let count = vals.len().min(width);
|
||||
let data_count = vals.len().min(width);
|
||||
let pad_count = width.saturating_sub(data_count);
|
||||
|
||||
if max == min {
|
||||
return "\u{2584}".repeat(count);
|
||||
// Data exists but is flat — show visible lowest bars (not invisible spaces)
|
||||
let mut result = String::with_capacity(width * 3);
|
||||
for _ in 0..pad_count {
|
||||
result.push(BASELINE_CHAR_CH);
|
||||
}
|
||||
for _ in 0..data_count {
|
||||
result.push(FLATLINE_CHAR);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let range = (max - min) as f64;
|
||||
vals.iter()
|
||||
.take(width)
|
||||
.map(|&v| {
|
||||
let idx = (((v - min) as f64 / range) * 7.0) as usize;
|
||||
SPARKLINE_CHARS[idx.min(7)]
|
||||
})
|
||||
.collect()
|
||||
let mut result = String::with_capacity(width * 3);
|
||||
|
||||
// Left-pad with baseline chars
|
||||
for _ in 0..pad_count {
|
||||
result.push(BASELINE_CHAR_CH);
|
||||
}
|
||||
|
||||
// Render data points (take the last `data_count` from available values,
|
||||
// capped to `width`)
|
||||
let skip = vals.len().saturating_sub(width);
|
||||
for &v in vals.iter().skip(skip) {
|
||||
let idx = (((v - min) as f64 / range) * 7.0) as usize;
|
||||
result.push(SPARKLINE_CHARS[idx.min(7)]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Render a sparkline with per-character gradient coloring.
|
||||
/// Each character is colored based on its normalized value (0.0=min, 1.0=max).
|
||||
/// Always renders exactly `width` characters — pads with DIM baseline chars on the left.
|
||||
/// Returns (raw, ansi) — raw is plain sparkline, ansi has gradient colors.
|
||||
pub fn sparkline_colored(
|
||||
csv: &str,
|
||||
width: usize,
|
||||
grad: &colorgrad::LinearGradient,
|
||||
) -> Option<(String, String)> {
|
||||
let vals: Vec<i64> = csv
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect();
|
||||
|
||||
if vals.is_empty() {
|
||||
let raw = BASELINE_CHAR.repeat(width);
|
||||
let ansi = format!("{}{raw}{}", color::DIM, color::RESET);
|
||||
return Some((raw, ansi));
|
||||
}
|
||||
|
||||
let min = *vals.iter().min().unwrap();
|
||||
let max = *vals.iter().max().unwrap();
|
||||
let data_count = vals.len().min(width);
|
||||
let pad_count = width.saturating_sub(data_count);
|
||||
|
||||
if max == min {
|
||||
// Data exists but is flat — show visible lowest bars with DIM styling
|
||||
let mut raw = String::with_capacity(width * 3);
|
||||
let mut ansi = String::with_capacity(width * 20);
|
||||
for _ in 0..pad_count {
|
||||
raw.push(BASELINE_CHAR_CH);
|
||||
}
|
||||
if pad_count > 0 {
|
||||
ansi.push_str(color::DIM);
|
||||
ansi.push_str(&BASELINE_CHAR.repeat(pad_count));
|
||||
ansi.push_str(color::RESET);
|
||||
}
|
||||
let flatline = FLATLINE_STR.repeat(data_count);
|
||||
raw.push_str(&flatline);
|
||||
ansi.push_str(color::DIM);
|
||||
ansi.push_str(&flatline);
|
||||
ansi.push_str(color::RESET);
|
||||
return Some((raw, ansi));
|
||||
}
|
||||
|
||||
let range = (max - min) as f32;
|
||||
let mut raw = String::with_capacity(width * 3);
|
||||
let mut ansi = String::with_capacity(width * 20);
|
||||
|
||||
// Left-pad with DIM baseline chars
|
||||
if pad_count > 0 {
|
||||
let pad_str = BASELINE_CHAR.repeat(pad_count);
|
||||
raw.push_str(&pad_str);
|
||||
ansi.push_str(color::DIM);
|
||||
ansi.push_str(&pad_str);
|
||||
ansi.push_str(color::RESET);
|
||||
}
|
||||
|
||||
// Render data points (take the last `width` values)
|
||||
let skip = vals.len().saturating_sub(width);
|
||||
for &v in vals.iter().skip(skip) {
|
||||
let norm = (v - min) as f32 / range;
|
||||
let idx = (norm * 7.0) as usize;
|
||||
let ch = SPARKLINE_CHARS[idx.min(7)];
|
||||
|
||||
raw.push(ch);
|
||||
ansi.push_str(&color::sample_fg(grad, norm));
|
||||
ansi.push(ch);
|
||||
}
|
||||
|
||||
ansi.push_str(color::RESET);
|
||||
|
||||
Some((raw, ansi))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user