From e5b18b17ff5342d9217abfefbf98338e75c01857 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Mon, 9 Feb 2026 23:43:12 -0500 Subject: [PATCH] chore: remove completed PRD and superseded bash implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust_prd.md (3,091 lines): The Rust port PRD has been fully implemented — all beads closed, all features delivered across 7 prior commits. Keeping the PRD in-repo adds confusion about what's aspirational vs actual. statusline.sh (2,245 lines): The original bash implementation is fully superseded by the Rust binary (claude-statusline). The bash script had fundamental limitations: ~200ms render time, no caching, no gradient support, no per-tool breakdown, and fragile process-tree width detection. The Rust binary renders in <5ms with full feature parity and then some. install.sh now targets the Rust binary exclusively. Co-Authored-By: Claude Opus 4.6 --- rust_prd.md | 3091 ------------------------------------------------- statusline.sh | 2245 ----------------------------------- 2 files changed, 5336 deletions(-) delete mode 100644 rust_prd.md delete mode 100755 statusline.sh diff --git a/rust_prd.md b/rust_prd.md deleted file mode 100644 index 0c8401d..0000000 --- a/rust_prd.md +++ /dev/null @@ -1,3091 +0,0 @@ -# Rust Port: claude-statusline - -## Why - -The bash version (~2236 lines) spawns ~233 subshells per render. Each fork+exec costs ~1-3ms on macOS. Measured: **670ms average per render** against a 300ms render cycle. The perf floor is structural. A Rust port targets **<1ms warm, <5ms cold**. - -## Contract - -- **Input**: JSON on stdin from Claude Code (model, cost, tokens, context, workspace, version, output_style) -- **Output**: Multi-line ANSI-colored text on stdout -- **Config**: JSON deep-merged with embedded defaults. Search order: `--config `, `CLAUDE_STATUSLINE_CONFIG`, `$XDG_CONFIG_HOME/claude/statusline.json`, `~/.config/claude/statusline.json`, `~/.claude/statusline.json` -- **CLI**: `--help`, `--test`, `--dump-state[=text|json]`, `--validate-config`, `--config-schema`, `--list-sections`, `--print-defaults`, `--config `, `--no-cache`, `--no-shell`, `--clear-cache`, `--width `, `--color=auto|always|never` -- **Env**: `NO_COLOR`, `CLAUDE_STATUSLINE_COLOR`, `CLAUDE_STATUSLINE_WIDTH`, `CLAUDE_STATUSLINE_NO_CACHE`, `CLAUDE_STATUSLINE_NO_SHELL` -- **Cache**: File-based in `/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/` with per-key TTLs (compatible with bash version) -- **VCS**: Shell out to git/jj (not libgit2) - ---- - -## Crate Structure: Single package with lib + bin - -No workspace. Keep one package, but split core logic into `src/lib.rs` -and a thin `src/bin/claude-statusline.rs` wrapper. This keeps the deliverable -a single binary while making tests/benchmarks reuse library code. - -``` -Cargo.toml -build.rs # embed defaults.json + schema.json -src/ - lib.rs # Core API: parse, render, layout - bin/ - claude-statusline.rs # CLI parsing, stdin, orchestration - error.rs # Error enum with From impls - config.rs # Config loading, deep merge, typed structs - input.rs # InputData struct (serde from stdin JSON) - theme.rs # Theme detection (COLORFGBG, config override) - color.rs # ANSI codes, palette resolution, color_by_name() - glyph.rs # Glyph system (Nerd Font + ASCII fallback) - width.rs # Terminal width detection (ioctl + process tree walk chain), memoized - cache.rs # File-based caching, secure dir, TTL via mtime - trend.rs # Trend tracking + sparkline (8 Unicode blocks) - format.rs # human_tokens, human_duration, truncation (grapheme-safe), apply_formatting - shell.rs # exec_with_timeout, GIT_ENV, parse_git_status_v2, circuit breaker - metrics.rs # Derived metrics from input (cost velocity, token velocity, usage %, totals) - section/ - mod.rs # SectionOutput{raw,ansi}, dispatch(RenderContext), registry + metadata - model.rs # [Opus 4.6] - bold - provider.rs # Bedrock/Vertex/Anthropic - dim - project.rs # dirname - cyan - vcs.rs # branch+dirty+ahead/behind - combined git status, jj shell-out - beads.rs # br status - shell-out - context_bar.rs # [====------] 58% - threshold colors, flex rebuild - context_usage.rs # 115k/200k - threshold colors - context_remaining.rs # 85k left - threshold colors - tokens_raw.rs # progressive disclosure by width tier - cache_efficiency.rs # cache:83% - dim/green/boldgreen - cost.rs # $0.42 - threshold colors, width-tier decimals - cost_velocity.rs # $0.03/m - dim - token_velocity.rs # 14.5ktok/m - dim - cost_trend.rs # sparkline - dim - context_trend.rs # sparkline - threshold colors - lines_changed.rs # +156 -23 - green/red - duration.rs # 14m - dim - tools.rs # 7 tools (Edit) - dim, progressive - turns.rs # 12 turns - dim - load.rs # load:2.1 - dim, shell-out - version.rs # v1.0.80 - dim - time.rs # 14:30 - dim - output_style.rs # learning - magenta - hostname.rs # myhost - dim - custom.rs # user commands: `exec` argv or `bash -c`, optional JSON output - layout/ - mod.rs # resolve_layout, render_line, assembly - priority.rs # drop tier 3 (all at once), then tier 2 - flex.rs # spacer > non-spacer; context_bar rebuild - justify.rs # spread/space-between gap distribution -defaults.json # embedded via include_str!() -schema.json # shipped alongside or embedded -``` - ---- - -## Cargo.toml - -```toml -[package] -name = "claude-statusline" -version = "0.1.0" -edition = "2021" -description = "Fast, configurable status line for Claude Code" -license = "MIT" -repository = "https://github.com/tayloreernisse/claude-statusline" - -[[bin]] -name = "claude-statusline" -path = "src/bin/claude-statusline.rs" - -[lib] -name = "claude_statusline" -path = "src/lib.rs" - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -md-5 = "0.10" # cache dir compat with bash (12-char hex of project path) -unicode-width = "0.2" # display width for CJK, emoji, Nerd Font glyphs -unicode-segmentation = "1" # grapheme-cluster-aware truncation -libc = "0.2" # ioctl, flock, and low-level TTY checks -serde_path_to_error = "0.1" # precise error paths for --validate-config -serde_ignored = "0.1" # warn on unknown config keys during normal runs - -[dev-dependencies] -criterion = { version = "0.5", features = ["html_reports"] } - -[[bench]] -name = "render" -harness = false - -[profile.release] -lto = true -codegen-units = 1 -strip = true -``` - -No clap (4 flags = manual parsing). No regex (simple string ops). No chrono (libc strftime or manual). No colored/owo-colors (10 ANSI codes as const strings). - ---- - -## build.rs — Embed defaults and schema - -```rust -fn main() { - println!("cargo:rerun-if-changed=defaults.json"); - println!("cargo:rerun-if-changed=schema.json"); -} -``` - -The actual embedding uses `include_str!()` in `config.rs`. `build.rs` only ensures rebuilds trigger on file changes. `--config-schema` and `--print-defaults` stream embedded JSON to stdout. - ---- - -## src/error.rs - -```rust -use std::fmt; -use std::io; - -#[derive(Debug)] -pub enum Error { - Io(io::Error), - Json(serde_json::Error), - ConfigNotFound(String), - EmptyStdin, -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io(e) => write!(f, "io: {e}"), - Self::Json(e) => write!(f, "json: {e}"), - Self::ConfigNotFound(p) => write!(f, "config not found: {p}"), - Self::EmptyStdin => write!(f, "empty stdin"), - } - } -} - -impl std::error::Error for Error {} - -impl From for Error { - fn from(e: io::Error) -> Self { Self::Io(e) } -} - -impl From for Error { - fn from(e: serde_json::Error) -> Self { Self::Json(e) } -} -``` - ---- - -## src/input.rs — Stdin JSON deserialization - -All fields are `Option` with `#[serde(default)]` — Claude Code may omit any field. - -```rust -use serde::Deserialize; - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct InputData { - pub model: Option, - pub cost: Option, - pub context_window: Option, - pub workspace: Option, - pub version: Option, - pub output_style: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct ModelInfo { - pub id: Option, - pub display_name: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct CostInfo { - pub total_cost_usd: Option, - pub total_duration_ms: Option, - pub total_lines_added: Option, - pub total_lines_removed: Option, - pub total_tool_uses: Option, - pub last_tool_name: Option, - pub total_turns: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct ContextWindow { - pub used_percentage: Option, - pub total_input_tokens: Option, - pub total_output_tokens: Option, - pub context_window_size: Option, - pub current_usage: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct CurrentUsage { - pub cache_read_input_tokens: Option, - pub cache_creation_input_tokens: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct Workspace { - pub project_dir: Option, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct OutputStyle { - pub name: Option, -} -``` - -### Complete stdin JSON shape - -Claude Code pipes this every ~300ms: - -```json -{ - "model": { - "id": "claude-opus-4-6-20260101", - "display_name": "Opus 4.6" - }, - "cost": { - "total_cost_usd": 0.42, - "total_duration_ms": 840000, - "total_lines_added": 156, - "total_lines_removed": 23, - "total_tool_uses": 7, - "last_tool_name": "Edit", - "total_turns": 12 - }, - "context_window": { - "used_percentage": 58.5, - "total_input_tokens": 115000, - "total_output_tokens": 8500, - "context_window_size": 200000, - "current_usage": { - "cache_read_input_tokens": 75000, - "cache_creation_input_tokens": 15000 - } - }, - "workspace": { - "project_dir": "/Users/taylor/projects/foo" - }, - "version": "1.0.80", - "output_style": { - "name": "learning" - } -} -``` - ---- - -## src/config.rs — Fully typed config with deep merge - -The `#[serde(flatten)]` pattern lets every section inherit `SectionBase` fields (enabled, priority, flex, min_width, prefix, suffix, pad, align, color) without repeating them. - -```rust -use serde::Deserialize; -use serde_json::Value; -use std::collections::HashMap; -use std::path::Path; - -const DEFAULTS_JSON: &str = include_str!("../defaults.json"); - -// ── Top-level Config ──────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct Config { - pub version: u32, - pub global: GlobalConfig, - pub colors: ThemeColors, - pub glyphs: GlyphConfig, - pub presets: HashMap>>, - pub layout: LayoutValue, - pub sections: Sections, - pub custom: Vec, -} - -impl Default for Config { - fn default() -> Self { - serde_json::from_str(DEFAULTS_JSON).expect("embedded defaults must parse") - } -} - -// ── Migration ─────────────────────────────────────────────────────────── -// Migrate older config versions to current (in-memory only). -// `--validate-config` reports the original version and applied migrations. - -// ── Layout: preset name or explicit array ─────────────────────────────── - -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum LayoutValue { - Preset(String), - Custom(Vec>), -} - -impl Default for LayoutValue { - fn default() -> Self { Self::Preset("standard".into()) } -} - -// ── Global settings ───────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct GlobalConfig { - pub separator: String, - pub justify: JustifyMode, - pub vcs: String, - pub width: Option, - pub width_margin: u16, - pub cache_dir: String, - pub cache_gc_days: u16, - pub cache_gc_interval_hours: u16, - pub cache_ttl_jitter_pct: u8, - pub responsive: bool, - pub breakpoints: Breakpoints, - pub render_budget_ms: u64, - pub theme: String, - pub color: ColorMode, - pub warn_unknown_keys: bool, - pub shell_enabled: bool, - pub shell_allowlist: Vec, - pub shell_denylist: Vec, - pub shell_timeout_ms: u64, - pub shell_max_output_bytes: usize, - pub shell_failure_threshold: u8, - pub shell_cooldown_ms: u64, - pub shell_env: HashMap, - pub cache_version: u32, - pub drop_strategy: String, - pub breakpoint_hysteresis: u16, -} - -impl Default for GlobalConfig { - fn default() -> Self { - Self { - separator: " | ".into(), - justify: JustifyMode::Left, - vcs: "auto".into(), - width: None, - width_margin: 4, - cache_dir: "/tmp/claude-sl-{session_id}".into(), - cache_gc_days: 7, - cache_gc_interval_hours: 24, - cache_ttl_jitter_pct: 10, - responsive: true, - breakpoints: Breakpoints::default(), - render_budget_ms: 8, - theme: "auto".into(), - color: ColorMode::Auto, - warn_unknown_keys: true, - shell_enabled: true, - shell_allowlist: Vec::new(), - shell_denylist: Vec::new(), - shell_timeout_ms: 200, - shell_max_output_bytes: 8192, - shell_failure_threshold: 3, - shell_cooldown_ms: 30_000, - shell_env: HashMap::new(), - cache_version: 1, - drop_strategy: "tiered".into(), - breakpoint_hysteresis: 2, - } - } -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum JustifyMode { - #[default] - Left, - Spread, - SpaceBetween, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ColorMode { - #[default] - Auto, - Always, - Never, -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct Breakpoints { - pub narrow: u16, - pub medium: u16, -} - -impl Default for Breakpoints { - fn default() -> Self { Self { narrow: 60, medium: 100 } } -} - -// ── Color palettes ────────────────────────────────────────────────────── - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct ThemeColors { - pub dark: HashMap, - pub light: HashMap, -} - -// ── Glyph config ──────────────────────────────────────────────────────── - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct GlyphConfig { - pub enabled: bool, - pub set: HashMap, - pub fallback: HashMap, -} - -// ── Shared section base (flattened into each section) ─────────────────── - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct SectionBase { - pub enabled: bool, - pub priority: u8, - pub flex: bool, - pub min_width: Option, - pub prefix: Option, - pub suffix: Option, - pub pad: Option, - pub align: Option, - pub color: Option, -} - -impl Default for SectionBase { - fn default() -> Self { - Self { - enabled: true, - priority: 2, - flex: false, - min_width: None, - prefix: None, - suffix: None, - pad: None, - align: None, - color: None, - } - } -} - -// ── Per-section typed configs ─────────────────────────────────────────── - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub struct Sections { - pub model: SectionBase, - pub provider: SectionBase, - pub project: ProjectSection, - pub vcs: VcsSection, - pub beads: BeadsSection, - pub context_bar: ContextBarSection, - pub context_usage: ContextUsageSection, - pub context_remaining: ContextRemainingSection, - pub tokens_raw: TokensRawSection, - pub cache_efficiency: SectionBase, - pub cost: CostSection, - pub cost_velocity: SectionBase, - pub token_velocity: SectionBase, - pub cost_trend: TrendSection, - pub context_trend: ContextTrendSection, - pub lines_changed: SectionBase, - pub duration: SectionBase, - pub tools: ToolsSection, - pub turns: CachedSection, - pub load: CachedSection, - pub version: SectionBase, - pub time: TimeSection, - pub output_style: SectionBase, - pub hostname: SectionBase, -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct ProjectSection { - #[serde(flatten)] - pub base: SectionBase, - pub truncate: TruncateConfig, -} - -impl Default for ProjectSection { - fn default() -> Self { - Self { - base: SectionBase { priority: 1, ..Default::default() }, - truncate: TruncateConfig { enabled: true, max: 30, style: "middle".into() }, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct TruncateConfig { - pub enabled: bool, - pub max: usize, - pub style: String, -} - -impl Default for TruncateConfig { - fn default() -> Self { Self { enabled: false, max: 0, style: "right".into() } } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct VcsSection { - #[serde(flatten)] - pub base: SectionBase, - pub prefer: String, - pub show_ahead_behind: bool, - pub show_dirty: bool, - pub untracked: String, - pub submodules: bool, - pub fast_mode: bool, - pub truncate: TruncateConfig, - pub ttl: VcsTtl, -} - -impl Default for VcsSection { - fn default() -> Self { - Self { - base: SectionBase { priority: 1, min_width: Some(8), ..Default::default() }, - prefer: "auto".into(), - show_ahead_behind: true, - show_dirty: true, - untracked: "normal".into(), - submodules: false, - fast_mode: false, - truncate: TruncateConfig { enabled: true, max: 25, style: "right".into() }, - ttl: VcsTtl::default(), - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct VcsTtl { - pub branch: u64, - pub dirty: u64, - pub ahead_behind: u64, -} - -impl Default for VcsTtl { - fn default() -> Self { Self { branch: 3, dirty: 5, ahead_behind: 30 } } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct BeadsSection { - #[serde(flatten)] - pub base: SectionBase, - pub show_wip: bool, - pub show_wip_count: bool, - pub show_ready_count: bool, - pub show_open_count: bool, - pub show_closed_count: bool, - pub ttl: u64, -} - -impl Default for BeadsSection { - fn default() -> Self { - Self { - base: SectionBase { priority: 3, ..Default::default() }, - show_wip: true, show_wip_count: true, - show_ready_count: true, show_open_count: true, show_closed_count: true, - ttl: 30, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct ContextBarSection { - #[serde(flatten)] - pub base: SectionBase, - pub bar_width: u16, - pub thresholds: Thresholds, -} - -impl Default for ContextBarSection { - fn default() -> Self { - Self { - base: SectionBase { - priority: 1, flex: true, min_width: Some(15), ..Default::default() - }, - bar_width: 10, - thresholds: Thresholds { warn: 50.0, danger: 70.0, critical: 85.0 }, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct Thresholds { - pub warn: f64, - pub danger: f64, - pub critical: f64, -} - -impl Default for Thresholds { - fn default() -> Self { Self { warn: 50.0, danger: 70.0, critical: 85.0 } } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct ContextUsageSection { - #[serde(flatten)] - pub base: SectionBase, - pub capacity: u64, - pub thresholds: Thresholds, -} - -impl Default for ContextUsageSection { - fn default() -> Self { - Self { - base: SectionBase { enabled: false, priority: 2, ..Default::default() }, - capacity: 200_000, - thresholds: Thresholds::default(), - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct ContextRemainingSection { - #[serde(flatten)] - pub base: SectionBase, - pub format: String, - pub thresholds: Thresholds, -} - -impl Default for ContextRemainingSection { - fn default() -> Self { - Self { - base: SectionBase { enabled: false, priority: 2, ..Default::default() }, - format: "{remaining} left".into(), - thresholds: Thresholds::default(), - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct TokensRawSection { - #[serde(flatten)] - pub base: SectionBase, - pub format: String, -} - -impl Default for TokensRawSection { - fn default() -> Self { - Self { - base: SectionBase { priority: 3, ..Default::default() }, - format: "{input} in/{output} out".into(), - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct CostSection { - #[serde(flatten)] - pub base: SectionBase, - pub thresholds: Thresholds, -} - -impl Default for CostSection { - fn default() -> Self { - Self { - base: SectionBase { priority: 1, ..Default::default() }, - thresholds: Thresholds { warn: 5.0, danger: 8.0, critical: 10.0 }, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct TrendSection { - #[serde(flatten)] - pub base: SectionBase, - pub width: u8, -} - -impl Default for TrendSection { - fn default() -> Self { - Self { base: SectionBase { priority: 3, ..Default::default() }, width: 8 } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct ContextTrendSection { - #[serde(flatten)] - pub base: SectionBase, - pub width: u8, - pub thresholds: Thresholds, -} - -impl Default for ContextTrendSection { - fn default() -> Self { - Self { - base: SectionBase { priority: 3, ..Default::default() }, - width: 8, - thresholds: Thresholds::default(), - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct ToolsSection { - #[serde(flatten)] - pub base: SectionBase, - pub show_last_name: bool, - pub ttl: u64, -} - -impl Default for ToolsSection { - fn default() -> Self { - Self { - base: SectionBase { priority: 2, min_width: Some(6), ..Default::default() }, - show_last_name: true, - ttl: 2, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct CachedSection { - #[serde(flatten)] - pub base: SectionBase, - pub ttl: u64, -} - -impl Default for CachedSection { - fn default() -> Self { - Self { base: SectionBase { priority: 3, ..Default::default() }, ttl: 10 } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct TimeSection { - #[serde(flatten)] - pub base: SectionBase, - pub format: String, -} - -impl Default for TimeSection { - fn default() -> Self { - Self { - base: SectionBase { enabled: false, priority: 3, ..Default::default() }, - format: "%H:%M".into(), - } - } -} - -// ── Custom command sections ───────────────────────────────────────────── - -#[derive(Debug, Clone, Deserialize)] -pub struct CustomCommand { - pub id: String, - #[serde(default)] - pub command: Option, - #[serde(default)] - pub exec: Option>, - #[serde(default)] - pub format: Option, - #[serde(default)] - pub label: Option, - #[serde(default = "default_custom_ttl")] - pub ttl: u64, - #[serde(default = "default_priority")] - pub priority: u8, - #[serde(default)] - pub flex: bool, - #[serde(default)] - pub min_width: Option, - #[serde(default)] - pub color: Option, - #[serde(default)] - pub default_color: Option, - #[serde(default)] - pub prefix: Option, - #[serde(default)] - pub suffix: Option, - #[serde(default)] - pub pad: Option, - #[serde(default)] - pub align: Option, -} - -fn default_custom_ttl() -> u64 { 30 } -fn default_priority() -> u8 { 2 } - -#[derive(Debug, Clone, Deserialize)] -pub struct CustomColor { - #[serde(rename = "match", default)] - pub match_map: HashMap, -} - -// ── Deep merge ────────────────────────────────────────────────────────── - -/// Recursive JSON merge: user values win, arrays replaced entirely. -pub fn deep_merge(base: &mut Value, patch: &Value) { - match (base, patch) { - (Value::Object(base_map), Value::Object(patch_map)) => { - for (k, v) in patch_map { - let entry = base_map.entry(k.clone()).or_insert(Value::Null); - deep_merge(entry, v); - } - } - (base, patch) => { - *base = patch.clone(); - } - } -} - -// ── Config loading ────────────────────────────────────────────────────── - -/// Load config: embedded defaults deep-merged with user overrides. -/// Returns (Config, Vec) where the Vec contains unknown-key warnings. -pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec), crate::Error> { - let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?; - - let user_path = explicit_path - .map(|p| std::path::PathBuf::from(p)) - .or_else(|| std::env::var("CLAUDE_STATUSLINE_CONFIG").ok().map(Into::into)) - .or_else(xdg_config_path) - .or_else(dot_config_path) - .unwrap_or_else(|| { - let mut p = dirs_home().unwrap_or_default(); - p.push(".claude/statusline.json"); - p - }); - - if user_path.exists() { - let user_json: Value = serde_json::from_str(&std::fs::read_to_string(&user_path)?)?; - deep_merge(&mut base, &user_json); - } else if explicit_path.is_some() { - return Err(crate::Error::ConfigNotFound(user_path.display().to_string())); - } - - // Deserialize with unknown-key capture - let mut warnings = Vec::new(); - let config: Config = serde_ignored::deserialize( - base, - |path| warnings.push(format!("unknown config key: {path}")), - )?; - - Ok((config, warnings)) -} - -fn xdg_config_path() -> Option { - let val = std::env::var("XDG_CONFIG_HOME").ok()?; - let mut p = std::path::PathBuf::from(val); - p.push("claude/statusline.json"); - if p.exists() { Some(p) } else { None } -} - -fn dot_config_path() -> Option { - let mut p = dirs_home()?; - p.push(".config/claude/statusline.json"); - if p.exists() { Some(p) } else { None } -} - -fn dirs_home() -> Option { - std::env::var("HOME").ok().map(Into::into) -} -``` - -### Config system rules - -1. Embedded `defaults.json` (compiled in via `include_str!()`) -2. User config from `--config ` / `$CLAUDE_STATUSLINE_CONFIG` / `$XDG_CONFIG_HOME/claude/statusline.json` / `~/.config/claude/statusline.json` / `~/.claude/statusline.json` -3. Deep merge: recursive `serde_json::Value` merge (user wins, arrays replaced entirely) -4. Migrate older config versions to current schema (in-memory only) -5. Deserialize merged JSON into typed `Config` struct -6. Capture unknown keys via `serde_ignored` and emit warnings by default. `global.warn_unknown_keys` toggles warnings; `--validate-config` uses `serde_path_to_error` for precise field paths and reports original version + applied migrations. -7. Color mode: `global.color` (auto|always|never). `NO_COLOR` forces never. `auto` disables color when stdout is not a TTY or `TERM=dumb`. - ---- - -## src/theme.rs — COLORFGBG detection - -```rust -use crate::config::Config; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Theme { - Dark, - Light, -} - -impl Theme { - pub fn as_str(&self) -> &'static str { - match self { - Self::Dark => "dark", - Self::Light => "light", - } - } -} - -/// Detection priority: -/// 1. Config override (global.theme = "dark" or "light") -/// 2. COLORFGBG env var: parse bg from "fg;bg", bg 9-15 = light, 0-8 = dark -/// 3. Default: dark -pub fn detect_theme(config: &Config) -> Theme { - match config.global.theme.as_str() { - "dark" => return Theme::Dark, - "light" => return Theme::Light, - _ => {} - } - - if let Ok(val) = std::env::var("COLORFGBG") { - if let Some(bg_str) = val.rsplit(';').next() { - if let Ok(bg) = bg_str.parse::() { - return if bg > 8 && bg < 16 { Theme::Light } else { Theme::Dark }; - } - } - } - - Theme::Dark -} -``` - ---- - -## src/color.rs — ANSI constants + resolve_color - -10 ANSI escape codes as `const` strings. No external crate. - -```rust -use crate::config::ThemeColors; -use crate::theme::Theme; - -pub const RESET: &str = "\x1b[0m"; -pub const BOLD: &str = "\x1b[1m"; -pub const DIM: &str = "\x1b[2m"; -pub const RED: &str = "\x1b[31m"; -pub const GREEN: &str = "\x1b[32m"; -pub const YELLOW: &str = "\x1b[33m"; -pub const BLUE: &str = "\x1b[34m"; -pub const MAGENTA: &str = "\x1b[35m"; -pub const CYAN: &str = "\x1b[36m"; -pub const WHITE: &str = "\x1b[37m"; - -/// Resolve a color name to ANSI escape sequence(s). -/// -/// Supports: -/// - Palette references: `"p:success"` -> look up in theme palette, resolve recursively -/// - Compound styles: `"red bold"` -> concatenated ANSI codes -/// - Single names: `"green"` -> direct ANSI code -/// - Unknown: returns RESET -pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String { - // Handle palette reference - 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_color(resolved, theme, palette); - } - return RESET.to_string(); - } - - // Handle compound styles ("red bold") - let mut result = String::new(); - for part in name.split_whitespace() { - result.push_str(match part { - "red" => RED, - "green" => GREEN, - "yellow" => YELLOW, - "blue" => BLUE, - "magenta" => MAGENTA, - "cyan" => CYAN, - "white" => WHITE, - "dim" => DIM, - "bold" => BOLD, - _ => "", - }); - } - - if result.is_empty() { RESET.to_string() } else { result } -} - -/// When color is disabled, returns empty strings instead of ANSI codes. -/// Determined by: `NO_COLOR` env, `--color=never`, or stdout is not a TTY. -pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool { - // NO_COLOR takes precedence (https://no-color.org/) - if std::env::var("NO_COLOR").is_ok() { - return false; - } - - // CLI --color flag overrides config - if let Some(flag) = cli_color { - return match flag { - "always" => true, - "never" => false, - _ => atty_stdout(), - }; - } - - match config_color { - crate::config::ColorMode::Always => true, - crate::config::ColorMode::Never => false, - crate::config::ColorMode::Auto => { - atty_stdout() && std::env::var("TERM").map_or(true, |t| t != "dumb") - } - } -} - -fn atty_stdout() -> bool { - unsafe { libc::isatty(libc::STDOUT_FILENO) != 0 } -} -``` - ---- - -## src/glyph.rs — Nerd Font + ASCII fallback - -```rust -use crate::config::GlyphConfig; - -/// Look up a named glyph. Returns Nerd Font icon when enabled, ASCII fallback otherwise. -pub fn glyph<'a>(name: &str, config: &'a GlyphConfig) -> &'a str { - if config.enabled { - if let Some(val) = config.set.get(name) { - if !val.is_empty() { - return val; - } - } - } - config.fallback.get(name).map(|s| s.as_str()).unwrap_or("") -} -``` - ---- - -## src/width.rs — Full detection chain with memoization - -```rust -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -static CACHED_WIDTH: Mutex> = Mutex::new(None); -const WIDTH_TTL: Duration = Duration::from_secs(1); - -/// Detect terminal width. Memoized for 1 second across renders. -/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120 -pub fn detect_width(cli_width: Option, config_width: Option, config_margin: u16) -> u16 { - // Check memo first - if let Ok(guard) = CACHED_WIDTH.lock() { - if let Some((w, ts)) = *guard { - if ts.elapsed() < WIDTH_TTL { - return w; - } - } - } - - let raw = detect_raw(cli_width, config_width); - let effective = raw.saturating_sub(config_margin); - let effective = effective.max(40); // minimum sane width - - // Store in memo - if let Ok(mut guard) = CACHED_WIDTH.lock() { - *guard = Some((effective, Instant::now())); - } - - effective -} - -fn detect_raw(cli_width: Option, config_width: Option) -> u16 { - // 1. --width CLI flag - if let Some(w) = cli_width { - if w > 0 { return w; } - } - - // 2. CLAUDE_STATUSLINE_WIDTH env var - if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") { - if let Ok(w) = val.parse::() { - if w > 0 { return w; } - } - } - - // 3. Config override - if let Some(w) = config_width { - if w > 0 { return w; } - } - - // 4. ioctl(TIOCGWINSZ) on stdout - if let Some(w) = ioctl_width(libc::STDOUT_FILENO) { - if w > 0 { return w; } - } - - // 5. Process tree walk: find ancestor with real TTY - if let Some(w) = process_tree_width() { - if w > 0 { return w; } - } - - // 6. stty size < /dev/tty - if let Some(w) = stty_dev_tty() { - if w > 0 { return w; } - } - - // 7. COLUMNS env var - if let Ok(val) = std::env::var("COLUMNS") { - if let Ok(w) = val.parse::() { - if w > 0 { return w; } - } - } - - // 8. tput cols - if let Some(w) = tput_cols() { - if w > 0 { return w; } - } - - // 9. Fallback - 120 -} - -fn ioctl_width(fd: i32) -> Option { - #[repr(C)] - struct Winsize { - ws_row: u16, - ws_col: u16, - ws_xpixel: u16, - ws_ypixel: u16, - } - - let mut ws = Winsize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0 }; - - // TIOCGWINSZ value differs by platform - #[cfg(target_os = "macos")] - const TIOCGWINSZ: libc::c_ulong = 0x40087468; - #[cfg(target_os = "linux")] - const TIOCGWINSZ: libc::c_ulong = 0x5413; - - let ret = unsafe { libc::ioctl(fd, TIOCGWINSZ, &mut ws) }; - if ret == 0 && ws.ws_col > 0 { Some(ws.ws_col) } else { None } -} - -/// Walk process tree from current PID, find ancestor with a real TTY, -/// then query its width via stty. -fn process_tree_width() -> Option { - let mut pid = std::process::id(); - - while pid > 1 { - // Read TTY from /dev/fd or ps - let output = std::process::Command::new("ps") - .args(["-o", "tty=", "-p", &pid.to_string()]) - .output() - .ok()?; - - let tty = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !tty.is_empty() && tty != "??" && tty != "-" { - let dev_path = format!("/dev/{tty}"); - if let Ok(out) = std::process::Command::new("stty") - .arg("size") - .stdin(std::fs::File::open(&dev_path).ok()?) - .output() - { - let s = String::from_utf8_lossy(&out.stdout); - if let Some(cols_str) = s.split_whitespace().nth(1) { - if let Ok(w) = cols_str.parse::() { - return Some(w); - } - } - } - } - - // Walk to parent - let ppid_out = std::process::Command::new("ps") - .args(["-o", "ppid=", "-p", &pid.to_string()]) - .output() - .ok()?; - let ppid_str = String::from_utf8_lossy(&ppid_out.stdout).trim().to_string(); - pid = ppid_str.parse().ok()?; - } - - None -} - -fn stty_dev_tty() -> Option { - let tty = std::fs::File::open("/dev/tty").ok()?; - let out = std::process::Command::new("stty") - .arg("size") - .stdin(tty) - .output() - .ok()?; - let s = String::from_utf8_lossy(&out.stdout); - s.split_whitespace().nth(1)?.parse().ok() -} - -fn tput_cols() -> Option { - let out = std::process::Command::new("tput").arg("cols").output().ok()?; - String::from_utf8_lossy(&out.stdout).trim().parse().ok() -} - -// ── Width tiers for progressive disclosure ────────────────────────────── - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WidthTier { - Narrow, - Medium, - Wide, -} - -pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier { - if width < narrow_bp { - WidthTier::Narrow - } else if width < medium_bp { - WidthTier::Medium - } else { - WidthTier::Wide - } -} -``` - ---- - -## src/cache.rs — Secure dir, per-key TTL, flock, atomic writes - -```rust -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; - -pub struct Cache { - dir: Option, -} - -impl Cache { - /// Create cache with secure directory. Returns disabled cache on failure. - pub fn new(template: &str, session_id: &str) -> Self { - let dir_str = template.replace("{session_id}", session_id); - let dir = PathBuf::from(&dir_str); - - if !dir.exists() { - if fs::create_dir_all(&dir).is_err() { - return Self { dir: None }; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); - } - } - - // Security: verify ownership, not a symlink, not world-writable - if !verify_cache_dir(&dir) { - return Self { dir: None }; - } - - Self { dir: Some(dir) } - } - - pub fn dir(&self) -> Option<&Path> { - self.dir.as_deref() - } - - /// Get cached value if fresher than TTL seconds. - pub fn get(&self, key: &str, ttl: Duration) -> Option { - let path = self.key_path(key)?; - let meta = fs::metadata(&path).ok()?; - let age = meta.modified().ok()?.elapsed().ok()?; - if age < ttl { - fs::read_to_string(&path).ok() - } else { - None - } - } - - /// Get stale cached value (ignores TTL). Used as fallback on command failure. - pub fn get_stale(&self, key: &str) -> Option { - let path = self.key_path(key)?; - fs::read_to_string(&path).ok() - } - - /// Atomic write: write to .tmp then rename (prevents partial reads). - /// Uses flock with LOCK_NB — skips cache on contention rather than blocking. - pub fn set(&self, key: &str, value: &str) -> Option<()> { - let path = self.key_path(key)?; - let tmp = path.with_extension("tmp"); - - // Try non-blocking flock - let lock_path = path.with_extension("lock"); - let lock_file = fs::File::create(&lock_path).ok()?; - if !try_flock(&lock_file) { - return None; // contention — skip cache write - } - - let mut f = fs::File::create(&tmp).ok()?; - f.write_all(value.as_bytes()).ok()?; - fs::rename(&tmp, &path).ok()?; - - unlock(&lock_file); - Some(()) - } - - fn key_path(&self, key: &str) -> Option { - let dir = self.dir.as_ref()?; - let safe_key: String = key.chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }) - .collect(); - Some(dir.join(safe_key)) - } -} - -fn verify_cache_dir(dir: &Path) -> bool { - // Must exist, be a directory, not a symlink - let meta = match fs::symlink_metadata(dir) { - Ok(m) => m, - Err(_) => return false, - }; - if !meta.is_dir() || meta.file_type().is_symlink() { - return false; - } - - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Must be owned by current user - if meta.uid() != unsafe { libc::getuid() } { - return false; - } - // Must not be world-writable - if meta.mode() & 0o002 != 0 { - return false; - } - } - - true -} - -fn try_flock(file: &fs::File) -> bool { - #[cfg(unix)] - { - use std::os::unix::io::AsRawFd; - let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; - ret == 0 - } - #[cfg(not(unix))] - { true } -} - -fn unlock(file: &fs::File) { - #[cfg(unix)] - { - use std::os::unix::io::AsRawFd; - unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN); } - } -} - -/// Session ID: first 12 chars of MD5 hex of project_dir. -/// Same algorithm as bash for cache compatibility during migration. -pub fn session_id(project_dir: &str) -> String { - use md5::{Md5, Digest}; - let hash = Md5::digest(project_dir.as_bytes()); - format!("{:x}", hash)[..12].to_string() -} -``` - ---- - -## src/trend.rs — Append with throttle, sparkline with flat-series guard - -```rust -use crate::cache::Cache; -use std::time::Duration; - -const SPARKLINE_CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - -/// 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(cache: &Cache, key: &str, value: i64, max_points: usize, interval: Duration) -> Option { - let trend_key = format!("trend_{key}"); - - // Check throttle: skip if last write was within interval - if let Some(existing) = cache.get(&trend_key, interval) { - // Not yet time to append — return existing series - return Some(existing); - } - - // Read current series (ignoring TTL) - let mut series: Vec = cache - .get_stale(&trend_key) - .unwrap_or_default() - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - - // Skip if value unchanged from last point - if series.last() == Some(&value) { - // Still update the file mtime so throttle window resets - let csv = series.iter().map(|v| v.to_string()).collect::>().join(","); - cache.set(&trend_key, &csv); - return Some(csv); - } - - series.push(value); - - // 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. -/// 8 Unicode block chars, normalized to min/max range. -/// Flat-series guard: when min == max, render mid-height blocks (▄). -pub fn sparkline(csv: &str, width: usize) -> String { - let vals: Vec = csv.split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - - if vals.is_empty() { - return String::new(); - } - - let min = *vals.iter().min().unwrap(); - let max = *vals.iter().max().unwrap(); - - let count = vals.len().min(width); - - if max == min { - // Flat series: mid-height block for all points - return std::iter::repeat('▄').take(count).collect(); - } - - 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() -} -``` - ---- - -## src/format.rs — Formatting utilities - -```rust -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -use crate::color; -use crate::config::SectionBase; -use crate::theme::Theme; - -/// Display width using unicode-width (handles CJK, emoji, Nerd Font glyphs). -pub fn display_width(s: &str) -> usize { - UnicodeWidthStr::width(s) -} - -/// Format token count for human readability: 1500 -> "1.5k", 2000000 -> "2.0M" -pub fn human_tokens(n: u64) -> String { - if n >= 1_000_000 { - format!("{:.1}M", n as f64 / 1_000_000.0) - } else if n >= 1_000 { - format!("{:.1}k", n as f64 / 1_000.0) - } else { - n.to_string() - } -} - -/// Format duration: 840000ms -> "14m", 7200000ms -> "2h0m", 45000ms -> "45s" -pub fn human_duration(ms: u64) -> String { - let secs = ms / 1000; - if secs >= 3600 { - format!("{}h{}m", secs / 3600, (secs % 3600) / 60) - } else if secs >= 60 { - format!("{}m", secs / 60) - } else { - format!("{secs}s") - } -} - -/// Grapheme-cluster-safe truncation. Never splits a grapheme. -pub fn truncate(s: &str, max: usize, style: &str) -> String { - let w = display_width(s); - if w <= max { - return s.to_string(); - } - - let graphemes: Vec<&str> = s.graphemes(true).collect(); - match style { - "middle" => truncate_middle(&graphemes, max), - "left" => truncate_left(&graphemes, max), - _ => truncate_right(&graphemes, max), - } -} - -fn truncate_right(graphemes: &[&str], max: usize) -> String { - let mut result = String::new(); - let mut w = 0; - for &g in graphemes { - let gw = UnicodeWidthStr::width(g); - if w + gw + 1 > max { break; } // +1 for ellipsis - result.push_str(g); - w += gw; - } - result.push('…'); - result -} - -fn truncate_middle(graphemes: &[&str], max: usize) -> String { - let half = (max - 1) / 2; // -1 for ellipsis - let mut left = String::new(); - let mut left_w = 0; - for &g in graphemes { - let gw = UnicodeWidthStr::width(g); - if left_w + gw > half { break; } - left.push_str(g); - left_w += gw; - } - - let right_budget = max - 1 - left_w; - let mut right_graphemes = Vec::new(); - let mut right_w = 0; - for &g in graphemes.iter().rev() { - let gw = UnicodeWidthStr::width(g); - if right_w + gw > right_budget { break; } - right_graphemes.push(g); - right_w += gw; - } - right_graphemes.reverse(); - - format!("{left}…{}", right_graphemes.join("")) -} - -fn truncate_left(graphemes: &[&str], max: usize) -> String { - let budget = max - 1; // -1 for ellipsis - let mut parts = Vec::new(); - let mut w = 0; - for &g in graphemes.iter().rev() { - let gw = UnicodeWidthStr::width(g); - if w + gw > budget { break; } - parts.push(g); - w += gw; - } - parts.reverse(); - format!("…{}", parts.join("")) -} - -/// Apply per-section formatting: prefix, suffix, color override, pad+align. -/// -/// Color override re-wraps `raw` text (discards section's internal ANSI), -/// matching bash behavior where `apply_formatting()` rebuilds the ANSI string. -pub fn apply_formatting( - raw: &mut String, - ansi: &mut String, - base: &SectionBase, - theme: Theme, - palette: &crate::config::ThemeColors, -) { - // 1. Prefix / suffix - if let Some(ref pfx) = base.prefix { - *raw = format!("{pfx}{raw}"); - *ansi = format!("{pfx}{ansi}"); - } - if let Some(ref sfx) = base.suffix { - raw.push_str(sfx); - ansi.push_str(sfx); - } - - // 2. Color override — re-wrap raw text - if let Some(ref color_name) = base.color { - let c = color::resolve_color(color_name, theme, palette); - *ansi = format!("{c}{raw}{}", color::RESET); - } - - // 3. Pad + align - if let Some(pad) = base.pad { - let pad = pad as usize; - let raw_w = display_width(raw); - if pad > raw_w { - let needed = pad - raw_w; - let spaces: String = " ".repeat(needed); - let align = base.align.as_deref().unwrap_or("left"); - match align { - "right" => { - *raw = format!("{spaces}{raw}"); - *ansi = format!("{spaces}{ansi}"); - } - "center" => { - let left = needed / 2; - let right = needed - left; - *raw = format!("{}{raw}{}", " ".repeat(left), " ".repeat(right)); - *ansi = format!("{}{ansi}{}", " ".repeat(left), " ".repeat(right)); - } - _ => { - raw.push_str(&spaces); - ansi.push_str(&spaces); - } - } - } - } -} -``` - ---- - -## src/shell.rs — exec_with_timeout, GIT_ENV, parse_git_status_v2 - -```rust -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; -use std::thread; - -/// Stable env for all git commands: no lock contention, no prompts, no locale variance. -const GIT_ENV: &[(&str, &str)] = &[ - ("GIT_OPTIONAL_LOCKS", "0"), - ("GIT_TERMINAL_PROMPT", "0"), - ("LC_ALL", "C"), -]; - -/// Execute a command with a polling timeout. No extra crate needed. -/// Returns None on timeout or error. -pub fn exec_with_timeout( - program: &str, - args: &[&str], - dir: Option<&str>, - timeout: Duration, -) -> Option { - let mut cmd = Command::new(program); - cmd.args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - - if let Some(d) = dir { - cmd.current_dir(d); - } - - // Apply GIT_ENV for git commands - if program == "git" { - for (k, v) in GIT_ENV { - cmd.env(k, v); - } - } - - let mut child = cmd.spawn().ok()?; - let start = Instant::now(); - - loop { - match child.try_wait() { - Ok(Some(status)) => { - if !status.success() { - return None; - } - let output = child.wait_with_output().ok()?; - return Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); - } - Ok(None) => { - if start.elapsed() >= timeout { - let _ = child.kill(); - return None; - } - thread::sleep(Duration::from_millis(5)); - } - Err(_) => return None, - } - } -} - -/// Parsed result from `git status --porcelain=v2 --branch`. -/// Single call returns branch, dirty, and ahead/behind. -#[derive(Debug, Default)] -pub struct GitStatusV2 { - pub branch: Option, - pub ahead: u32, - pub behind: u32, - pub is_dirty: bool, -} - -/// Parse combined `git status --porcelain=v2 --branch` output. -/// Three cache TTLs are preserved by caching sub-results independently, -/// but all three are populated from a single execution when any one expires. -pub fn parse_git_status_v2(output: &str) -> GitStatusV2 { - let mut result = GitStatusV2::default(); - - for line in output.lines() { - if let Some(rest) = line.strip_prefix("# branch.head ") { - result.branch = Some(rest.to_string()); - } else if let Some(rest) = line.strip_prefix("# branch.ab ") { - // Format: "+3 -1" - for part in rest.split_whitespace() { - if let Some(n) = part.strip_prefix('+') { - result.ahead = n.parse().unwrap_or(0); - } else if let Some(n) = part.strip_prefix('-') { - result.behind = n.parse().unwrap_or(0); - } - } - } else if line.starts_with('1') || line.starts_with('2') || line.starts_with('?') || line.starts_with('u') { - // Porcelain v2: 1=changed, 2=renamed, ?=untracked, u=unmerged - result.is_dirty = true; - } - } - - result -} -``` - ---- - -## src/section/mod.rs — Section dispatch and registry - -Function pointers (`fn(&RenderContext) -> Option`) — no traits. - -```rust -use crate::config::Config; -use crate::cache::Cache; -use crate::input::InputData; -use crate::theme::Theme; -use crate::width::WidthTier; - -use std::path::Path; - -/// What every section renderer returns. -#[derive(Debug, Clone)] -pub struct SectionOutput { - pub raw: String, // plain text (for width calculation) - pub ansi: String, // ANSI-colored text (for display) -} - -/// Type alias for section render functions. -pub type RenderFn = fn(&RenderContext) -> Option; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VcsType { - Git, - Jj, - None, -} - -/// Context passed to every section renderer. -pub struct RenderContext<'a> { - pub input: &'a InputData, - pub config: &'a Config, - pub theme: Theme, - pub width_tier: WidthTier, - pub term_width: u16, - pub vcs_type: VcsType, - pub project_dir: &'a Path, - pub cache: &'a Cache, - pub glyphs_enabled: bool, - pub color_enabled: bool, - pub metrics: ComputedMetrics, -} - -/// Metadata for layout planning (before rendering). -/// Single source of truth for section IDs, validation, and CLI introspection. -pub struct SectionDescriptor { - pub id: &'static str, - pub render: RenderFn, - pub priority: u8, - pub is_spacer: bool, - pub is_flex: bool, - pub estimated_width: u16, - pub shell_out: bool, -} - -/// Build the registry of all built-in sections. -/// Maps section ID to its render function. -pub fn registry() -> Vec<(&'static str, RenderFn)> { - vec![ - ("model", super::section::model::render), - ("provider", super::section::provider::render), - ("project", super::section::project::render), - ("vcs", super::section::vcs::render), - ("beads", super::section::beads::render), - ("context_bar", super::section::context_bar::render), - ("context_usage", super::section::context_usage::render), - ("context_remaining", super::section::context_remaining::render), - ("tokens_raw", super::section::tokens_raw::render), - ("cache_efficiency", super::section::cache_efficiency::render), - ("cost", super::section::cost::render), - ("cost_velocity", super::section::cost_velocity::render), - ("token_velocity", super::section::token_velocity::render), - ("cost_trend", super::section::cost_trend::render), - ("context_trend", super::section::context_trend::render), - ("lines_changed", super::section::lines_changed::render), - ("duration", super::section::duration::render), - ("tools", super::section::tools::render), - ("turns", super::section::turns::render), - ("load", super::section::load::render), - ("version", super::section::version::render), - ("time", super::section::time::render), - ("output_style", super::section::output_style::render), - ("hostname", super::section::hostname::render), - ] -} - -/// Dispatch: look up section by ID and render it. -/// Returns None if section is disabled, missing data, or unknown. -pub fn render_section(id: &str, ctx: &RenderContext) -> Option { - if is_spacer(id) { - return Some(SectionOutput { raw: " ".into(), ansi: " ".into() }); - } - - // Try built-in - for (name, render_fn) in registry() { - if name == id { - return render_fn(ctx); - } - } - - // Try custom command - super::section::custom::render(id, ctx) -} - -pub fn is_spacer(id: &str) -> bool { - id == "spacer" || id.starts_with("_spacer") -} -``` - ---- - -## Representative Sections (4 of 24) - -### section/model.rs — Pure data, no shell-out - -```rust -use crate::color; -use crate::section::{RenderContext, SectionOutput}; - -pub fn render(ctx: &RenderContext) -> Option { - if !ctx.config.sections.model.enabled { return None; } - - let model = ctx.input.model.as_ref()?; - let id = model.id.as_deref().unwrap_or("?"); - let id_lower = id.to_ascii_lowercase(); - - let base_name = if id_lower.contains("opus") { "Opus" } - else if id_lower.contains("sonnet") { "Sonnet" } - else if id_lower.contains("haiku") { "Haiku" } - else { return Some(simple_output(model.display_name.as_deref().unwrap_or(id), ctx)) }; - - // Extract version: "opus-4-6" or "4-6-opus" -> "4.6" - let version = extract_version(&id_lower, base_name.to_ascii_lowercase().as_str()); - - let name = match version { - Some(v) => format!("{base_name} {v}"), - None => base_name.to_string(), - }; - - let raw = format!("[{name}]"); - let ansi = if ctx.color_enabled { - format!("{}[{name}]{}", color::BOLD, color::RESET) - } else { - raw.clone() - }; - - Some(SectionOutput { raw, ansi }) -} - -fn extract_version(id: &str, family: &str) -> Option { - // Pattern: "opus-4-6" or "4-6-opus" - let parts: Vec<&str> = id.split('-').collect(); - for window in parts.windows(3) { - if window[0] == family { - if let (Ok(a), Ok(b)) = (window[1].parse::(), window[2].parse::()) { - return Some(format!("{a}.{b}")); - } - } - if window[2] == family { - if let (Ok(a), Ok(b)) = (window[0].parse::(), window[1].parse::()) { - return Some(format!("{a}.{b}")); - } - } - } - None -} - -fn simple_output(name: &str, ctx: &RenderContext) -> SectionOutput { - let raw = format!("[{name}]"); - let ansi = if ctx.color_enabled { - format!("{}[{name}]{}", color::BOLD, color::RESET) - } else { - raw.clone() - }; - SectionOutput { raw, ansi } -} -``` - -### section/cost.rs — Threshold colors, width-tier decimals - -```rust -use crate::color; -use crate::section::{RenderContext, SectionOutput}; -use crate::width::WidthTier; - -pub fn render(ctx: &RenderContext) -> Option { - if !ctx.config.sections.cost.base.enabled { return None; } - - let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?; - - let decimals = match ctx.width_tier { - WidthTier::Narrow => 0, - WidthTier::Medium => 2, - WidthTier::Wide => 4, - }; - - let cost_str = format!("{cost_val:.decimals$}"); - let raw = format!("${cost_str}"); - - let thresh = &ctx.config.sections.cost.thresholds; - let color_code = if cost_val >= thresh.critical { - format!("{}{}", color::RED, color::BOLD) - } else if cost_val >= thresh.danger { - color::RED.to_string() - } else if cost_val >= thresh.warn { - color::YELLOW.to_string() - } else { - color::GREEN.to_string() - }; - - let ansi = if ctx.color_enabled { - format!("{color_code}${cost_str}{}", color::RESET) - } else { - raw.clone() - }; - - Some(SectionOutput { raw, ansi }) -} -``` - -### section/context_bar.rs — Flex-expandable, threshold colors - -```rust -use crate::color; -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_int = pct.round() as u16; - - let filled = (pct_int as u32 * bar_width as u32 / 100) as usize; - let empty = bar_width as usize - filled; - - let bar: String = "=".repeat(filled) + &"-".repeat(empty); - let raw = format!("[{bar}] {pct_int}%"); - - let thresh = &ctx.config.sections.context_bar.thresholds; - let color_code = threshold_color(pct, thresh); - - let ansi = if ctx.color_enabled { - format!("{color_code}[{bar}] {pct_int}%{}", color::RESET) - } else { - raw.clone() - }; - - Some(SectionOutput { raw, ansi }) -} - -pub fn render(ctx: &RenderContext) -> Option { - if !ctx.config.sections.context_bar.base.enabled { return None; } - render_at_width(ctx, ctx.config.sections.context_bar.bar_width) -} - -fn threshold_color(pct: f64, thresh: &crate::config::Thresholds) -> String { - if pct >= thresh.critical { - format!("{}{}", color::RED, color::BOLD) - } else if pct >= thresh.danger { - color::RED.to_string() - } else if pct >= thresh.warn { - color::YELLOW.to_string() - } else { - color::GREEN.to_string() - } -} -``` - -### section/vcs.rs — Shell-out with combined git status - -```rust -use crate::color; -use crate::glyph; -use crate::section::{RenderContext, SectionOutput, VcsType}; -use crate::shell::{self, GitStatusV2}; -use crate::width::WidthTier; -use std::time::Duration; - -pub fn render(ctx: &RenderContext) -> Option { - if !ctx.config.sections.vcs.base.enabled { return None; } - if ctx.vcs_type == VcsType::None { return None; } - - let dir = ctx.project_dir.to_str()?; - let ttl = &ctx.config.sections.vcs.ttl; - let glyphs = &ctx.config.glyphs; - - match ctx.vcs_type { - VcsType::Git => render_git(ctx, dir, ttl, glyphs), - VcsType::Jj => render_jj(ctx, dir, ttl, glyphs), - VcsType::None => None, - } -} - -fn render_git( - ctx: &RenderContext, - dir: &str, - ttl: &crate::config::VcsTtl, - glyphs: &crate::config::GlyphConfig, -) -> Option { - // Try combined git status (populates all three sub-caches at once) - let branch_ttl = Duration::from_secs(ttl.branch); - let dirty_ttl = Duration::from_secs(ttl.dirty); - let ab_ttl = Duration::from_secs(ttl.ahead_behind); - let timeout = Duration::from_millis(200); - - // Check if any sub-cache is expired - let branch_cached = ctx.cache.get("vcs_branch", branch_ttl); - let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl); - let ab_cached = ctx.cache.get("vcs_ab", ab_ttl); - - let status = if branch_cached.is_none() || dirty_cached.is_none() || ab_cached.is_none() { - // Run combined command, populate all sub-caches - let output = shell::exec_with_timeout( - "git", &["-C", dir, "status", "--porcelain=v2", "--branch"], - None, timeout, - ); - match output { - Some(ref out) => { - let s = shell::parse_git_status_v2(out); - if let Some(ref b) = s.branch { - ctx.cache.set("vcs_branch", b); - } - ctx.cache.set("vcs_dirty", if s.is_dirty { "1" } else { "" }); - ctx.cache.set("vcs_ab", &format!("{} {}", s.ahead, s.behind)); - s - } - None => { - // Fall back to stale cache - GitStatusV2 { - branch: branch_cached.or_else(|| ctx.cache.get_stale("vcs_branch")), - is_dirty: dirty_cached.or_else(|| ctx.cache.get_stale("vcs_dirty")) - .map_or(false, |v| !v.is_empty()), - ahead: 0, - behind: 0, - } - } - } - } else { - GitStatusV2 { - branch: branch_cached, - is_dirty: dirty_cached.map_or(false, |v| !v.is_empty()), - ahead: ab_cached.as_ref() - .and_then(|s| s.split_whitespace().next()?.parse().ok()).unwrap_or(0), - behind: ab_cached.as_ref() - .and_then(|s| s.split_whitespace().nth(1)?.parse().ok()).unwrap_or(0), - } - }; - - let branch = status.branch.as_deref().unwrap_or("?"); - let branch_glyph = glyph::glyph("branch", glyphs); - let dirty_glyph = if status.is_dirty && ctx.config.sections.vcs.show_dirty { - glyph::glyph("dirty", glyphs) - } else { "" }; - - let mut raw = format!("{branch_glyph}{branch}{dirty_glyph}"); - let mut ansi = if ctx.color_enabled { - let mut s = format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET); - if !dirty_glyph.is_empty() { - s.push_str(&format!("{}{dirty_glyph}{}", color::YELLOW, color::RESET)); - } - s - } else { - raw.clone() - }; - - // Ahead/behind: medium+ width only - if ctx.config.sections.vcs.show_ahead_behind - && ctx.width_tier != WidthTier::Narrow - && (status.ahead > 0 || status.behind > 0) - { - let ahead_g = glyph::glyph("ahead", glyphs); - let behind_g = glyph::glyph("behind", glyphs); - let mut ab = String::new(); - if status.ahead > 0 { ab.push_str(&format!("{ahead_g}{}", status.ahead)); } - if status.behind > 0 { ab.push_str(&format!("{behind_g}{}", status.behind)); } - raw.push_str(&format!(" {ab}")); - if ctx.color_enabled { - ansi.push_str(&format!(" {}{ab}{}", color::DIM, color::RESET)); - } else { - ansi = raw.clone(); - } - } - - Some(SectionOutput { raw, ansi }) -} - -fn render_jj( - ctx: &RenderContext, - _dir: &str, - ttl: &crate::config::VcsTtl, - glyphs: &crate::config::GlyphConfig, -) -> Option { - let branch_ttl = Duration::from_secs(ttl.branch); - let timeout = Duration::from_millis(200); - - let branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| { - let out = shell::exec_with_timeout( - "jj", - &["log", "-r", "@", "--no-graph", - "-T", "if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))", - "--color=never"], - None, timeout, - )?; - ctx.cache.set("vcs_branch", &out); - Some(out) - })?; - - let branch_glyph = glyph::glyph("branch", glyphs); - let raw = format!("{branch_glyph}{branch}"); - let ansi = if ctx.color_enabled { - format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET) - } else { - raw.clone() - }; - - // jj has no upstream concept — ahead/behind hardcoded to 0/0 - Some(SectionOutput { raw, ansi }) -} -``` - ---- - -## src/layout/mod.rs — Full render pipeline - -Three phases: Plan → Render survivors → Reflow. - -```rust -use crate::config::{Config, JustifyMode, LayoutValue}; -use crate::format; -use crate::section::{self, RenderContext, SectionOutput}; - -/// A section that survived priority drops and has rendered output. -pub struct ActiveSection { - pub id: String, - pub output: SectionOutput, - pub priority: u8, - pub is_spacer: bool, - pub is_flex: bool, -} - -/// Resolve layout: preset lookup with optional responsive override. -pub fn resolve_layout(config: &Config, term_width: u16) -> Vec> { - match &config.layout { - LayoutValue::Preset(name) => { - let effective = if config.global.responsive { - responsive_preset(term_width, &config.global.breakpoints) - } else { - name.as_str() - }; - config.presets.get(effective) - .or_else(|| config.presets.get(name.as_str())) - .cloned() - .unwrap_or_else(|| vec![vec!["model".into(), "project".into()]]) - } - LayoutValue::Custom(lines) => lines.clone(), - } -} - -fn responsive_preset(width: u16, bp: &crate::config::Breakpoints) -> &'static str { - if width < bp.narrow { "dense" } - else if width < bp.medium { "standard" } - else { "verbose" } -} - -/// Render a single layout line. -/// Returns the final ANSI string, or None if all sections were empty. -pub fn render_line( - section_ids: &[String], - ctx: &RenderContext, - separator: &str, -) -> Option { - // Phase 1: Render all sections, collect active ones - let mut active: Vec = Vec::new(); - - for id in section_ids { - let output = section::render_section(id, ctx)?; - if output.raw.is_empty() && !section::is_spacer(id) { - continue; - } - - let (priority, is_flex) = section_meta(id, ctx.config); - active.push(ActiveSection { - id: id.clone(), - output, - priority, - is_spacer: section::is_spacer(id), - is_flex, - }); - } - - if active.is_empty() || active.iter().all(|s| s.is_spacer) { - return None; - } - - // Phase 2: Priority drop if overflowing - let mut active = super::layout::priority::priority_drop( - active, ctx.term_width, separator, - ); - - // Phase 3: Flex expand or justify - let line = if ctx.config.global.justify != JustifyMode::Left - && !active.iter().any(|s| s.is_spacer) - && active.len() > 1 - { - super::layout::justify::justify(&active, ctx.term_width, separator, ctx.config.global.justify) - } else { - super::layout::flex::flex_expand(&mut active, ctx, separator)?; - assemble_left(&active, separator, ctx.color_enabled) - }; - - Some(line) -} - -/// Left-aligned assembly with separator dimming and spacer suppression. -fn assemble_left(active: &[ActiveSection], separator: &str, color_enabled: bool) -> String { - let mut output = String::new(); - let mut prev_is_spacer = false; - - for (i, sec) in active.iter().enumerate() { - if i > 0 && !prev_is_spacer && !sec.is_spacer { - if color_enabled { - output.push_str(&format!("{}{separator}{}", crate::color::DIM, crate::color::RESET)); - } else { - output.push_str(separator); - } - } - output.push_str(&sec.output.ansi); - prev_is_spacer = sec.is_spacer; - } - - output -} - -fn section_meta(id: &str, config: &Config) -> (u8, bool) { - if section::is_spacer(id) { - return (1, true); - } - // Look up from config (simplified — real impl uses per-section base) - (2, false) // placeholder; real impl reads config.sections.{id}.base -} - -/// 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 lines: Vec = layout.iter() - .filter_map(|line_ids| render_line(line_ids, ctx, separator)) - .collect(); - - lines.join("\n") -} -``` - ---- - -## src/layout/priority.rs — Priority drop - -```rust -use crate::layout::ActiveSection; -use crate::format; - -/// Drop priority 3 sections (all at once), then priority 2, until line fits. -/// Priority 1 sections never drop. -pub fn priority_drop( - mut active: Vec, - term_width: u16, - separator: &str, -) -> Vec { - if line_width(&active, separator) <= term_width as usize { - return active; - } - - // Drop all priority 3 - active.retain(|s| s.priority < 3); - if line_width(&active, separator) <= term_width as usize { - return active; - } - - // Drop all priority 2 - active.retain(|s| s.priority < 2); - active -} - -/// Calculate total display width including separators. -/// Spacers suppress adjacent separators on both sides. -fn line_width(active: &[ActiveSection], separator: &str) -> usize { - let sep_w = format::display_width(separator); - let mut total = 0; - - for (i, sec) in active.iter().enumerate() { - if i > 0 { - let prev = &active[i - 1]; - if !prev.is_spacer && !sec.is_spacer { - total += sep_w; - } - } - total += format::display_width(&sec.output.raw); - } - - total -} -``` - ---- - -## src/layout/flex.rs — Flex expansion with context_bar rebuild - -```rust -use crate::layout::ActiveSection; -use crate::section::RenderContext; -use crate::format; - -/// Expand the winning flex section to fill remaining terminal width. -/// -/// Rules: -/// - Spacers take priority over non-spacer flex sections -/// - Only one flex section wins per line -/// - Spacer: fill with spaces -/// - context_bar: rebuild bar with wider width (recalculate filled/empty chars) -/// - Other: pad with trailing spaces -pub fn flex_expand( - active: &mut Vec, - ctx: &RenderContext, - separator: &str, -) -> Option<()> { - let current_width = line_width(active, separator); - let term_width = ctx.term_width as usize; - - if current_width >= term_width { - return Some(()); - } - - // Find winning flex section: spacer wins over non-spacer - let mut flex_idx: Option = None; - for (i, sec) in active.iter().enumerate() { - if !sec.is_flex { continue; } - match flex_idx { - None => flex_idx = Some(i), - Some(prev) => { - if sec.is_spacer && !active[prev].is_spacer { - flex_idx = Some(i); - } - } - } - } - - let idx = flex_idx?; - let extra = term_width - current_width; - - if active[idx].is_spacer { - let padding = " ".repeat(extra + 1); - active[idx].output = crate::section::SectionOutput { - raw: padding.clone(), - ansi: padding, - }; - } else if active[idx].id == "context_bar" { - // Rebuild context_bar with wider bar_width - let cur_bar_width = ctx.config.sections.context_bar.bar_width; - let new_bar_width = cur_bar_width + extra as u16; - if let Some(mut output) = crate::section::context_bar::render_at_width(ctx, new_bar_width) { - // Re-apply formatting after flex rebuild - let base = &ctx.config.sections.context_bar.base; - format::apply_formatting( - &mut output.raw, &mut output.ansi, - base, ctx.theme, &ctx.config.colors, - ); - active[idx].output = output; - } - } else { - let padding = " ".repeat(extra); - active[idx].output.raw.push_str(&padding); - active[idx].output.ansi.push_str(&padding); - } - - Some(()) -} - -fn line_width(active: &[ActiveSection], separator: &str) -> usize { - let sep_w = format::display_width(separator); - let mut total = 0; - for (i, sec) in active.iter().enumerate() { - if i > 0 { - let this_gap = sep_w + format::display_width(&sec.output.raw); - total += this_gap; - } else { - total += format::display_width(&sec.output.raw); - } - } - total -} -``` - ---- - -## src/layout/justify.rs — Spread/space-between gap math - -```rust -use crate::color; -use crate::config::JustifyMode; -use crate::format; -use crate::layout::ActiveSection; - -/// Distribute extra space evenly across gaps between sections. -/// Center the separator core (e.g., `|` from ` | `) within each gap. -/// Remainder chars distributed left-to-right. -pub fn justify( - active: &[ActiveSection], - term_width: u16, - separator: &str, - mode: JustifyMode, -) -> String { - let content_width: usize = active.iter() - .map(|s| format::display_width(&s.output.raw)) - .sum(); - - let num_gaps = active.len().saturating_sub(1); - if num_gaps == 0 { - return active.first().map(|s| s.output.ansi.clone()).unwrap_or_default(); - } - - let available = (term_width as usize).saturating_sub(content_width); - let gap_width = available / num_gaps; - let gap_remainder = available % num_gaps; - - // Extract separator core (non-space chars, e.g. "|" from " | ") - let sep_core = separator.trim(); - let sep_core_len = format::display_width(sep_core); - - let mut output = String::new(); - for (i, sec) in active.iter().enumerate() { - if i > 0 { - let this_gap = gap_width + if i - 1 < gap_remainder { 1 } else { 0 }; - let gap_str = build_gap(sep_core, sep_core_len, this_gap); - output.push_str(&format!("{}{gap_str}{}", color::DIM, color::RESET)); - } - output.push_str(&sec.output.ansi); - } - - output -} - -/// Center the separator core within a gap of `total` columns. -fn build_gap(core: &str, core_len: usize, total: usize) -> String { - if core.is_empty() || core_len == 0 { - return " ".repeat(total); - } - - let pad_total = total.saturating_sub(core_len); - let pad_left = pad_total / 2; - let pad_right = pad_total - pad_left; - - format!("{}{core}{}", " ".repeat(pad_left), " ".repeat(pad_right)) -} -``` - ---- - -## src/bin/claude-statusline.rs — CLI entry point - -```rust -use claude_statusline::{config, input, theme, width, cache, color, section}; -use claude_statusline::section::RenderContext; -use std::io::Read; - -fn main() { - let args: Vec = std::env::args().collect(); - - // Parse CLI flags (no clap — only 4 flags) - if args.iter().any(|a| a == "--help" || a == "-h") { - print_help(); - return; - } - if args.iter().any(|a| a == "--config-schema") { - println!("{}", include_str!("../../schema.json")); - return; - } - if args.iter().any(|a| a == "--print-defaults") { - println!("{}", include_str!("../../defaults.json")); - return; - } - if args.iter().any(|a| a == "--list-sections") { - for (id, _) in section::registry() { - println!("{id}"); - } - return; - } - - let cli_color = args.iter() - .find_map(|a| a.strip_prefix("--color=")) - .map(|s| s.to_string()); - - let config_path = args.iter() - .position(|a| a == "--config") - .and_then(|i| args.get(i + 1)) - .map(|s| s.as_str()); - - let cli_width = args.iter() - .find_map(|a| a.strip_prefix("--width=").or_else(|| { - args.iter().position(|x| x == "--width") - .and_then(|i| args.get(i + 1)) - .map(|s| s.as_str()) - })) - .and_then(|s| s.parse::().ok()); - - let no_cache = args.iter().any(|a| a == "--no-cache") - || std::env::var("CLAUDE_STATUSLINE_NO_CACHE").is_ok(); - let no_shell = args.iter().any(|a| a == "--no-shell") - || std::env::var("CLAUDE_STATUSLINE_NO_SHELL").is_ok(); - let clear_cache = args.iter().any(|a| a == "--clear-cache"); - - let is_test = args.iter().any(|a| a == "--test"); - let dump_state = args.iter().find_map(|a| { - if a == "--dump-state" { Some("text") } - else { a.strip_prefix("--dump-state=") } - }); - let validate_config = args.iter().any(|a| a == "--validate-config"); - - // Load config - let (config, warnings) = match config::load_config(config_path) { - Ok(v) => v, - Err(e) => { - eprintln!("claude-statusline: {e}"); - std::process::exit(1); - } - }; - - if validate_config { - if warnings.is_empty() { - println!("config valid"); - std::process::exit(0); - } else { - for w in &warnings { - eprintln!("{w}"); - } - std::process::exit(1); - } - } - - // Warn on unknown keys (non-fatal) - if config.global.warn_unknown_keys { - for w in &warnings { - eprintln!("claude-statusline: {w}"); - } - } - - // Read stdin JSON - let input_data: input::InputData = if is_test { - serde_json::from_str(include_str!("../../test_data.json")) - .unwrap_or_default() - } else { - let mut buf = String::new(); - if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() { - return; // empty stdin -> exit 0, no output - } - match serde_json::from_str(&buf) { - Ok(v) => v, - Err(e) => { - eprintln!("claude-statusline: stdin: {e}"); - std::process::exit(1); - } - } - }; - - // Detect environment - let detected_theme = theme::detect_theme(&config); - let term_width = width::detect_width(cli_width, config.global.width, config.global.width_margin); - let tier = width::width_tier( - term_width, - config.global.breakpoints.narrow, - config.global.breakpoints.medium, - ); - - let project_dir = input_data.workspace.as_ref() - .and_then(|w| w.project_dir.as_deref()) - .unwrap_or("."); - - let session = cache::session_id(project_dir); - let cache = cache::Cache::new(&config.global.cache_dir, &session); - - let color_enabled = color::should_use_color( - cli_color.as_deref(), - &config.global.color, - ); - - let vcs_type = detect_vcs(project_dir, &config); - - // Handle --dump-state - if let Some(format) = dump_state { - dump_state_output(format, &config, term_width, tier, detected_theme, vcs_type, project_dir, &session); - return; - } - - // Build render context - let project_path = std::path::Path::new(project_dir); - let ctx = RenderContext { - input: &input_data, - config: &config, - theme: detected_theme, - width_tier: tier, - term_width, - vcs_type, - project_dir: project_path, - cache: &cache, - glyphs_enabled: config.glyphs.enabled, - color_enabled, - }; - - // Render and output - let output = claude_statusline::layout::render_all(&ctx); - print!("{output}"); -} - -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); - - match prefer { - "jj" => { - if path.join(".jj").is_dir() { section::VcsType::Jj } - else { section::VcsType::None } - } - "git" => { - if path.join(".git").is_dir() { section::VcsType::Git } - else { section::VcsType::None } - } - _ => { - if path.join(".jj").is_dir() { section::VcsType::Jj } - else if path.join(".git").is_dir() { section::VcsType::Git } - else { section::VcsType::None } - } - } -} - -fn dump_state_output( - format: &str, - config: &config::Config, - term_width: u16, - tier: width::WidthTier, - theme: theme::Theme, - vcs: section::VcsType, - project_dir: &str, - session_id: &str, -) { - // JSON output with comprehensive debug info - let json = serde_json::json!({ - "terminal": { - "effective_width": term_width, - "width_margin": config.global.width_margin, - "width_tier": format!("{tier:?}"), - }, - "theme": theme.as_str(), - "vcs": format!("{vcs:?}"), - "layout": { - "justify": format!("{:?}", config.global.justify), - "separator": &config.global.separator, - }, - "paths": { - "project_dir": project_dir, - "cache_dir": config.global.cache_dir.replace("{session_id}", session_id), - }, - "session_id": session_id, - }); - - match format { - "json" => println!("{}", serde_json::to_string_pretty(&json).unwrap()), - _ => println!("{json:#}"), - } -} - -fn print_help() { - println!("claude-statusline — Fast, configurable status line for Claude Code - -USAGE: - claude-statusline Read JSON from stdin, output status line - claude-statusline --test Render with mock data to validate config - claude-statusline --dump-state[=text|json] Output internal state for debugging - claude-statusline --validate-config Validate config (exit 0=ok, 1=errors) - claude-statusline --config-schema Print schema JSON to stdout - claude-statusline --print-defaults Print defaults JSON to stdout - claude-statusline --list-sections List all section IDs - claude-statusline --config Use alternate config file - claude-statusline --no-cache Disable caching for this run - claude-statusline --no-shell Disable all shell-outs (serve stale cache) - claude-statusline --clear-cache Remove cache directory and exit - claude-statusline --width Force terminal width - claude-statusline --color=auto|always|never Override color detection - claude-statusline --help Show this help"); -} -``` - ---- - -## 24 Built-in Sections - -| Section | Color | Shell-out | Cached | Width-tier varies | -|---------|-------|-----------|--------|-------------------| -| model | bold | no | no | no | -| provider | dim | no | no | no | -| project | cyan | no | no | no (truncation only) | -| vcs | green/yellow/dim | git/jj | yes (3s/5s/30s) | yes (ahead/behind hidden narrow) | -| beads | yellow/green/blue/dim | br | yes (30s) | yes (counts hidden narrow) | -| context_bar | threshold | no | no | no (flex-expandable) | -| context_usage | threshold/dim | no | no | no | -| context_remaining | threshold/dim | no | no | no | -| tokens_raw | dim | no | no | yes (labels/decimals) | -| cache_efficiency | dim/green/boldgreen | no | no | no | -| cost | threshold | no | no | yes (decimal places) | -| cost_velocity | dim | no | no | no | -| token_velocity | dim | no | no | yes (suffix) | -| cost_trend | dim | no | trend file | no | -| context_trend | threshold | no | trend file | no | -| lines_changed | green/red | no | no | no | -| duration | dim | no | no | no | -| tools | dim | no | no | yes (label/last name) | -| turns | dim | no | no | no | -| load | dim | sysctl/proc | yes (10s) | no | -| version | dim | no | no | no | -| time | dim | date | no | no | -| output_style | magenta | no | no | no | -| hostname | dim | hostname | no | no | -| spacer | n/a | no | no | n/a (virtual, flex) | -| custom | configurable | bash -c | yes (user TTL) | no | - -### Shell-out optimization - -**Combined git call**: The bash version forks three separate git processes per render (branch, dirty, ahead/behind), each with independent cache TTLs. The Rust port combines these into a single `git status --porcelain=v2 --branch` call (with `-uno` when `untracked = "no"` and `-c status.submoduleSummary=false` when `submodules = false`; `fast_mode` skips untracked and submodules regardless) that returns branch name, ahead/behind counts, and dirty status in one fork. The three cache TTLs are preserved by caching the parsed sub-results independently (branch for 3s, dirty for 5s, ahead/behind for 30s), but all three are populated from a single execution when any one expires. - -**Parallel shell-outs on cache miss**: When multiple shell-out sections have expired caches in the same render (e.g., vcs + load + beads), execute them in parallel using `std::thread::scope`. Cache hits are >90% of renders, so this only matters for the occasional cold render. - -```rust -// Parallel shell-outs using std::thread::scope (borrows from calling scope, no 'static) -std::thread::scope(|s| { - let vcs_handle = s.spawn(|| { - shell::exec_with_timeout("git", &["-C", dir, "status", "--porcelain=v2", "--branch"], - None, Duration::from_millis(200)) - }); - let load_handle = s.spawn(|| { - shell::exec_with_timeout("sysctl", &["-n", "vm.loadavg"], None, Duration::from_millis(100)) - }); - let beads_handle = s.spawn(|| { - shell::exec_with_timeout("br", &["status", "--json"], None, Duration::from_millis(200)) - }); - - let vcs_out = vcs_handle.join().ok().flatten(); - let load_out = load_handle.join().ok().flatten(); - let beads_out = beads_handle.join().ok().flatten(); - // ... cache results ... -}); -``` - -**Lazy shell-outs**: Layout planning happens before invoking shell-outs. Sections that are dropped by width or preset never execute their commands. - -**Command timeouts + stable env**: All shell-outs use `global.shell_timeout_ms` (default: 200ms), cap output to `global.shell_max_output_bytes` (default: 8192), and merge `global.shell_env` with per-command env. Set `GIT_OPTIONAL_LOCKS=0`, `GIT_TERMINAL_PROMPT=0`, and `LC_ALL=C` to avoid lock contention, prompts, and locale-dependent parsing. On timeout or error, return stale cache if available. - -**Allow/deny list**: If `global.shell_enabled = false` (or `--no-shell` / `CLAUDE_STATUSLINE_NO_SHELL`), or a command is not in `shell_allowlist` / is in `shell_denylist`, skip execution and return stale cache if present. - -**Circuit breaker**: Track consecutive failures per command key (timeout or non-zero exit). If failures exceed `shell_failure_threshold` (default: 3), open a cooldown window (`shell_cooldown_ms`, default: 30s) and serve stale cache or `None` without executing the command. Resets on success or cooldown expiry. - ---- - -## Layout Engine Algorithms - -### Priority Drop -When total width exceeds terminal width: -If `drop_strategy = "tiered"` (default): -1. Remove ALL priority 3 sections at once. Recalculate. -2. If still too wide, remove ALL priority 2 sections at once. -3. Priority 1 sections never drop. - -If `drop_strategy = "gradual"`: -1. Drop sections one-by-one by (priority desc, width cost desc, rightmost first), recompute after each removal. -2. Priority 1 sections never drop. - -### Flex Expansion -When total width is less than terminal width and a flex section exists: -- Spacers take priority over non-spacer flex sections -- Only one flex section wins per line -- **Spacer**: Fill with spaces -- **context_bar**: Rebuild bar with wider width (recalculate filled/empty chars) -- **Other**: Pad with trailing spaces - -### Justify Modes -- **left** (default): Fixed separators, left-packed. Flex expansion applies. -- **spread / space-between**: Distribute extra space evenly across gaps. Center separator core (e.g., `|`) within each gap. Remainder chars distributed left-to-right. Bypassed if any spacer is present. - -### Separator Handling -- Default separator: ` | ` (configurable) -- Spacers suppress adjacent separators on both sides -- Separators rendered dim -- SEP_CORE = non-space portion (e.g., `|` from ` | `) - -### Responsive Layout -When `global.responsive = true` and layout is a preset name (string): -- width < 60: use "dense" preset (1 line) -- 60-99: use "standard" preset (2 lines) -- >= 100: use "verbose" preset (3 lines) - -Use `breakpoint_hysteresis` (default: 2) to avoid toggling presets if width fluctuates within ±hysteresis columns of a breakpoint. - -Explicit array layouts ignore responsive mode. - -### Render Pipeline (lazy) -1. Resolve layout preset -> ordered section ids by line -2. Build a RenderPlan using cached widths or fixed estimates per section -3. Start render budget timer; render only included sections (shell-outs skipped for dropped sections) -4. Recompute widths from real outputs; if overflow remains, apply one bounded reflow (priority drop + flex rebuild) to avoid oscillation -5. If `global.render_budget_ms` (default: 8ms) is exceeded, drop remaining lowest-priority sections and emit partial output - ---- - -## Architectural Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Section system | Registry of `SectionDescriptor { id, render_fn, estimated_width, shell_out }` | Single source of truth for IDs, validation, CLI introspection | -| Config types | Fully typed structs with `#[serde(flatten)]` for shared SectionBase | Compile-time safety, IDE completion, no stringly-typed lookups | -| Layout pipeline | 3-phase: Plan → Render survivors → Reflow | Lazy: dropped sections never shell-out | -| Color override | `apply_formatting` re-wraps `raw` text (discards section's internal ANSI) | Matches bash behavior where user color overrides trump section defaults | -| Shell-out timeout | Polling `try_wait()` + 5ms sleep | No extra crate, bounded latency | -| Parallel shell-outs | `std::thread::scope` | Borrows from calling scope, no `'static` requirement | -| Width memoization | `Mutex>` with 1s TTL | Avoids repeating process tree walk on 2-3 consecutive renders | -| Cache concurrency | `flock(LOCK_EX \| LOCK_NB)` | Skip cache on contention rather than blocking render | -| Render budget | `global.render_budget_ms` (default: 8ms) with graceful degradation | Prevents UI stalls from cascading shell-out delays | -| Shell circuit breaker | Per-command failure tracking with cooldown window | Stops hammering failing commands; serves stale cache instead | -| Config discovery | XDG + dot-config + legacy `~/.claude` fallback chain | Modern tooling compat without breaking existing users | - ---- - -## Width Detection Priority Chain - -1. `--width ` CLI flag -2. `CLAUDE_STATUSLINE_WIDTH` env var -3. `global.width` config (explicit override) -4. `ioctl(TIOCGWINSZ)` on stdout fd (zero-cost syscall, correct when stdout is a real TTY) -5. Process tree walk: start at PID, walk ppid chain, find TTY, `stty size < /dev/{tty}` (handles multiplexed terminals where stdout is a pipe) -6. `stty size < /dev/tty` -7. `$COLUMNS` env var -8. `tput cols` -9. Fallback: 120 - -Final width = detected - `global.width_margin` (default: 4) - -**Memoization**: Cache detected width for 1 second. Since renders happen every ~300ms, this avoids repeating the process tree walk on 2-3 consecutive renders. In-memory only (a `(u16, Instant)` tuple), not file-based. - -Width tiers (for progressive disclosure within sections): -- narrow: < breakpoints.narrow (default 60) -- medium: < breakpoints.medium (default 100) -- wide: >= breakpoints.medium - ---- - -## Cache System - -### Session ID -MD5 of `workspace.project_dir`, truncated to 12 hex chars. Same algorithm as bash for cache sharing during migration. - -### Cache Namespace -`{session_id}-{cache_version}-{config_hash}` to avoid stale data across upgrades or config edits. - -### Directory -Template: `/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/` (configurable). Created with `chmod 700`. Ownership verified (not a symlink, owned by current user, not world-writable). Caching disabled if suspicious. - -### Cache GC -- At most once per `cache_gc_interval_hours` (default: 24), scan `/tmp/claude-sl-*` and delete dirs older than `cache_gc_days` (default: 7), owned by current user, not symlinks. -- Use a lock file in `/tmp` to avoid concurrent GC across renders. -- GC runs asynchronously after render output is written, never blocking the status line. - -### Per-key Caching -- File per key: `$CACHE_DIR/$sanitized_key` -- TTL checked via file mtime vs current time -- **TTL jitter**: Add a random +/- `cache_ttl_jitter_pct` (default: 10%) to TTL to desynchronize expiration across concurrent renders -- **Atomic writes**: Write to `$key.tmp`, then `rename()` to `$key` (prevents partial reads if process is killed mid-write) -- **Stale fallback**: On command failure, return previous cached value if it exists (prevents status line flicker during transient errors like git lock contention) -- Key sanitization: `[^a-zA-Z0-9_-]` -> `_` -- **Concurrency guard**: Use per-key lock files with `flock` (short wait, then no-cache) to prevent interleaved writes across concurrent renders. Trend appends use the same lock. - -### Trend Tracking -- Append-only comma-separated files: `$CACHE_DIR/trend_{key}` -- **Write throttling**: Append at most once per 5 seconds (configurable). This is for sparkline data quality — 8 points at 5s intervals covers 40s of history (meaningful trends), vs 8 points at 300ms covering 2.4s (noise). -- Skip append if value unchanged from last point -- Max N points (default 8), trim from left -- Sparkline: 8 Unicode block chars (▁▂▃▄▅▆▇█), normalized to min/max range -- **Flat-series guard**: When min == max, render mid-height blocks (▄) for all points (prevents divide-by-zero in normalization) - ---- - -## Implementation Phases - -### Phase 1: Skeleton + Pure Sections -- `main.rs`: CLI flags, stdin read, `--test` with mock data, `--help` -- `config.rs`: Load embedded defaults, merge user config (recursive serde_json::Value merge) -- `input.rs`: Deserialize stdin JSON -- `color.rs`: ANSI constants, `color_by_name()` with compound styles and palette refs -- `theme.rs`: COLORFGBG parsing -- `format.rs`: `human_tokens()`, `human_duration()`, truncation (right/middle/left, grapheme-cluster-safe using `unicode-segmentation`) -- `metrics.rs`: Compute derived values once (cost velocity, token velocity, usage %, totals), reused by all sections -- Port 17 pure sections (no shell-outs): - model, provider, project, context_bar, context_usage, context_remaining, tokens_raw, cache_efficiency, - cost, cost_velocity, token_velocity, lines_changed, duration, tools, turns, - version, output_style -- `section/mod.rs`: Dispatch, `apply_formatting()`, `apply_truncation()` -- **Verify**: `--test` produces correct output for all pure sections - -**Exit criteria**: -- `claude-statusline --test` succeeds and produces stable output -- Config load + deep merge + defaults verified with unit tests - -**Non-goals**: -- No shell-outs -- No cache system yet - -### Phase 2: Layout Engine -- `layout/mod.rs`: `resolve_layout()` (preset lookup, responsive override) -- `layout/priority.rs`: Drop tier 3 all at once, then tier 2. Never tier 1. -- `layout/flex.rs`: Spacer expansion, context_bar rebuild, generic padding -- `layout/justify.rs`: Spread/space-between gap math with centered separator core -- Width tier calculation (narrow < 60, medium 60-99, wide >= 100) -- Separator handling (spacers suppress adjacent separators) -- All width calculations use `unicode-width::UnicodeWidthStr::width()` on raw text, not `.len()` or `.chars().count()`. Correctly handles CJK (2 cells), Nerd Font glyphs (1 or 2 cells), and zero-width joiners. -- **Verify**: Parity test - same stdin JSON at 80/120/175 cols = same raw output as bash - -**Exit criteria**: -- Parity tests pass for layout at 3 widths -- Width tiering and drop strategy behave deterministically - -### Phase 3: Width Detection + Cache + Shell-out Sections -- `width.rs`: Full priority chain (cli > env > config > ioctl > process tree walk > stty > COLUMNS > tput > 120), 1s in-memory memoization -- `cache.rs`: Secure dir (symlink + ownership + world-writable checks), per-key TTL via mtime, atomic write-rename, stale fallback on command failure -- `trend.rs`: Append-only files, sparkline, write throttling (5s), flat-series guard -- `glyph.rs`: Nerd Font + ASCII fallback -- Port shell-out sections: vcs (combined `git status --porcelain=v2 --branch` + jj), beads, load, hostname, time, custom commands -- Parallel shell-out execution on cache miss (`std::thread::scope`) -- **Verify**: Full parity with bash version including VCS and cache - -**Exit criteria**: -- All shell-outs respect timeout and cache -- Render time under budget in warm runs - -### Phase 4: Polish + Validation -- `--dump-state` (enhanced: width detection source, per-section render timing in microseconds, priority drop reasons, cache hit/miss per key, trend throttle state; supports `text` and `json` output) -- `--validate-config` (strict deserialize with path-aware errors via `serde_path_to_error` for unknown keys, type mismatches, deprecated fields; exit 0 = valid, exit 1 = errors) -- `--config-schema` (print schema JSON to stdout) -- `--print-defaults` (print defaults JSON to stdout) -- `--list-sections` (list all registered section IDs with metadata) -- Comprehensive snapshot tests - -**Exit criteria**: -- Snapshot tests stable across 3 widths -- `--validate-config` errors are actionable - -### Phase 5: Distribution (post-MVP) -- Cross-compile: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu -- GitHub Actions: CI (fmt, clippy, test) + Release (build, tar, checksums) -- Homebrew tap formula -- Update install.sh to prefer Rust binary -- Update README - ---- - -## Error Handling - -- **Startup**: Fail fast on explicit config path not found or stdin parse error -- **Sections**: Return `Option` - missing data = None = section skipped; unknown section IDs become validation errors via registry -- **Shell-outs**: Disabled (`--no-shell`), circuit-open, or denied by allow/deny list = skip and return stale cache if present; otherwise command failure or timeout = None unless stale cache is present -- **Cache**: Creation failure = disable caching, run commands directly - ---- - -## Testing - -1. **Unit tests**: Per-module (config merge, format helpers, color resolution, sparkline, priority drop, flex, justify) -2. **Snapshot tests**: Known JSON input -> expected raw output at specific widths -3. **Parity tests**: Run both bash and Rust with same input, diff raw output (ANSI stripped) -4. **Benchmarks**: `criterion` measuring parse-to-stdout. Target: <1ms warm -5. **Config validation tests**: `--validate-config` rejects unknown keys and type mismatches - ---- - -## Verification - -```bash -# Smoke test -echo '{"model":{"id":"claude-opus-4-6"},"cost":{"total_cost_usd":0.42}}' | claude-statusline - -# Parity with bash -echo "$TEST_JSON" | bash statusline.sh 2>/dev/null > /tmp/bash_out.txt -echo "$TEST_JSON" | claude-statusline > /tmp/rust_out.txt -diff <(sed 's/\x1b\[[0-9;]*m//g' /tmp/bash_out.txt) <(sed 's/\x1b\[[0-9;]*m//g' /tmp/rust_out.txt) - -# Performance comparison -hyperfine --warmup 3 'echo "$TEST_JSON" | claude-statusline' 'echo "$TEST_JSON" | bash statusline.sh' -``` - ---- - -## Distribution - -- `cargo install claude-statusline` (crates.io) -- `brew install tayloreernisse/tap/claude-statusline` (Homebrew tap, pre-built) -- GitHub Releases (direct download, macOS arm64+x86_64, Linux x86_64) - ---- - -## Switchover - -Drop-in replacement. Change `~/.claude/settings.json`: -```json -{ "statusLine": "claude-statusline" } -``` -Bash version remains available as fallback. Cache directories are compatible. - ---- - -## Edge Cases to Port Correctly - -1. Empty stdin -> exit 0, no output -2. `null` JSON values -> treated as missing (not the string "null") -3. Config deep merge: nested objects merged recursively, arrays replaced entirely -4. Separator core extraction: trim leading/trailing spaces from separator -5. context_bar flex rebuild: must re-apply `apply_formatting()` after -6. jj ahead/behind: hardcoded to 0/0 (jj has no upstream concept) -7. Tool name truncation: >12 chars -> 11 chars + `~` (must use display width, not char count) -8. Beads parts joined with ` | ` (literal, not global separator) -9. `stat -f %m` (macOS) vs `stat -c %Y` (Linux) for mtime — Rust uses `std::fs::metadata().modified()` (portable) -10. Session ID: first 12 chars of MD5 hex of project_dir -11. Truncation must split on grapheme cluster boundaries (not bytes or chars) -12. Stale cache fallback: on shell-out failure, return previous cached value if exists -13. Trend sparkline flat series: min == max -> mid-height block (▄) for all points -14. ioctl(TIOCGWINSZ) returns 0 when stdout is a pipe -- must fall through to process tree walk -15. Combined `git status --porcelain=v2 --branch` may fail on repos with no commits -- fall back to individual commands -16. Circuit breaker must track failures per command key in-memory (not cached) so it resets on process restart -17. Cache GC must only delete dirs owned by current user and must not follow symlinks -18. XDG config discovery: only use XDG/dot-config paths if the file actually exists (don't create empty files) -19. Render budget: partial output must still be valid ANSI (no unclosed escape sequences) \ No newline at end of file diff --git a/statusline.sh b/statusline.sh deleted file mode 100755 index 9805f50..0000000 --- a/statusline.sh +++ /dev/null @@ -1,2245 +0,0 @@ -#!/usr/bin/env bash -# Simple shell-style status line for Claude Code -set -euo pipefail - -# Dependencies check -if ! command -v jq &>/dev/null; then - printf "statusline: jq not found\n" >&2 - exit 1 -fi - -# Read JSON from stdin -INPUT=$(cat) - -# ANSI Colors (dimmed for status line) -C_RESET='\033[0m' -C_DIM='\033[2m' -C_GREEN='\033[32m' -C_CYAN='\033[36m' -C_YELLOW='\033[33m' - -# Get current directory -CWD=$(echo "$INPUT" | jq -r '.workspace.current_dir // .workspace.project_dir // empty') -if [ -z "$CWD" ]; then - CWD=$(pwd) -fi - -# Shorten path: replace home with ~ -CWD_DISPLAY="${CWD/#$HOME/\~}" - -# Get username and hostname -USER=$(whoami) -HOST=$(hostname -s) - -# Detect VCS (prefer jj over git) -VCS_BRANCH="" -VCS_DIRTY="" -if [ -d "$CWD/.jj" ]; then - # Jujutsu - VCS_BRANCH=$(cd "$CWD" && jj log -r @ --no-graph -T 'if(bookmarks, bookmarks.join(","), change_id.shortest(8))' --color=never 2>/dev/null || echo "") - if [ -n "$VCS_BRANCH" ]; then - VCS_DIRTY=$(cd "$CWD" && jj diff --stat --color=never 2>/dev/null | tail -1 || echo "") - if [ -n "$VCS_DIRTY" ] && [[ "$VCS_DIRTY" != *"0 files changed"* ]]; then - VCS_DIRTY="*" - else - VCS_DIRTY="" - fi - fi -elif [ -d "$CWD/.git" ]; then - # Git - VCS_BRANCH=$(cd "$CWD" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") - if [ -n "$VCS_BRANCH" ]; then - VCS_STATUS=$(cd "$CWD" && git status --porcelain --untracked-files=no 2>/dev/null || echo "") - if [ -n "$VCS_STATUS" ]; then - VCS_DIRTY="*" - else - VCS_DIRTY="" - fi - fi -fi - -# Build prompt -OUTPUT="" - -# user@host -OUTPUT+="${C_GREEN}${USER}@${HOST}${C_RESET}" - -# :path -OUTPUT+="${C_DIM}:${C_RESET}" -OUTPUT+="${C_CYAN}${CWD_DISPLAY}${C_RESET}" - -# (branch*) -if [ -n "$VCS_BRANCH" ]; then - OUTPUT+=" ${C_DIM}(${C_RESET}" - OUTPUT+="${C_GREEN}${VCS_BRANCH}${C_RESET}" - if [ -n "$VCS_DIRTY" ]; then - OUTPUT+="${C_YELLOW}${VCS_DIRTY}${C_RESET}" - fi - OUTPUT+="${C_DIM})${C_RESET}" -fi - -printf '%b' "$OUTPUT" - # Mock data for testing config without Claude Code - INPUT_JSON='{ - "model": {"id": "claude-opus-4-6-20260101", "display_name": "Opus 4.6"}, - "cost": { - "total_cost_usd": 0.42, - "total_duration_ms": 840000, - "total_lines_added": 156, - "total_lines_removed": 23, - "total_tool_uses": 7, - "last_tool_name": "Edit", - "total_turns": 12 - }, - "context_window": { - "used_percentage": 58.5, - "total_input_tokens": 115000, - "total_output_tokens": 8500, - "context_window_size": 200000, - "current_usage": { - "cache_read_input_tokens": 75000, - "cache_creation_input_tokens": 15000 - } - }, - "workspace": {"project_dir": "'"$(pwd)"'"}, - "version": "1.0.80", - "output_style": {"name": "learning"} - }' -elif [[ "$CLI_MODE" == "dump-state" ]]; then - # Dump-state mode uses minimal mock data to compute state - INPUT_JSON='{"workspace":{"project_dir":"'"$(pwd)"'"}}' -else - INPUT_JSON="$(cat)" - if [[ -z "$INPUT_JSON" ]]; then - exit 0 - fi -fi - -# ── ANSI Colors ─────────────────────────────────────────────────────────────── - -C_RESET='\033[0m' -C_BOLD='\033[1m' -C_DIM='\033[2m' -C_RED='\033[31m' -C_GREEN='\033[32m' -C_YELLOW='\033[33m' -C_BLUE='\033[34m' -C_MAGENTA='\033[35m' -C_CYAN='\033[36m' -C_WHITE='\033[37m' - -# ── Config Loading ──────────────────────────────────────────────────────────── - -# SCRIPT_DIR already resolved above -DEFAULTS_PATH="$SCRIPT_DIR/defaults.json" - -if [[ -f "$DEFAULTS_PATH" ]]; then - DEFAULTS_JSON="$(cat "$DEFAULTS_PATH")" -else - # Minimal fallback if defaults.json is missing - DEFAULTS_JSON='{"global":{},"presets":{"standard":[["model","project"]]},"layout":"standard","sections":{}}' -fi - -# User config contains only overrides — merged on top of defaults -USER_CONFIG_PATH="${CLAUDE_STATUSLINE_CONFIG:-$HOME/.claude/statusline.json}" -if [[ -f "$USER_CONFIG_PATH" ]]; then - USER_CONFIG_JSON="$(cat "$USER_CONFIG_PATH")" - # Deep merge: defaults * user (user wins) - CONFIG_JSON="$(jq -s '.[0] * .[1]' <<< "$DEFAULTS_JSON"$'\n'"$USER_CONFIG_JSON")" -else - CONFIG_JSON="$DEFAULTS_JSON" -fi - -# ── Config Preloading ───────────────────────────────────────────────────────── -# Extract all config values in a single jq call for performance. -# Each value is stored in a CFG_* variable. The cfg() function checks these -# preloaded values first, falling back to jq for non-preloaded paths. -# -# Variable naming: .global.separator -> CFG__global__separator -# (dots become double underscores to create valid bash variable names) - -# Preload all config values in one jq call -eval "$(jq -r ' - # Helper to output shell variable assignment - # Converts path like .global.separator to CFG__global__separator - def assign($name; $val): - "CFG_" + ($name | gsub("\\."; "__")) + "=" + ($val | @sh) + "\n"; - - # Global settings - assign(".global.separator"; .global.separator // " | ") + - assign(".global.justify"; .global.justify // "left") + - assign(".global.width"; (.global.width // "") | tostring) + - assign(".global.width_margin"; (.global.width_margin // 4) | tostring) + - assign(".global.responsive"; (.global.responsive // true) | tostring) + - assign(".global.theme"; .global.theme // "auto") + - assign(".global.vcs"; .global.vcs // "auto") + - assign(".global.cache_dir"; .global.cache_dir // "/tmp/claude-sl-{session_id}") + - assign(".global.breakpoints.narrow"; (.global.breakpoints.narrow // 60) | tostring) + - assign(".global.breakpoints.medium"; (.global.breakpoints.medium // 100) | tostring) + - - # Glyphs - assign(".glyphs.enabled"; (.glyphs.enabled // false) | tostring) + - - # Glyph set (Nerd Font icons) - (.glyphs.set // {} | to_entries | map( - assign(".glyphs.set." + .key; .value // "") - ) | add // "") + - - # Glyph fallbacks (ASCII) - (.glyphs.fallback // {} | to_entries | map( - assign(".glyphs.fallback." + .key; .value // "") - ) | add // "") + - - # Section enabled flags and priorities for all builtin sections - # Need to capture root context since we are inside a map - (. as $root | ["model", "provider", "project", "vcs", "beads", "context_bar", "context_usage", "tokens_raw", - "cache_efficiency", "cost", "cost_velocity", "token_velocity", "cost_trend", "context_trend", - "lines_changed", "duration", "tools", "turns", "load", "version", "time", - "output_style", "hostname"] | map(. as $section | - assign(".sections." + $section + ".enabled"; ($root.sections[$section].enabled // true) | tostring) + - assign(".sections." + $section + ".priority"; ($root.sections[$section].priority // 2) | tostring) + - assign(".sections." + $section + ".flex"; ($root.sections[$section].flex // false) | tostring) - ) | add // "") + - - # Section-specific settings - # VCS - assign(".sections.vcs.prefer"; .sections.vcs.prefer // "auto") + - assign(".sections.vcs.show_dirty"; (.sections.vcs.show_dirty // true) | tostring) + - assign(".sections.vcs.show_ahead_behind"; (.sections.vcs.show_ahead_behind // true) | tostring) + - assign(".sections.vcs.ttl.branch"; (.sections.vcs.ttl.branch // 3) | tostring) + - assign(".sections.vcs.ttl.dirty"; (.sections.vcs.ttl.dirty // 5) | tostring) + - assign(".sections.vcs.ttl.ahead_behind"; (.sections.vcs.ttl.ahead_behind // 30) | tostring) + - - # Beads - assign(".sections.beads.ttl"; (.sections.beads.ttl // 30) | tostring) + - assign(".sections.beads.show_wip"; (.sections.beads.show_wip // true) | tostring) + - assign(".sections.beads.show_wip_count"; (.sections.beads.show_wip_count // true) | tostring) + - assign(".sections.beads.show_ready_count"; (.sections.beads.show_ready_count // true) | tostring) + - assign(".sections.beads.show_open_count"; (.sections.beads.show_open_count // true) | tostring) + - assign(".sections.beads.show_closed_count"; (.sections.beads.show_closed_count // true) | tostring) + - - # Context bar - assign(".sections.context_bar.bar_width"; (.sections.context_bar.bar_width // 10) | tostring) + - assign(".sections.context_bar.thresholds.warn"; (.sections.context_bar.thresholds.warn // 50) | tostring) + - assign(".sections.context_bar.thresholds.danger"; (.sections.context_bar.thresholds.danger // 70) | tostring) + - assign(".sections.context_bar.thresholds.critical"; (.sections.context_bar.thresholds.critical // 85) | tostring) + - - # Tokens raw - assign(".sections.tokens_raw.format"; .sections.tokens_raw.format // "{input}in/{output}out") + - - # Cost - assign(".sections.cost.thresholds.warn"; (.sections.cost.thresholds.warn // 5.00) | tostring) + - assign(".sections.cost.thresholds.danger"; (.sections.cost.thresholds.danger // 8.00) | tostring) + - assign(".sections.cost.thresholds.critical"; (.sections.cost.thresholds.critical // 10.00) | tostring) + - - # Cost trend - assign(".sections.cost_trend.width"; (.sections.cost_trend.width // 8) | tostring) + - - # Context trend - assign(".sections.context_trend.width"; (.sections.context_trend.width // 8) | tostring) + - assign(".sections.context_trend.thresholds.warn"; (.sections.context_trend.thresholds.warn // 50) | tostring) + - assign(".sections.context_trend.thresholds.danger"; (.sections.context_trend.thresholds.danger // 70) | tostring) + - assign(".sections.context_trend.thresholds.critical"; (.sections.context_trend.thresholds.critical // 85) | tostring) + - - # Tools - assign(".sections.tools.show_last_name"; (.sections.tools.show_last_name // true) | tostring) + - - # Load - assign(".sections.load.ttl"; (.sections.load.ttl // 10) | tostring) + - - # Time - assign(".sections.time.format"; .sections.time.format // "%H:%M") + - - # Truncation settings for sections that support it - # Need to capture root context since we are inside a map - (. as $root | ["project", "vcs"] | map(. as $section | - assign(".sections." + $section + ".truncate.enabled"; ($root.sections[$section].truncate.enabled // false) | tostring) + - assign(".sections." + $section + ".truncate.max"; ($root.sections[$section].truncate.max // 0) | tostring) + - assign(".sections." + $section + ".truncate.style"; $root.sections[$section].truncate.style // "right") - ) | add // "") + - - # Themed color palettes (dark and light) - # Need to capture root context since we are inside nested maps - (. as $root | ["dark", "light"] | map(. as $theme | - ["success", "warning", "danger", "critical", "muted", "accent", "highlight", "info"] | map(. as $color | - assign(".colors." + $theme + "." + $color; $root.colors[$theme][$color] // "") - ) | add // "" - ) | add // "") + - - "" -' <<< "$CONFIG_JSON" 2>/dev/null)" || true - -# Helper: read config value with default (optimized with preloading) -# First checks preloaded CFG_* variables, falls back to jq for non-preloaded paths. -# Path conversion: .global.separator -> CFG__global__separator -cfg() { - local path="$1" default="${2:-}" - - # Convert path to variable name: .global.separator -> CFG__global__separator - local var_name="CFG_${path//./__}" - - # Check preloaded value first (fast path) - # Use eval for indirect variable access (bash 3.x compatible) - local val - eval "val=\"\${$var_name:-__CFG_UNSET__}\"" - if [[ "$val" != "__CFG_UNSET__" ]]; then - if [[ -z "$val" || "$val" == "null" ]]; then - printf '%s' "$default" - else - printf '%s' "$val" - fi - return - fi - - # Fallback to jq for non-preloaded paths (slow path) - val="$(jq -e "$path" <<< "$CONFIG_JSON" 2>/dev/null | jq -r 'tostring' 2>/dev/null)" || true - if [[ -z "$val" || "$val" == "null" ]]; then - printf '%s' "$default" - else - printf '%s' "$val" - fi -} - -# Helper: read from stdin JSON -inp() { - local path="$1" default="${2:-}" - local val - val="$(jq -r "$path // empty" <<< "$INPUT_JSON" 2>/dev/null)" || true - if [[ -z "$val" ]]; then - printf '%s' "$default" - else - printf '%s' "$val" - fi -} - -# Helper: check if section is enabled (uses preloaded values) -section_enabled() { - local section="$1" - local val - val="$(cfg ".sections.${section}.enabled" "true")" - [[ "$val" == "true" ]] -} - -# Helper: get section priority (uses preloaded values) -section_priority() { - local section="$1" - cfg ".sections.${section}.priority" "2" -} - -# ── Theme Detection ────────────────────────────────────────────────────────── -# Detects terminal background (light/dark) for color palette selection. -# Detection priority: -# 1. Config override (global.theme = "dark" or "light") -# 2. COLORFGBG env var (set by some terminals, format: "fg;bg") -# 3. Default to dark (most common for developers) -# -# Note: OSC 11 query is complex and unreliable without a TTY, so we use -# simpler heuristics. Users can always override via config. - -_detect_theme_impl() { - # 1. Config override - local theme_cfg - theme_cfg="$(cfg '.global.theme' 'auto')" - if [[ "$theme_cfg" != "auto" ]]; then - echo "$theme_cfg" - return - fi - - # 2. COLORFGBG env var (set by xterm, rxvt, some others) - # Format: "fg;bg" or "fg;bg;unused" — bg > 8 usually indicates light background - if [[ -n "${COLORFGBG:-}" ]]; then - local bg="${COLORFGBG##*;}" - # Standard ANSI colors 0-7 are dark, 8-15 are bright (light) - # Some terminals use 15 for white background - if [[ "$bg" =~ ^[0-9]+$ ]] && (( bg > 8 && bg < 16 )); then - echo "light" - return - fi - # bg values 0-8 or outside range -> dark - if [[ "$bg" =~ ^[0-9]+$ ]] && (( bg <= 8 )); then - echo "dark" - return - fi - fi - - # 3. Default to dark (most common for developers) - echo "dark" -} - -# Initialize detected theme -DETECTED_THEME="$(_detect_theme_impl)" - -# ── Color System ────────────────────────────────────────────────────────────── - -color_by_name() { - local name="$1" - - # Check for palette reference (p:colorname) - if [[ "$name" == p:* ]]; then - local palette_key="${name#p:}" - local resolved="" - - # Try themed palette first: .colors.{theme}.{key} - if [[ -n "$DETECTED_THEME" ]]; then - resolved="$(cfg ".colors.${DETECTED_THEME}.${palette_key}" "")" - fi - - # Fall back to flat palette: .colors.{key} (backwards compatibility) - if [[ -z "$resolved" ]]; then - resolved="$(cfg ".colors.${palette_key}" "")" - fi - - if [[ -z "$resolved" ]]; then - printf '%s' "$C_RESET" - return - fi - name="$resolved" - fi - - # Handle compound styles (e.g., "red bold") - local result="" - for part in $name; do - case "$part" in - red) result+="$C_RED" ;; - green) result+="$C_GREEN" ;; - yellow) result+="$C_YELLOW" ;; - blue) result+="$C_BLUE" ;; - magenta) result+="$C_MAGENTA" ;; - cyan) result+="$C_CYAN" ;; - white) result+="$C_WHITE" ;; - dim) result+="$C_DIM" ;; - bold) result+="$C_BOLD" ;; - *) ;; - esac - done - - if [[ -z "$result" ]]; then - printf '%s' "$C_RESET" - else - printf '%s' "$result" - fi -} - -# ── Glyph System ───────────────────────────────────────────────────────────── -# Nerd Font glyphs with automatic ASCII fallback. -# When glyphs.enabled is true, uses fancy Unicode/Powerline symbols. -# When disabled (default), uses plain ASCII characters. - -GLYPHS_ENABLED="$(cfg '.glyphs.enabled' 'false')" - -# glyph NAME — returns the appropriate glyph or fallback -glyph() { - local name="$1" - if [[ "$GLYPHS_ENABLED" == "true" ]]; then - local val - val="$(cfg ".glyphs.set.${name}" "")" - if [[ -n "$val" ]]; then - printf '%s' "$val" - return - fi - fi - # Fallback (either glyphs disabled or specific glyph not in set) - cfg ".glyphs.fallback.${name}" "" -} - -# ── Cache Infrastructure ────────────────────────────────────────────────────── - -# Session ID needs to be stable across invocations for trends to accumulate. -# Use project directory hash so same project = same session cache. -_project_dir="$(inp '.workspace.project_dir' "$(pwd)")" - -# Sanitize project directory: reject paths with shell metacharacters -# This prevents command injection via malicious workspace.project_dir -sanitize_path() { - local path="$1" - # Reject paths containing shell metacharacters that could enable injection - # Allow: alphanumeric, slash, dash, underscore, dot, space, tilde - if [[ "$path" =~ [^a-zA-Z0-9/_.\-\ ~] ]]; then - # Fall back to current directory if path contains suspicious chars - pwd - else - printf '%s' "$path" - fi -} -_project_dir="$(sanitize_path "$_project_dir")" - -SESSION_ID="$(printf '%s' "$_project_dir" | md5 -q 2>/dev/null || printf '%s' "$_project_dir" | md5sum | cut -d' ' -f1)" -SESSION_ID="${SESSION_ID:0:12}" # Truncate to 12 chars - -CACHE_DIR_TEMPLATE="$(cfg '.global.cache_dir' '/tmp/claude-sl-{session_id}')" -CACHE_DIR="${CACHE_DIR_TEMPLATE//\{session_id\}/$SESSION_ID}" - -# Secure cache directory creation: prevent symlink attacks -if [[ ! -d "$CACHE_DIR" ]]; then - mkdir -p "$CACHE_DIR" 2>/dev/null || true - chmod 700 "$CACHE_DIR" 2>/dev/null || true -fi -# Verify we own the cache directory (defense against pre-created symlinks) -if [[ ! -d "$CACHE_DIR" ]] || [[ ! -O "$CACHE_DIR" ]]; then - # Disable caching if directory is suspicious - CACHE_DIR="" -fi - -# get_mtime FILE — portable mtime retrieval -get_mtime() { - local file="$1" - case "$(uname -s)" in - Darwin) stat -f %m "$file" 2>/dev/null ;; - *) stat -c %Y "$file" 2>/dev/null ;; - esac || echo 0 -} - -# cached_value KEY TTL_SECONDS COMMAND [ARGS...] -# Returns cached value if fresh, otherwise runs command and caches result. -# SECURITY: Commands are executed directly (not via eval) to prevent injection. -cached_value() { - local key="$1" ttl="$2" - shift 2 - - # If caching is disabled, just run the command - if [[ -z "$CACHE_DIR" ]]; then - "$@" 2>/dev/null || true - return - fi - - # Sanitize cache key to prevent path traversal - local safe_key="${key//[^a-zA-Z0-9_-]/_}" - local cache_file="$CACHE_DIR/$safe_key" - - if [[ -f "$cache_file" ]]; then - local now mtime age - now="$(date +%s)" - mtime="$(get_mtime "$cache_file")" - age=$(( now - mtime )) - if (( age < ttl )); then - cat "$cache_file" - return 0 - fi - fi - - local result - # Execute command directly without eval — prevents injection - result="$("$@" 2>/dev/null)" || true - printf '%s' "$result" > "$cache_file" - printf '%s' "$result" -} - -# ── Terminal Width ──────────────────────────────────────────────────────────── -# Claude Code runs the script without a TTY, so tput/COLUMNS often return wrong -# values. Detection priority: -# 1. Explicit config override (global.width) -# 2. CLAUDE_STATUSLINE_WIDTH env var -# 3. Walk process tree to find ancestor with a real TTY, then stty size on it -# 4. stty via /dev/tty (works on some systems) -# 5. COLUMNS env var -# 6. tput cols -# 7. Fallback: 120 - -detect_width() { - # 1. Config override - local cfg_width - cfg_width="$(cfg '.global.width' '')" - if [[ -n "$cfg_width" && "$cfg_width" != "0" ]]; then - echo "$cfg_width" - return - fi - - # 2. Env var override - if [[ -n "${CLAUDE_STATUSLINE_WIDTH:-}" ]]; then - echo "$CLAUDE_STATUSLINE_WIDTH" - return - fi - - # 3. Walk process tree to find ancestor's TTY - # The script runs without a TTY, but the shell that launched Claude Code has one. - local pid=$$ - while [[ "$pid" -gt 1 ]]; do - local tty_name - tty_name="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')" || break - if [[ -n "$tty_name" && "$tty_name" != "??" && "$tty_name" != "-" && "$tty_name" != "" ]]; then - local w - w="$(stty size < "/dev/$tty_name" 2>/dev/null | awk '{print $2}')" || true - if [[ -n "$w" ]] && (( w > 0 )); then - echo "$w" - return - fi - fi - pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')" || break - done - - # 4. stty via /dev/tty - local tty_width - tty_width="$(stty size < /dev/tty 2>/dev/null | awk '{print $2}')" || true - if [[ -n "$tty_width" ]] && (( tty_width > 0 )); then - echo "$tty_width" - return - fi - - # 5. COLUMNS env var (often wrong in non-interactive shells) - if [[ -n "${COLUMNS:-}" ]] && (( ${COLUMNS:-0} > 0 )); then - echo "$COLUMNS" - return - fi - - # 6. tput cols - local tput_width - tput_width="$(tput cols 2>/dev/null)" || true - if [[ -n "$tput_width" ]] && (( tput_width > 0 )); then - echo "$tput_width" - return - fi - - # 7. Fallback - echo 120 -} - -# The detected PTY width may exceed the actual rendering area due to -# terminal multiplexer borders (Zellij/tmux) or Claude Code UI chrome. -# Configurable via global.width_margin (default: 4). -WIDTH_MARGIN="$(cfg '.global.width_margin' '4')" -TERM_WIDTH=$(( $(detect_width) - WIDTH_MARGIN )) - -# ── Responsive Layout Selection ────────────────────────────────────────────── -# Auto-select layout preset based on terminal width when responsive mode is enabled. -# Breakpoints: narrow (<60) -> dense, medium (60-99) -> standard, wide (>=100) -> verbose - -RESPONSIVE="$(cfg '.global.responsive' 'true')" -RESPONSIVE_LAYOUT="" -if [[ "$RESPONSIVE" == "true" ]]; then - NARROW_BP="$(cfg '.global.breakpoints.narrow' '60')" - MEDIUM_BP="$(cfg '.global.breakpoints.medium' '100')" - - if (( TERM_WIDTH < NARROW_BP )); then - RESPONSIVE_LAYOUT="dense" - elif (( TERM_WIDTH < MEDIUM_BP )); then - RESPONSIVE_LAYOUT="standard" - else - RESPONSIVE_LAYOUT="verbose" - fi -fi - -# ── Width Tiers for Progressive Disclosure ─────────────────────────────────── -# Sections can show different levels of detail based on available width. -# Uses the same breakpoints as responsive layout selection. - -if (( TERM_WIDTH < ${NARROW_BP:-60} )); then - WIDTH_TIER="narrow" -elif (( TERM_WIDTH < ${MEDIUM_BP:-100} )); then - WIDTH_TIER="medium" -else - WIDTH_TIER="wide" -fi - -# ── Sparkline Helper ───────────────────────────────────────────────────────── - -sparkline() { - local values="$1" # comma-separated: "10,20,30,25,40" - local width="${2:-8}" - local chars="▁▂▃▄▅▆▇█" - - # Parse values into array - IFS=',' read -ra vals <<< "$values" - local count=${#vals[@]} - if (( count == 0 )); then - printf '%s' "" - return - fi - - # Find min/max - local min=${vals[0]} max=${vals[0]} - local v - for v in "${vals[@]}"; do - (( v < min )) && min=$v - (( v > max )) && max=$v - done - - # Handle flat line - if (( max == min )); then - local mid_char="${chars:4:1}" - local result="" - local i - for (( i = 0; i < count && i < width; i++ )); do - result+="$mid_char" - done - printf '%s' "$result" - return - fi - - # Map values to sparkline characters - local result="" - local range=$((max - min)) - local char_count=8 - local i - for (( i = 0; i < count && i < width; i++ )); do - local v=${vals[$i]} - local idx=$(( (v - min) * (char_count - 1) / range )) - result+="${chars:$idx:1}" - done - - printf '%s' "$result" -} - -# ── Trend Tracking ─────────────────────────────────────────────────────────── - -# Track a value for trend sparkline -track_trend() { - local key="$1" value="$2" max_points="${3:-8}" - local trend_file="$CACHE_DIR/trend_${key}" - - # Read existing values - local existing="" - [[ -f "$trend_file" ]] && existing="$(cat "$trend_file")" - - # Append new value - if [[ -n "$existing" ]]; then - existing="${existing},${value}" - else - existing="$value" - fi - - # Trim to max points - IFS=',' read -ra vals <<< "$existing" - if (( ${#vals[@]} > max_points )); then - vals=("${vals[@]: -$max_points}") - existing=$(IFS=','; echo "${vals[*]}") - fi - - printf '%s' "$existing" > "$trend_file" - printf '%s' "$existing" -} - -get_trend() { - local key="$1" - local trend_file="$CACHE_DIR/trend_${key}" - [[ -f "$trend_file" ]] && cat "$trend_file" || echo "" -} - -# ── Human-Readable Helpers ──────────────────────────────────────────────────── - -human_tokens() { - local n="${1:-0}" - if (( n >= 1000000 )); then - printf '%.1fM' "$(echo "scale=1; $n / 1000000" | bc 2>/dev/null || echo "$n")" - elif (( n >= 1000 )); then - printf '%.1fk' "$(echo "scale=1; $n / 1000" | bc 2>/dev/null || echo "$n")" - else - printf '%d' "$n" - fi -} - -human_duration() { - local ms="${1:-0}" - local secs=$(( ms / 1000 )) - if (( secs >= 3600 )); then - printf '%dh%dm' $(( secs / 3600 )) $(( (secs % 3600) / 60 )) - elif (( secs >= 60 )); then - printf '%dm' $(( secs / 60 )) - else - printf '%ds' "$secs" - fi -} - -# ── Truncation Helpers ─────────────────────────────────────────────────────── - -truncate_right() { - local s="$1" max="$2" ellipsis="${3:-…}" - if (( ${#s} <= max )); then - printf '%s' "$s" - else - printf '%s%s' "${s:0:$((max - 1))}" "$ellipsis" - fi -} - -truncate_middle() { - local s="$1" max="$2" ellipsis="${3:-…}" - if (( ${#s} <= max )); then - printf '%s' "$s" - else - local half=$(( (max - 1) / 2 )) - printf '%s%s%s' "${s:0:$half}" "$ellipsis" "${s: -$((max - 1 - half))}" - fi -} - -truncate_left() { - local s="$1" max="$2" ellipsis="${3:-…}" - if (( ${#s} <= max )); then - printf '%s' "$s" - else - printf '%s%s' "$ellipsis" "${s: -$((max - 1))}" - fi -} - -# Apply truncation to a section based on its config -# Modifies SEC_RAW and SEC_ANSI globals -apply_truncation() { - local id="$1" - local trunc_enabled trunc_max trunc_style - - # Read truncation config (works for both builtin and custom sections) - if is_builtin "$id"; then - trunc_enabled="$(cfg ".sections.${id}.truncate.enabled" "false")" - trunc_max="$(cfg ".sections.${id}.truncate.max" "0")" - trunc_style="$(cfg ".sections.${id}.truncate.style" "right")" - else - local idx - idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true - if [[ -n "$idx" ]]; then - trunc_enabled="$(jq -r ".custom[$idx].truncate.enabled // false" <<< "$CONFIG_JSON")" - trunc_max="$(jq -r ".custom[$idx].truncate.max // 0" <<< "$CONFIG_JSON")" - trunc_style="$(jq -r ".custom[$idx].truncate.style // \"right\"" <<< "$CONFIG_JSON")" - else - return - fi - fi - - # Skip if truncation not enabled or max is 0 - if [[ "$trunc_enabled" != "true" ]]; then - return - fi - if [[ "$trunc_max" == "0" || -z "$trunc_max" ]]; then - return - fi - - # Only truncate if content exceeds max - if (( ${#SEC_RAW} > trunc_max )); then - case "$trunc_style" in - middle) SEC_RAW="$(truncate_middle "$SEC_RAW" "$trunc_max")" ;; - left) SEC_RAW="$(truncate_left "$SEC_RAW" "$trunc_max")" ;; - *) SEC_RAW="$(truncate_right "$SEC_RAW" "$trunc_max")" ;; - esac - # Regenerate ANSI version - apply dim styling to truncated content - SEC_ANSI="${C_DIM}${SEC_RAW}${C_RESET}" - fi -} - -# ── VCS Detection ───────────────────────────────────────────────────────────── - -# PROJECT_DIR is already sanitized via _project_dir above -PROJECT_DIR="$_project_dir" -VCS_PREFER="$(cfg '.sections.vcs.prefer' "$(cfg '.global.vcs' 'auto')")" - -detect_vcs() { - local dir="${PROJECT_DIR:-.}" - case "$VCS_PREFER" in - jj) - if [[ -d "$dir/.jj" ]]; then echo "jj"; else echo "none"; fi - ;; - git) - if [[ -d "$dir/.git" ]]; then echo "git"; else echo "none"; fi - ;; - auto|*) - if [[ -d "$dir/.jj" ]]; then echo "jj" - elif [[ -d "$dir/.git" ]]; then echo "git" - else echo "none" - fi - ;; - esac -} - -VCS_TYPE="$(detect_vcs)" - -# ── VCS Helper Commands ─────────────────────────────────────────────────────── -# Safe wrappers that accept sanitized paths and don't require eval. - -_git_branch() { - local dir="$1" - git -C "$dir" rev-parse --abbrev-ref HEAD -} - -_git_dirty() { - local dir="$1" - git -C "$dir" status --porcelain --untracked-files=no | head -1 -} - -_git_ahead_behind() { - local dir="$1" - git -C "$dir" rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo '0 0' -} - -_jj_branch() { - jj log -r @ --no-graph -T 'if(bookmarks, bookmarks.join(","), change_id.shortest(8))' --color=never -} - -_jj_dirty() { - jj diff --stat --color=never | tail -1 -} - -_system_load() { - if [[ "$(uname)" == "Darwin" ]]; then - sysctl -n vm.loadavg | awk '{print $2}' - else - awk '{print $1}' /proc/loadavg - fi -} - -_beads_wip_id() { - br list --status=in_progress --json 2>/dev/null | jq -r 'if type == "array" then .[0].id // empty else empty end' -} - -# Get all beads stats in one call (cached) -_beads_stats() { - br status --json 2>/dev/null | jq -r '.summary // empty' -} - -# ── Section Renderers ───────────────────────────────────────────────────────── -# Each renderer sets two globals: -# SEC_RAW — plain text (for width calculation) -# SEC_ANSI — ANSI-colored text (for display) - -render_model() { - local display_name model_id - display_name="$(inp '.model.display_name' '')" - model_id="$(inp '.model.id' '?')" - - # Use model_id for parsing (display_name often equals model_id anyway) - local id_to_parse="$model_id" - - # Convert to lowercase for matching (bash 3 compatible) - local id_lower - id_lower="$(printf '%s' "$id_to_parse" | tr '[:upper:]' '[:lower:]')" - - # Extract base model name - local base_name="" - case "$id_lower" in - *opus*) base_name="Opus" ;; - *sonnet*) base_name="Sonnet" ;; - *haiku*) base_name="Haiku" ;; - *) base_name="${display_name:-$model_id}" ;; - esac - - # Try to extract version number (e.g., "4-5" or "3-5" or "4-6" → "4.5" or "3.5" or "4.6") - local version="" - if [[ "$id_lower" =~ (opus|sonnet|haiku)-([0-9]+)-([0-9]+) ]]; then - version="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" - elif [[ "$id_lower" =~ ([0-9]+)-([0-9]+)-(opus|sonnet|haiku) ]]; then - version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" - fi - - local name - if [[ -n "$version" ]]; then - name="${base_name} ${version}" - else - name="$base_name" - fi - - SEC_RAW="[$name]" - SEC_ANSI="${C_BOLD}[${name}]${C_RESET}" -} - -render_provider() { - local model_id - model_id="$(inp '.model.id' '')" - local provider="" - case "$model_id" in - us.anthropic.*|anthropic.*) provider="Bedrock" ;; - *@[0-9][0-9][0-9][0-9]*) provider="Vertex" ;; - claude-*) provider="Anthropic" ;; - *) provider="" ;; - esac - if [[ -z "$provider" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - SEC_RAW="$provider" - SEC_ANSI="${C_DIM}${provider}${C_RESET}" -} - -render_project() { - local dir - dir="$(inp '.workspace.project_dir' '')" - local name - if [[ -n "$dir" ]]; then - name="$(basename "$dir")" - else - name="$(basename "$(pwd)")" - fi - SEC_RAW="$name" - SEC_ANSI="${C_CYAN}${name}${C_RESET}" -} - -render_vcs() { - if [[ "$VCS_TYPE" == "none" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local dir="${PROJECT_DIR:-.}" - local branch="" dirty="" ahead="" behind="" - local show_dirty show_ahead_behind - show_dirty="$(cfg '.sections.vcs.show_dirty' 'true')" - show_ahead_behind="$(cfg '.sections.vcs.show_ahead_behind' 'true')" - - if [[ "$VCS_TYPE" == "git" ]]; then - local branch_ttl dirty_ttl ab_ttl - branch_ttl="$(cfg '.sections.vcs.ttl.branch' '3')" - dirty_ttl="$(cfg '.sections.vcs.ttl.dirty' '5')" - ab_ttl="$(cfg '.sections.vcs.ttl.ahead_behind' '30')" - - branch="$(cached_value "vcs_branch" "$branch_ttl" _git_branch "$dir")" - - if [[ "$show_dirty" == "true" ]]; then - local status_out - status_out="$(cached_value "vcs_dirty" "$dirty_ttl" _git_dirty "$dir")" - [[ -n "$status_out" ]] && dirty="$(glyph dirty)" - fi - - # Progressive disclosure: ahead/behind only for medium+ width - if [[ "$show_ahead_behind" == "true" && "$WIDTH_TIER" != "narrow" ]]; then - local ab_raw - ab_raw="$(cached_value "vcs_ab" "$ab_ttl" _git_ahead_behind "$dir")" - ahead="$(echo "$ab_raw" | awk '{print $1}')" - behind="$(echo "$ab_raw" | awk '{print $2}')" - fi - elif [[ "$VCS_TYPE" == "jj" ]]; then - local branch_ttl dirty_ttl - branch_ttl="$(cfg '.sections.vcs.ttl.branch' '3')" - dirty_ttl="$(cfg '.sections.vcs.ttl.dirty' '5')" - - branch="$(cached_value "vcs_branch" "$branch_ttl" _jj_branch)" - - if [[ "$show_dirty" == "true" ]]; then - local jj_diff - jj_diff="$(cached_value "vcs_dirty" "$dirty_ttl" _jj_dirty)" - [[ -n "$jj_diff" && "$jj_diff" != *"0 files changed"* && "$jj_diff" != "0"* ]] && dirty="$(glyph dirty)" - fi - # jj doesn't have a direct ahead/behind concept vs upstream - ahead="0" - behind="0" - fi - - # Build output with glyphs - local branch_glyph - branch_glyph="$(glyph branch)" - local raw="${branch_glyph}${branch}${dirty}" - local ansi="${C_GREEN}${branch_glyph}${branch}${C_RESET}" - [[ -n "$dirty" ]] && ansi="${ansi}${C_YELLOW}${dirty}${C_RESET}" - - # Progressive disclosure: show ahead/behind only for medium+ width - if [[ "$show_ahead_behind" == "true" && "$WIDTH_TIER" != "narrow" && ("${ahead:-0}" != "0" || "${behind:-0}" != "0") ]]; then - local ahead_glyph behind_glyph ab_str="" - ahead_glyph="$(glyph ahead)" - behind_glyph="$(glyph behind)" - [[ "${ahead:-0}" != "0" ]] && ab_str="${ahead_glyph}${ahead}" - [[ "${behind:-0}" != "0" ]] && ab_str="${ab_str}${behind_glyph}${behind}" - raw="${raw} ${ab_str}" - ansi="${ansi} ${C_DIM}${ab_str}${C_RESET}" - fi - - SEC_RAW="$raw" - SEC_ANSI="$ansi" -} - -render_beads() { - local ttl - ttl="$(cfg '.sections.beads.ttl' '30')" - local show_wip show_wip_count show_ready show_open show_closed - show_wip="$(cfg '.sections.beads.show_wip' 'true')" - show_wip_count="$(cfg '.sections.beads.show_wip_count' 'true')" - show_ready="$(cfg '.sections.beads.show_ready_count' 'true')" - show_open="$(cfg '.sections.beads.show_open_count' 'true')" - show_closed="$(cfg '.sections.beads.show_closed_count' 'true')" - - if ! command -v br &>/dev/null; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local parts=() - local ansi_parts=() - - # Get all stats in one cached call - local stats_json - stats_json="$(cached_value "beads_stats" "$ttl" _beads_stats)" - - if [[ -z "$stats_json" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Current WIP bead ID (always show if enabled — most important context) - if [[ "$show_wip" == "true" ]]; then - local wip - wip="$(cached_value "beads_wip_id" "$ttl" _beads_wip_id)" - if [[ -n "$wip" ]]; then - parts+=("$wip wip") - ansi_parts+=("${C_YELLOW}${wip}${C_RESET} ${C_DIM}wip${C_RESET}") - fi - fi - - # Ready count (high priority — what's available to work on) - if [[ "$show_ready" == "true" ]]; then - local ready_count - ready_count="$(jq -r '.ready_issues // 0' <<< "$stats_json")" - if [[ "${ready_count:-0}" -gt 0 ]]; then - parts+=("${ready_count} ready") - ansi_parts+=("${C_GREEN}${ready_count}${C_RESET} ${C_DIM}ready${C_RESET}") - fi - fi - - # Open count (medium+ width — total backlog) - if [[ "$show_open" == "true" && "$WIDTH_TIER" != "narrow" ]]; then - local open_count - open_count="$(jq -r '.open_issues // 0' <<< "$stats_json")" - if [[ "${open_count:-0}" -gt 0 ]]; then - parts+=("${open_count} open") - ansi_parts+=("${C_BLUE}${open_count}${C_RESET} ${C_DIM}open${C_RESET}") - fi - fi - - # In-progress count (medium+ width — shows workload) - if [[ "$show_wip_count" == "true" && "$WIDTH_TIER" != "narrow" ]]; then - local wip_count - wip_count="$(jq -r '.in_progress_issues // 0' <<< "$stats_json")" - if [[ "${wip_count:-0}" -gt 0 ]]; then - parts+=("${wip_count} wip") - ansi_parts+=("${C_YELLOW}${wip_count}${C_RESET} ${C_DIM}wip${C_RESET}") - fi - fi - - # Closed count (wide only — shows progress/completion) - if [[ "$show_closed" == "true" && "$WIDTH_TIER" == "wide" ]]; then - local closed_count - closed_count="$(jq -r '.closed_issues // 0' <<< "$stats_json")" - if [[ "${closed_count:-0}" -gt 0 ]]; then - parts+=("${closed_count} done") - ansi_parts+=("${C_DIM}${closed_count} done${C_RESET}") - fi - fi - - if [[ ${#parts[@]} -eq 0 ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local IFS=" | " - SEC_RAW="${parts[*]}" - SEC_ANSI="${ansi_parts[*]}" -} - -render_context_bar() { - local pct - pct="$(inp '.context_window.used_percentage' '')" - if [[ -z "$pct" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Round to integer - local pct_int - pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" - - local bar_width - bar_width="$(cfg '.sections.context_bar.bar_width' '10')" - local filled=$(( pct_int * bar_width / 100 )) - local empty=$(( bar_width - filled )) - - local bar="" - local i - for (( i = 0; i < filled; i++ )); do bar+="="; done - for (( i = 0; i < empty; i++ )); do bar+="-"; done - - SEC_RAW="[${bar}] ${pct_int}%" - - # Color by threshold - local warn danger critical - warn="$(cfg '.sections.context_bar.thresholds.warn' '50')" - danger="$(cfg '.sections.context_bar.thresholds.danger' '70')" - critical="$(cfg '.sections.context_bar.thresholds.critical' '85')" - - local color="$C_GREEN" - if (( pct_int >= critical )); then - color="${C_RED}${C_BOLD}" - elif (( pct_int >= danger )); then - color="$C_RED" - elif (( pct_int >= warn )); then - color="$C_YELLOW" - fi - - SEC_ANSI="${color}[${bar}] ${pct_int}%${C_RESET}" -} - -render_context_usage() { - # Shows context usage as "154k / 200k" (current context used / total capacity) - # NOTE: total_input/output_tokens are CUMULATIVE session totals, not current context! - # We must calculate current usage from: used_percentage * context_window_size / 100 - local pct context_size - pct="$(inp '.context_window.used_percentage' '')" - context_size="$(inp '.context_window.context_window_size' '')" - - if [[ -z "$pct" || -z "$context_size" || "$context_size" == "0" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Calculate current used tokens from percentage - local used - used="$(awk "BEGIN{printf \"%.0f\", $pct * $context_size / 100}" 2>/dev/null || echo "0")" - - if (( used == 0 )); then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local total="$context_size" - - local used_h total_h - used_h="$(human_tokens "$used")" - total_h="$(human_tokens "$total")" - - SEC_RAW="${used_h}/${total_h}" - - # Color by percentage threshold - local pct_int=0 - if [[ -n "$pct" ]]; then - pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" - fi - - local warn danger critical - warn="$(cfg '.sections.context_usage.thresholds.warn' '50')" - danger="$(cfg '.sections.context_usage.thresholds.danger' '70')" - critical="$(cfg '.sections.context_usage.thresholds.critical' '85')" - - local color="$C_GREEN" - if (( pct_int >= critical )); then - color="${C_RED}${C_BOLD}" - elif (( pct_int >= danger )); then - color="$C_RED" - elif (( pct_int >= warn )); then - color="$C_YELLOW" - fi - - SEC_ANSI="${color}${used_h}${C_RESET}${C_DIM}/${total_h}${C_RESET}" -} - -render_tokens_raw() { - local input output - input="$(inp '.context_window.total_input_tokens' '0')" - output="$(inp '.context_window.total_output_tokens' '0')" - - # Progressive disclosure: more detail at wider widths - # narrow: "115k/8k" (compact, no labels) - # medium: "115k in/8k out" (with labels) - # wide: "115.2k in / 8.5k out" (decimal precision, spaced) - local input_h output_h raw - - case "$WIDTH_TIER" in - narrow) - # Compact format: integer k/M values, no labels - input_h="$(human_tokens "$input")" - output_h="$(human_tokens "$output")" - raw="${input_h}/${output_h}" - ;; - medium) - # Standard format: k/M values with labels - input_h="$(human_tokens "$input")" - output_h="$(human_tokens "$output")" - local fmt - fmt="$(cfg '.sections.tokens_raw.format' '{input}in/{output}out')" - raw="${fmt//\{input\}/$input_h}" - raw="${raw//\{output\}/$output_h}" - ;; - wide) - # Verbose format: more precision, spaced separators - # Use one decimal place for better precision - if (( input >= 1000000 )); then - input_h="$(printf '%.1fM' "$(echo "scale=2; $input / 1000000" | bc 2>/dev/null || echo "$input")")" - elif (( input >= 1000 )); then - input_h="$(printf '%.1fk' "$(echo "scale=2; $input / 1000" | bc 2>/dev/null || echo "$input")")" - else - input_h="$input" - fi - if (( output >= 1000000 )); then - output_h="$(printf '%.1fM' "$(echo "scale=2; $output / 1000000" | bc 2>/dev/null || echo "$output")")" - elif (( output >= 1000 )); then - output_h="$(printf '%.1fk' "$(echo "scale=2; $output / 1000" | bc 2>/dev/null || echo "$output")")" - else - output_h="$output" - fi - raw="${input_h} in / ${output_h} out" - ;; - esac - - SEC_RAW="$raw" - SEC_ANSI="${C_DIM}${raw}${C_RESET}" -} - -render_cache_efficiency() { - local cache_read cache_creation - cache_read="$(inp '.context_window.current_usage.cache_read_input_tokens' '0')" - cache_creation="$(inp '.context_window.current_usage.cache_creation_input_tokens' '0')" - - local total=$(( cache_read + cache_creation )) - if (( total == 0 )); then - SEC_RAW="cache:0%" - SEC_ANSI="${C_DIM}cache:0%${C_RESET}" - return - fi - - local pct=$(( cache_read * 100 / total )) - SEC_RAW="cache:${pct}%" - - local color="$C_DIM" - (( pct >= 50 )) && color="$C_GREEN" - (( pct >= 80 )) && color="${C_GREEN}${C_BOLD}" - - SEC_ANSI="${color}cache:${pct}%${C_RESET}" -} - -render_cost() { - local cost_raw - cost_raw="$(inp '.cost.total_cost_usd' '')" - if [[ -z "$cost_raw" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Progressive disclosure: decimal precision based on width tier - # narrow: "$0" (no decimals, rounds to nearest dollar) - # medium: "$0.25" (2 decimals, standard) - # wide: "$0.2547" (4 decimals, precise) - local decimals=2 - case "$WIDTH_TIER" in - narrow) decimals=0 ;; - medium) decimals=2 ;; - wide) decimals=4 ;; - esac - - local cost - cost="$(printf "%.${decimals}f" "$cost_raw" 2>/dev/null || echo "$cost_raw")" - - SEC_RAW="\$${cost}" - - local warn danger critical - warn="$(cfg '.sections.cost.thresholds.warn' '5.00')" - danger="$(cfg '.sections.cost.thresholds.danger' '8.00')" - critical="$(cfg '.sections.cost.thresholds.critical' '10.00')" - - # Compare using awk (cost_raw retains full precision for comparison) - local color="$C_GREEN" - if awk "BEGIN{exit(!($cost_raw >= $critical))}"; then - color="${C_RED}${C_BOLD}" - elif awk "BEGIN{exit(!($cost_raw >= $danger))}"; then - color="$C_RED" - elif awk "BEGIN{exit(!($cost_raw >= $warn))}"; then - color="$C_YELLOW" - fi - - SEC_ANSI="${color}\$${cost}${C_RESET}" -} - -render_cost_velocity() { - local cost duration_ms - cost="$(inp '.cost.total_cost_usd' '0')" - duration_ms="$(inp '.cost.total_duration_ms' '0')" - - if [[ "$duration_ms" == "0" || -z "$duration_ms" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local velocity - velocity="$(awk "BEGIN{printf \"%.2f\", $cost / ($duration_ms / 60000)}" 2>/dev/null || echo "0.00")" - SEC_RAW="\$${velocity}/m" - SEC_ANSI="${C_DIM}\$${velocity}/m${C_RESET}" -} - -render_token_velocity() { - local input output duration_ms - input="$(inp '.context_window.total_input_tokens' '0')" - output="$(inp '.context_window.total_output_tokens' '0')" - duration_ms="$(inp '.cost.total_duration_ms' '0')" - - if [[ "$duration_ms" == "0" || -z "$duration_ms" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local total=$(( input + output )) - local velocity - velocity="$(awk "BEGIN{printf \"%.1f\", $total / ($duration_ms / 60000)}" 2>/dev/null || echo "0.0")" - - # Progressive disclosure: abbreviate based on width tier - local suffix="tok/m" - if [[ "$WIDTH_TIER" == "narrow" ]]; then - suffix="t/m" - fi - - # Human-readable for large values - if awk "BEGIN{exit !($velocity >= 1000)}" 2>/dev/null; then - velocity="$(awk "BEGIN{printf \"%.1f\", $velocity / 1000}" 2>/dev/null)k" - fi - - SEC_RAW="${velocity}${suffix}" - SEC_ANSI="${C_DIM}${velocity}${suffix}${C_RESET}" -} - -render_lines_changed() { - local added removed - added="$(inp '.cost.total_lines_added' '0')" - removed="$(inp '.cost.total_lines_removed' '0')" - - if [[ "$added" == "0" && "$removed" == "0" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - SEC_RAW="+${added} -${removed}" - SEC_ANSI="${C_GREEN}+${added}${C_RESET} ${C_RED}-${removed}${C_RESET}" -} - -render_duration() { - local ms - ms="$(inp '.cost.total_duration_ms' '')" - if [[ -z "$ms" || "$ms" == "0" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local human - human="$(human_duration "$ms")" - SEC_RAW="$human" - SEC_ANSI="${C_DIM}${human}${C_RESET}" -} - -render_tools() { - local tool_count - tool_count="$(inp '.cost.total_tool_uses' '0')" - - if [[ "$tool_count" == "0" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Progressive disclosure: detail based on width tier - # narrow: "42" (just the count) - # medium: "42 tools" (count with label) - # wide: "42 tools (Read)" (count, label, and last tool name) - local raw ansi - - case "$WIDTH_TIER" in - narrow) - raw="$tool_count" - ansi="${C_DIM}${tool_count}${C_RESET}" - ;; - medium) - raw="${tool_count} tools" - ansi="${C_DIM}${tool_count} tools${C_RESET}" - ;; - wide) - raw="${tool_count} tools" - ansi="${C_DIM}${tool_count} tools${C_RESET}" - - local show_last_name - show_last_name="$(cfg '.sections.tools.show_last_name' 'true')" - if [[ "$show_last_name" == "true" ]]; then - local last_tool - last_tool="$(inp '.cost.last_tool_name' '')" - if [[ -n "$last_tool" ]]; then - # Shorten tool name if too long - if (( ${#last_tool} > 12 )); then - last_tool="${last_tool:0:11}~" - fi - raw="${raw} (${last_tool})" - ansi="${ansi} ${C_DIM}(${last_tool})${C_RESET}" - fi - fi - ;; - esac - - SEC_RAW="$raw" - SEC_ANSI="$ansi" -} - -render_turns() { - local turns - turns="$(inp '.cost.total_turns' '')" - if [[ -z "$turns" || "$turns" == "0" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - SEC_RAW="${turns} turns" - SEC_ANSI="${C_DIM}${turns} turns${C_RESET}" -} - -render_load() { - local ttl - ttl="$(cfg '.sections.load.ttl' '10')" - local load_val - load_val="$(cached_value "load" "$ttl" _system_load)" - SEC_RAW="load:${load_val}" - SEC_ANSI="${C_DIM}load:${load_val}${C_RESET}" -} - -render_version() { - local ver - ver="$(inp '.version' '')" - if [[ -z "$ver" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - SEC_RAW="v${ver}" - SEC_ANSI="${C_DIM}v${ver}${C_RESET}" -} - -render_time() { - local fmt - fmt="$(cfg '.sections.time.format' '%H:%M')" - local t - t="$(date +"$fmt")" - SEC_RAW="$t" - SEC_ANSI="${C_DIM}${t}${C_RESET}" -} - -render_output_style() { - local style - style="$(inp '.output_style.name' '')" - if [[ -z "$style" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - SEC_RAW="$style" - SEC_ANSI="${C_MAGENTA}${style}${C_RESET}" -} - -render_hostname() { - local h - h="$(hostname -s 2>/dev/null || echo "?")" - SEC_RAW="$h" - SEC_ANSI="${C_DIM}${h}${C_RESET}" -} - -render_cost_trend() { - local cost - cost="$(inp '.cost.total_cost_usd' '')" - if [[ -z "$cost" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Convert to cents for integer sparkline (avoids floating point issues) - local cents - cents="$(awk "BEGIN{printf \"%.0f\", $cost * 100}" 2>/dev/null || echo "0")" - - # Get sparkline width from config - local width - width="$(cfg '.sections.cost_trend.width' '8')" - - local trend - trend="$(track_trend "cost" "$cents" "$width")" - - local spark - spark="$(sparkline "$trend" "$width")" - - if [[ -z "$spark" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - SEC_RAW="$spark" - SEC_ANSI="${C_DIM}$spark${C_RESET}" -} - -render_context_trend() { - local pct - pct="$(inp '.context_window.used_percentage' '')" - if [[ -z "$pct" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Round to integer - local pct_int - pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" - - # Get sparkline width from config - local width - width="$(cfg '.sections.context_trend.width' '8')" - - local trend - trend="$(track_trend "context" "$pct_int" "$width")" - - local spark - spark="$(sparkline "$trend" "$width")" - - if [[ -z "$spark" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - # Color based on current percentage - local warn danger critical - warn="$(cfg '.sections.context_trend.thresholds.warn' '50')" - danger="$(cfg '.sections.context_trend.thresholds.danger' '70')" - critical="$(cfg '.sections.context_trend.thresholds.critical' '85')" - - local color="$C_DIM" - if (( pct_int >= critical )); then - color="${C_RED}${C_BOLD}" - elif (( pct_int >= danger )); then - color="$C_RED" - elif (( pct_int >= warn )); then - color="$C_YELLOW" - fi - - SEC_RAW="$spark" - SEC_ANSI="${color}$spark${C_RESET}" -} - -# ── Custom Command Renderer ────────────────────────────────────────────────── - -# Helper for custom commands — uses bash -c since commands are shell strings -# Custom commands come from user config (trusted source), not stdin -_run_custom_cmd() { - local cmd="$1" - bash -c "$cmd" -} - -render_custom() { - local section_id="$1" - local idx - idx="$(jq -r --arg id "$section_id" ' - .custom | to_entries[] | select(.value.id == $id) | .key - ' <<< "$CONFIG_JSON" 2>/dev/null)" || true - - if [[ -z "$idx" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - local cmd label ttl_val - cmd="$(jq -r ".custom[$idx].command" <<< "$CONFIG_JSON")" - label="$(jq -r ".custom[$idx].label // .custom[$idx].id" <<< "$CONFIG_JSON")" - ttl_val="$(jq -r ".custom[$idx].ttl // 30" <<< "$CONFIG_JSON")" - - # Cache key sanitization is handled by cached_value() - local result - result="$(cached_value "custom_${section_id}" "$ttl_val" _run_custom_cmd "$cmd")" - - if [[ -z "$result" ]]; then - SEC_RAW="" - SEC_ANSI="" - return - fi - - SEC_RAW="${label}:${result}" - - # Color matching - local color_name - color_name="$(jq -r --arg val "$result" ".custom[$idx].color.match[\$val] // empty" <<< "$CONFIG_JSON" 2>/dev/null)" || true - - if [[ -n "$color_name" ]]; then - local c - c="$(color_by_name "$color_name")" - SEC_ANSI="${c}${label}:${result}${C_RESET}" - else - SEC_ANSI="${C_DIM}${label}:${result}${C_RESET}" - fi -} - -# ── Section Dispatch ────────────────────────────────────────────────────────── - -BUILTIN_SECTIONS="model provider project vcs beads context_bar context_usage tokens_raw cache_efficiency cost cost_velocity token_velocity cost_trend context_trend lines_changed duration tools turns load version time output_style hostname" - -is_builtin() { - local id="$1" - [[ " $BUILTIN_SECTIONS " == *" $id "* ]] -} - -is_spacer() { - local id="$1" - [[ "$id" == "spacer" || "$id" == _spacer* ]] -} - -render_section() { - local id="$1" - SEC_RAW="" - SEC_ANSI="" - - if is_spacer "$id"; then - SEC_RAW=" " - SEC_ANSI=" " - return - fi - - if is_builtin "$id"; then - if ! section_enabled "$id"; then - return - fi - "render_${id}" - else - # Try custom command - render_custom "$id" - fi - - # Apply truncation and formatting if section produced output - if [[ -n "$SEC_RAW" ]]; then - apply_truncation "$id" - apply_formatting "$id" - fi -} - -# ── Per-Section Formatting ───────────────────────────────────────────────── -# Applies prefix, suffix, color override, and pad+align to any section. -# Uses a single jq call to batch-read all formatting properties. - -apply_formatting() { - local id="$1" - - # Single jq call using @sh to produce shell-safe variable assignments. - # Tab-split (IFS=$'\t' read) doesn't work: tab is whitespace in POSIX, - # so consecutive tabs collapse and leading empty fields are swallowed. - local prefix="" suffix="" pad="" align="" color_name="" - if is_builtin "$id"; then - eval "$(jq -r ' - .sections["'"$id"'"] as $s | - "prefix=" + (@sh "\($s.prefix // "")") + - " suffix=" + (@sh "\($s.suffix // "")") + - " pad=" + (@sh "\(($s.pad // "") | tostring)") + - " align=" + (@sh "\($s.align // "")") + - " color_name=" + (@sh "\($s.color // "")") - ' <<< "$CONFIG_JSON" 2>/dev/null)" || true - else - # Custom commands use default_color to avoid conflict with color.match - eval "$(jq -r --arg id "$id" ' - (.custom | to_entries[] | select(.value.id == $id) | .value) as $sec | - "prefix=" + (@sh "\($sec.prefix // "")") + - " suffix=" + (@sh "\($sec.suffix // "")") + - " pad=" + (@sh "\(($sec.pad // "") | tostring)") + - " align=" + (@sh "\($sec.align // "")") + - " color_name=" + (@sh "\($sec.default_color // "")") - ' <<< "$CONFIG_JSON" 2>/dev/null)" || true - fi - - # If nothing to do, return early - if [[ -z "$prefix" && -z "$suffix" && -z "$pad" && -z "$color_name" ]]; then - return - fi - - # 1. Apply prefix/suffix - if [[ -n "$prefix" ]]; then - SEC_RAW="${prefix}${SEC_RAW}" - SEC_ANSI="${prefix}${SEC_ANSI}" - fi - if [[ -n "$suffix" ]]; then - SEC_RAW="${SEC_RAW}${suffix}" - SEC_ANSI="${SEC_ANSI}${suffix}" - fi - - # 2. Apply color override (re-wrap entire content) - if [[ -n "$color_name" ]]; then - local c - c="$(color_by_name "$color_name")" - SEC_ANSI="${c}${SEC_RAW}${C_RESET}" - fi - - # 3. Apply pad + align (spaces stay uncolored) - if [[ -n "$pad" ]] && (( pad > ${#SEC_RAW} )); then - local pad_needed=$(( pad - ${#SEC_RAW} )) - local pad_str="" - local p - for (( p = 0; p < pad_needed; p++ )); do pad_str+=" "; done - - case "${align:-left}" in - right) - SEC_RAW="${pad_str}${SEC_RAW}" - SEC_ANSI="${pad_str}${SEC_ANSI}" - ;; - center) - local left_pad=$(( pad_needed / 2 )) - local right_pad=$(( pad_needed - left_pad )) - local lpad="" rpad="" - for (( p = 0; p < left_pad; p++ )); do lpad+=" "; done - for (( p = 0; p < right_pad; p++ )); do rpad+=" "; done - SEC_RAW="${lpad}${SEC_RAW}${rpad}" - SEC_ANSI="${lpad}${SEC_ANSI}${rpad}" - ;; - *) # left (default) - SEC_RAW="${SEC_RAW}${pad_str}" - SEC_ANSI="${SEC_ANSI}${pad_str}" - ;; - esac - fi -} - -get_section_priority() { - local id="$1" - if is_spacer "$id"; then - echo "1" - elif is_builtin "$id"; then - section_priority "$id" - else - local idx - idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true - if [[ -n "$idx" ]]; then - jq -r ".custom[$idx].priority // 2" <<< "$CONFIG_JSON" - else - echo "2" - fi - fi -} - -is_flex_section() { - local id="$1" - if is_spacer "$id"; then - return 0 - elif is_builtin "$id"; then - local val - val="$(cfg ".sections.${id}.flex" "false")" - [[ "$val" == "true" ]] - else - local idx - idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true - if [[ -n "$idx" ]]; then - local val - val="$(jq -r ".custom[$idx].flex // false" <<< "$CONFIG_JSON")" - [[ "$val" == "true" ]] - else - return 1 - fi - fi -} - -# ── Layout Resolution ───────────────────────────────────────────────────────── - -resolve_layout() { - local layout_val - layout_val="$(jq -r '.layout' <<< "$CONFIG_JSON" 2>/dev/null)" || true - - if [[ -z "$layout_val" || "$layout_val" == "null" ]]; then - layout_val="standard" - fi - - # Check if it's a string (preset name) or array - local layout_type - layout_type="$(jq -r '.layout | type' <<< "$CONFIG_JSON" 2>/dev/null)" || true - - if [[ "$layout_type" == "string" ]]; then - # When responsive mode is enabled and layout is a preset name (not explicit array), - # use the responsive layout instead of the configured preset - local effective_layout="$layout_val" - if [[ -n "$RESPONSIVE_LAYOUT" ]]; then - effective_layout="$RESPONSIVE_LAYOUT" - fi - # Look up preset - jq -c ".presets[\"$effective_layout\"] // [[\"model\",\"project\"]]" <<< "$CONFIG_JSON" - else - # Explicit layout array provided — use it directly (ignore responsive) - jq -c '.layout' <<< "$CONFIG_JSON" - fi -} - -# ── Layout Engine ───────────────────────────────────────────────────────────── - -SEPARATOR="$(cfg '.global.separator' ' | ')" -SEP_LEN="${#SEPARATOR}" -JUSTIFY="$(cfg '.global.justify' 'left')" - -# Extract the visible "core" of the separator (non-space chars, e.g. "|" from " | ") -# Used as the anchor when building justified gaps -SEP_CORE="${SEPARATOR#"${SEPARATOR%%[! ]*}"}" # trim leading spaces -SEP_CORE="${SEP_CORE%"${SEP_CORE##*[! ]}"}" # trim trailing spaces -SEP_CORE_LEN="${#SEP_CORE}" -# If separator is pure spaces, core is empty — gaps will be pure space -if [[ -z "$SEP_CORE" ]]; then - SEP_CORE_LEN=0 -fi - -# ── Dump State Mode ─────────────────────────────────────────────────────────── -# Output all computed internal state as JSON for debugging - -if [[ "$CLI_MODE" == "dump-state" ]]; then - jq -n \ - --argjson term_width "$TERM_WIDTH" \ - --arg width_tier "$WIDTH_TIER" \ - --arg detected_theme "$DETECTED_THEME" \ - --arg vcs_type "$VCS_TYPE" \ - --arg responsive_layout "${RESPONSIVE_LAYOUT:-}" \ - --arg responsive_enabled "$RESPONSIVE" \ - --arg justify "$JUSTIFY" \ - --arg separator "$SEPARATOR" \ - --arg project_dir "$PROJECT_DIR" \ - --arg session_id "$SESSION_ID" \ - --arg cache_dir "$CACHE_DIR" \ - --arg config_path "$USER_CONFIG_PATH" \ - --arg defaults_path "$DEFAULTS_PATH" \ - --argjson width_margin "$WIDTH_MARGIN" \ - --argjson narrow_bp "${NARROW_BP:-60}" \ - --argjson medium_bp "${MEDIUM_BP:-100}" \ - --arg glyphs_enabled "$GLYPHS_ENABLED" \ - '{ - terminal: { - detected_width: ($term_width + $width_margin), - effective_width: $term_width, - width_margin: $width_margin, - width_tier: $width_tier - }, - responsive: { - enabled: ($responsive_enabled == "true"), - layout: (if $responsive_layout == "" then null else $responsive_layout end), - breakpoints: {narrow: $narrow_bp, medium: $medium_bp} - }, - theme: $detected_theme, - vcs: $vcs_type, - layout: { - justify: $justify, - separator: $separator - }, - glyphs_enabled: ($glyphs_enabled == "true"), - paths: { - project_dir: $project_dir, - config: $config_path, - defaults: $defaults_path, - cache_dir: $cache_dir - }, - session_id: $session_id - }' - exit 0 -fi - -render_line() { - local line_json="$1" - local section_ids=() - local raw_texts=() - local ansi_texts=() - local priorities=() - local flex_idx=-1 - - # Parse section IDs from JSON array - local count - count="$(jq 'length' <<< "$line_json")" - - local i - for (( i = 0; i < count; i++ )); do - local sid - sid="$(jq -r ".[$i]" <<< "$line_json")" - - render_section "$sid" - if [[ -z "$SEC_RAW" ]]; then - continue - fi - - section_ids+=("$sid") - raw_texts+=("$SEC_RAW") - ansi_texts+=("$SEC_ANSI") - priorities+=("$(get_section_priority "$sid")") - - if is_flex_section "$sid"; then - if (( flex_idx == -1 )); then - # First flex section found - flex_idx=$(( ${#section_ids[@]} - 1 )) - elif is_spacer "$sid" && ! is_spacer "${section_ids[$flex_idx]}"; then - # Spacer overrides a non-spacer flex section, but not another spacer - flex_idx=$(( ${#section_ids[@]} - 1 )) - fi - fi - done - - local n=${#section_ids[@]} - if (( n == 0 )); then - return - fi - - # If the only sections are spacers, skip the line - local non_spacer=0 - for (( i = 0; i < n; i++ )); do - if ! is_spacer "${section_ids[$i]}"; then - non_spacer=1 - break - fi - done - if (( non_spacer == 0 )); then - return - fi - - # Calculate total width using minimum separators - # Skips separator width when either adjacent section is a spacer - calc_width() { - local total=0 - local active=("$@") - local prev_idx=-1 - local idx - for idx in "${active[@]}"; do - if (( prev_idx >= 0 )); then - if ! is_spacer "${section_ids[$prev_idx]}" && ! is_spacer "${section_ids[$idx]}"; then - total=$(( total + SEP_LEN )) - fi - fi - total=$(( total + ${#raw_texts[$idx]} )) - prev_idx=$idx - done - echo "$total" - } - - # Calculate content-only width (no separators) - calc_content_width() { - local total=0 - local active=("$@") - local idx - for idx in "${active[@]}"; do - total=$(( total + ${#raw_texts[$idx]} )) - done - echo "$total" - } - - # Build active indices - local active_indices=() - for (( i = 0; i < n; i++ )); do - active_indices+=("$i") - done - - local total_width - total_width="$(calc_width "${active_indices[@]}")" - - # Priority drop: remove priority 3 from right - if (( total_width > TERM_WIDTH )); then - local new_active=() - for idx in "${active_indices[@]}"; do - if [[ "${priorities[$idx]}" != "3" ]]; then - new_active+=("$idx") - fi - done - active_indices=("${new_active[@]}") - total_width="$(calc_width "${active_indices[@]}")" - fi - - # Priority drop: remove priority 2 from right - if (( total_width > TERM_WIDTH )); then - local new_active=() - for idx in "${active_indices[@]}"; do - if [[ "${priorities[$idx]}" != "2" ]]; then - new_active+=("$idx") - fi - done - active_indices=("${new_active[@]}") - total_width="$(calc_width "${active_indices[@]}")" - fi - - local active_count=${#active_indices[@]} - - # ── Justify: spread / space-between ────────────────────────────────────── - # Distributes extra space into gaps between sections. - # "spread" — equal gaps everywhere (like CSS space-evenly) - # "space-between" — first/last flush to edges, equal gaps in between - # When justify is active, flex on individual sections is ignored. - - # Check if any active section is a spacer — if so, bypass justify - local has_spacer=0 - for idx in "${active_indices[@]}"; do - if is_spacer "${section_ids[$idx]}"; then - has_spacer=1 - break - fi - done - - if [[ "$JUSTIFY" != "left" ]] && (( has_spacer == 0 && active_count > 1 && total_width < TERM_WIDTH )); then - local content_width - content_width="$(calc_content_width "${active_indices[@]}")" - local num_gaps=$(( active_count - 1 )) - local available=$(( TERM_WIDTH - content_width )) - - # For space-between: all available space goes into the gaps - # For spread: conceptually N+1 slots, but since we can't pad before first - # or after last in a terminal line, we treat it as N gaps with equal size - # (effectively the same as space-between for terminal output) - local gap_width=$(( available / num_gaps )) - local gap_remainder=$(( available % num_gaps )) - - # Build gap separators: center the separator core in each gap - # E.g., gap_width=8 with core "|": " | " (3 left + 1 core + 4 right) - # Remainder chars get distributed one per gap from the left - - local gap_separators=() - local g - for (( g = 0; g < num_gaps; g++ )); do - local this_gap=$gap_width - # Distribute remainder: first gaps get +1 - if (( g < gap_remainder )); then - this_gap=$(( this_gap + 1 )) - fi - - local sep_str="" - if (( SEP_CORE_LEN > 0 )); then - local pad_total=$(( this_gap - SEP_CORE_LEN )) - if (( pad_total < 0 )); then pad_total=0; fi - local pad_left=$(( pad_total / 2 )) - local pad_right=$(( pad_total - pad_left )) - local lpad="" rpad="" - for (( i = 0; i < pad_left; i++ )); do lpad+=" "; done - for (( i = 0; i < pad_right; i++ )); do rpad+=" "; done - sep_str="${lpad}${SEP_CORE}${rpad}" - else - # No core char — pure space gap - for (( i = 0; i < this_gap; i++ )); do sep_str+=" "; done - fi - gap_separators+=("$sep_str") - done - - # Assemble justified output - local output="" - local gap_idx=0 - local first=1 - for idx in "${active_indices[@]}"; do - if (( first )); then - first=0 - else - output+="${C_DIM}${gap_separators[$gap_idx]}${C_RESET}" - gap_idx=$(( gap_idx + 1 )) - fi - output+="${ansi_texts[$idx]}" - done - - printf '%b' "$output" - return - fi - - # ── Left-aligned layout with optional flex expansion ───────────────────── - - if (( flex_idx >= 0 && total_width < TERM_WIDTH )); then - # Check if flex section is still active - local flex_active=0 - for idx in "${active_indices[@]}"; do - if (( idx == flex_idx )); then - flex_active=1 - break - fi - done - - if (( flex_active )); then - local extra=$(( TERM_WIDTH - total_width )) - local old_raw="${raw_texts[$flex_idx]}" - local old_ansi="${ansi_texts[$flex_idx]}" - - # For spacers, replace placeholder with pure spaces - if is_spacer "${section_ids[$flex_idx]}"; then - local padding="" - for (( i = 0; i < extra + 1; i++ )); do padding+=" "; done - raw_texts[$flex_idx]="$padding" - ansi_texts[$flex_idx]="$padding" - elif [[ "${section_ids[$flex_idx]}" == "context_bar" ]]; then - local pct - pct="$(inp '.context_window.used_percentage' '0')" - local pct_int - pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" - - # Use config bar_width directly (not derived from raw text length, - # which may include formatting prefix/suffix/pad) - local cur_bar_width - cur_bar_width="$(cfg '.sections.context_bar.bar_width' '10')" - local new_bar_width=$(( cur_bar_width + extra )) - (( new_bar_width < 3 )) && new_bar_width=3 - - local filled=$(( pct_int * new_bar_width / 100 )) - local empty_count=$(( new_bar_width - filled )) - - local bar="" - for (( i = 0; i < filled; i++ )); do bar+="="; done - for (( i = 0; i < empty_count; i++ )); do bar+="-"; done - - raw_texts[$flex_idx]="[${bar}] ${pct_int}%" - - local warn danger critical - warn="$(cfg '.sections.context_bar.thresholds.warn' '50')" - danger="$(cfg '.sections.context_bar.thresholds.danger' '70')" - critical="$(cfg '.sections.context_bar.thresholds.critical' '85')" - - local color="$C_GREEN" - if (( pct_int >= critical )); then - color="${C_RED}${C_BOLD}" - elif (( pct_int >= danger )); then - color="$C_RED" - elif (( pct_int >= warn )); then - color="$C_YELLOW" - fi - - ansi_texts[$flex_idx]="${color}[${bar}] ${pct_int}%${C_RESET}" - - # Re-apply per-section formatting (prefix/suffix/color/pad) since - # the flex rebuild replaced the formatted output from scratch - SEC_RAW="${raw_texts[$flex_idx]}" - SEC_ANSI="${ansi_texts[$flex_idx]}" - apply_formatting "context_bar" - raw_texts[$flex_idx]="$SEC_RAW" - ansi_texts[$flex_idx]="$SEC_ANSI" - else - # Generic flex: pad with spaces - local padding="" - for (( i = 0; i < extra; i++ )); do padding+=" "; done - raw_texts[$flex_idx]="${old_raw}${padding}" - ansi_texts[$flex_idx]="${old_ansi}${padding}" - fi - fi - fi - - # Assemble left-aligned output - local output="" - local prev_asm_idx=-1 - for idx in "${active_indices[@]}"; do - if (( prev_asm_idx >= 0 )); then - # Suppress separator when either side is a spacer - if ! is_spacer "${section_ids[$prev_asm_idx]}" && ! is_spacer "${section_ids[$idx]}"; then - output+="${C_DIM}${SEPARATOR}${C_RESET}" - fi - fi - output+="${ansi_texts[$idx]}" - prev_asm_idx=$idx - done - - printf '%b' "$output" -} - -# ── Main ────────────────────────────────────────────────────────────────────── - -main() { - local layout_json - layout_json="$(resolve_layout)" - - local line_count - line_count="$(jq 'length' <<< "$layout_json")" - - local lines=() - local i - for (( i = 0; i < line_count; i++ )); do - local line_def - line_def="$(jq -c ".[$i]" <<< "$layout_json")" - local rendered - rendered="$(render_line "$line_def")" - if [[ -n "$rendered" ]]; then - lines+=("$rendered") - fi - done - - # Output lines separated by newlines - local first=1 - for line in "${lines[@]}"; do - if (( first )); then - first=0 - else - printf '\n' - fi - printf '%b' "$line" - done -} - -main