From c03b0b1bd7b8dc01af113c0682f177103823a26d Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Mon, 9 Feb 2026 23:42:34 -0500 Subject: [PATCH] feat: add environment sections, visual enhancements, enhanced tools/beads, and /clear detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/bin/claude-statusline.rs | 121 +++++++++++++++++- src/config.rs | 50 ++++++++ src/format.rs | 34 ++++++ src/layout/mod.rs | 70 ++++++++++- src/metrics.rs | 14 ++- src/section/beads.rs | 84 +++++++++---- src/section/cloud_profile.rs | 59 +++++++++ src/section/context_bar.rs | 75 +++++++++++- src/section/context_trend.rs | 25 +++- src/section/context_usage.rs | 26 +++- src/section/cost.rs | 2 +- src/section/cost_trend.rs | 22 +++- src/section/cost_velocity.rs | 3 +- src/section/duration.rs | 5 +- src/section/k8s_context.rs | 134 ++++++++++++++++++++ src/section/mod.rs | 43 +++++++ src/section/python_env.rs | 40 ++++++ src/section/token_velocity.rs | 3 +- src/section/tokens_raw.rs | 4 - src/section/toolchain.rs | 127 +++++++++++++++++++ src/section/tools.rs | 223 +++++++++++++++++++++++++++++++--- src/section/turns.rs | 15 ++- src/trend.rs | 177 +++++++++++++++++++++++++-- 23 files changed, 1264 insertions(+), 92 deletions(-) create mode 100644 src/section/cloud_profile.rs create mode 100644 src/section/k8s_context.rs create mode 100644 src/section/python_env.rs create mode 100644 src/section/toolchain.rs diff --git a/src/bin/claude-statusline.rs b/src/bin/claude-statusline.rs index 5e74d60..8e999aa 100644 --- a/src/bin/claude-statusline.rs +++ b/src/bin/claude-statusline.rs @@ -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 { + // 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> { + 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 { diff --git a/src/config.rs b/src/config.rs index 4b5350b..38f521a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, @@ -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, pub align: Option, pub color: Option, + pub background: Option, + pub placeholder: Option, } 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, + pub empty_char: Option, 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, 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, } } diff --git a/src/format.rs b/src/format.rs index b4c0a52..ae069f9 100644 --- a/src/format.rs +++ b/src/format.rs @@ -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) +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 88c66d6..7f28777 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -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 = 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, + }); + } + } + } + } } } diff --git a/src/metrics.rs b/src/metrics.rs index 6804d4c..317fd80 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -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); diff --git a/src/section/beads.rs b/src/section/beads.rs index fdf7649..3d2303b 100644 --- a/src/section/beads.rs +++ b/src/section/beads.rs @@ -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 { if !ctx.config.sections.beads.base.enabled { return None; @@ -15,35 +16,82 @@ pub fn render(ctx: &RenderContext) -> Option { // --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 { + 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 { + 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 = 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 { Some(SectionOutput { raw, ansi }) } - -fn render_from_cache(ctx: &RenderContext, summary: String) -> Option { - 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 }) -} diff --git a/src/section/cloud_profile.rs b/src/section/cloud_profile.rs new file mode 100644 index 0000000..6b23274 --- /dev/null +++ b/src/section/cloud_profile.rs @@ -0,0 +1,59 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option { + 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 { + 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 }) +} diff --git a/src/section/context_bar.rs b/src/section/context_bar.rs index 0acde7a..319aa73 100644 --- a/src/section/context_bar.rs +++ b/src/section/context_bar.rs @@ -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 { - 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 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 { if !ctx.config.sections.context_bar.base.enabled { return None; diff --git a/src/section/context_trend.rs b/src/section/context_trend.rs index 5207003..1c64ece 100644 --- a/src/section/context_trend.rs +++ b/src/section/context_trend.rs @@ -8,19 +8,38 @@ pub fn render(ctx: &RenderContext) -> Option { 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; } diff --git a/src/section/context_usage.rs b/src/section/context_usage.rs index 202ebd5..0395ae8 100644 --- a/src/section/context_usage.rs +++ b/src/section/context_usage.rs @@ -8,18 +8,32 @@ pub fn render(ctx: &RenderContext) -> Option { } 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), ); diff --git a/src/section/cost.rs b/src/section/cost.rs index 347ea48..abb5e36 100644 --- a/src/section/cost.rs +++ b/src/section/cost.rs @@ -7,7 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option { 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, diff --git a/src/section/cost_trend.rs b/src/section/cost_trend.rs index ced2389..2c9d699 100644 --- a/src/section/cost_trend.rs +++ b/src/section/cost_trend.rs @@ -8,19 +8,35 @@ pub fn render(ctx: &RenderContext) -> Option { 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; } diff --git a/src/section/cost_velocity.rs b/src/section/cost_velocity.rs index 7534372..499bf1c 100644 --- a/src/section/cost_velocity.rs +++ b/src/section/cost_velocity.rs @@ -6,7 +6,8 @@ pub fn render(ctx: &RenderContext) -> Option { 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 { diff --git a/src/section/duration.rs b/src/section/duration.rs index 4421809..104b96a 100644 --- a/src/section/duration.rs +++ b/src/section/duration.rs @@ -7,10 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option { 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 { diff --git a/src/section/k8s_context.rs b/src/section/k8s_context.rs new file mode 100644 index 0000000..2f90822 --- /dev/null +++ b/src/section/k8s_context.rs @@ -0,0 +1,134 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use std::time::Duration; + +pub fn render(ctx: &RenderContext) -> Option { + 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 { + 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 { + 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: " + 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 +} diff --git a/src/section/mod.rs b/src/section/mod.rs index d1208a1..bfae40e 100644 --- a/src/section/mod.rs +++ b/src/section/mod.rs @@ -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, pub budget_ms: u64, pub shell_results: std::collections::HashMap>, + pub transcript_stats: Option, + pub terminal_palette: Option>, } impl RenderContext<'_> { @@ -316,6 +323,42 @@ pub fn registry() -> Vec { 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, + }, ] } diff --git a/src/section/python_env.rs b/src/section/python_env.rs new file mode 100644 index 0000000..fcfb099 --- /dev/null +++ b/src/section/python_env.rs @@ -0,0 +1,40 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option { + 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 { + let raw = format!("({name})"); + + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/token_velocity.rs b/src/section/token_velocity.rs index 6dc46ec..2c952af 100644 --- a/src/section/token_velocity.rs +++ b/src/section/token_velocity.rs @@ -7,7 +7,8 @@ pub fn render(ctx: &RenderContext) -> Option { 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 { diff --git a/src/section/tokens_raw.rs b/src/section/tokens_raw.rs index 566ca4e..fa28711 100644 --- a/src/section/tokens_raw.rs +++ b/src/section/tokens_raw.rs @@ -11,10 +11,6 @@ pub fn render(ctx: &RenderContext) -> Option { 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 diff --git a/src/section/toolchain.rs b/src/section/toolchain.rs new file mode 100644 index 0000000..2c8a024 --- /dev/null +++ b/src/section/toolchain.rs @@ -0,0 +1,127 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option { + 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, +} + +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 { + // 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 { + 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 { + // 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 { + let content = std::fs::read_to_string(path).ok()?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} diff --git a/src/section/tools.rs b/src/section/tools.rs index 663d95f..3f4665e 100644 --- a/src/section/tools.rs +++ b/src/section/tools.rs @@ -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)> { + // 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 { 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 { + 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 = 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 = 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 + ) +} diff --git a/src/section/turns.rs b/src/section/turns.rs index 43c1c2e..aec248e 100644 --- a/src/section/turns.rs +++ b/src/section/turns.rs @@ -6,11 +6,22 @@ pub fn render(ctx: &RenderContext) -> Option { 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}"); diff --git a/src/trend.rs b/src/trend.rs index 7d138b6..fc6e243 100644 --- a/src/trend.rs +++ b/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 { + 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::().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 = 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::>() + .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 = 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 = 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)) }