# 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)