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:
Taylor Eernisse
2026-02-09 23:42:34 -05:00
parent e0c4a0fa9a
commit c03b0b1bd7
23 changed files with 1264 additions and 92 deletions

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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,
});
}
}
}
}
}
}

View File

@@ -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);

View File

@@ -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 })
}

View 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 })
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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),
);

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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
View 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, &current_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
}

View File

@@ -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
View 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 })
}

View File

@@ -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 {

View File

@@ -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
View 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)
}
}

View File

@@ -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
)
}

View File

@@ -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}");

View File

@@ -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))
}