Files
claude-statusline/rust_prd.md
Taylor Eernisse b55d1aefd1 feat: complete Rust port of claude-statusline
Port the entire 2236-line bash statusline script to Rust.
Implements all 25 sections, 3-phase layout engine (render, priority
drop, flex/justify), file-based caching with flock, 9-level terminal
width detection, trend sparklines, and deep-merge JSON config.

Release binary: 864K with LTO. Render time: <1ms warm.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:21:57 -05:00

100 KiB

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 <path>, 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 <path>, --no-cache, --no-shell, --clear-cache, --width <cols>, --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

[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

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

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<io::Error> for Error {
    fn from(e: io::Error) -> Self { Self::Io(e) }
}

impl From<serde_json::Error> 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.

use serde::Deserialize;

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct InputData {
    pub model: Option<ModelInfo>,
    pub cost: Option<CostInfo>,
    pub context_window: Option<ContextWindow>,
    pub workspace: Option<Workspace>,
    pub version: Option<String>,
    pub output_style: Option<OutputStyle>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ModelInfo {
    pub id: Option<String>,
    pub display_name: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct CostInfo {
    pub total_cost_usd: Option<f64>,
    pub total_duration_ms: Option<u64>,
    pub total_lines_added: Option<u64>,
    pub total_lines_removed: Option<u64>,
    pub total_tool_uses: Option<u64>,
    pub last_tool_name: Option<String>,
    pub total_turns: Option<u64>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ContextWindow {
    pub used_percentage: Option<f64>,
    pub total_input_tokens: Option<u64>,
    pub total_output_tokens: Option<u64>,
    pub context_window_size: Option<u64>,
    pub current_usage: Option<CurrentUsage>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct CurrentUsage {
    pub cache_read_input_tokens: Option<u64>,
    pub cache_creation_input_tokens: Option<u64>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Workspace {
    pub project_dir: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct OutputStyle {
    pub name: Option<String>,
}

Complete stdin JSON shape

Claude Code pipes this every ~300ms:

{
  "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.

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<String, Vec<Vec<String>>>,
    pub layout: LayoutValue,
    pub sections: Sections,
    pub custom: Vec<CustomCommand>,
}

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<Vec<String>>),
}

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<u16>,
    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<String>,
    pub shell_denylist: Vec<String>,
    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<String, String>,
    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<String, String>,
    pub light: HashMap<String, String>,
}

// ── Glyph config ────────────────────────────────────────────────────────

#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct GlyphConfig {
    pub enabled: bool,
    pub set: HashMap<String, String>,
    pub fallback: HashMap<String, String>,
}

// ── 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<u16>,
    pub prefix: Option<String>,
    pub suffix: Option<String>,
    pub pad: Option<u16>,
    pub align: Option<String>,
    pub color: Option<String>,
}

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<String>,
    #[serde(default)]
    pub exec: Option<Vec<String>>,
    #[serde(default)]
    pub format: Option<String>,
    #[serde(default)]
    pub label: Option<String>,
    #[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<u16>,
    #[serde(default)]
    pub color: Option<CustomColor>,
    #[serde(default)]
    pub default_color: Option<String>,
    #[serde(default)]
    pub prefix: Option<String>,
    #[serde(default)]
    pub suffix: Option<String>,
    #[serde(default)]
    pub pad: Option<u16>,
    #[serde(default)]
    pub align: Option<String>,
}

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

// ── 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<String>) where the Vec contains unknown-key warnings.
pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec<String>), 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<std::path::PathBuf> {
    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<std::path::PathBuf> {
    let mut p = dirs_home()?;
    p.push(".config/claude/statusline.json");
    if p.exists() { Some(p) } else { None }
}

fn dirs_home() -> Option<std::path::PathBuf> {
    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 <path> / $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

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::<u8>() {
                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.

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

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

use std::sync::Mutex;
use std::time::{Duration, Instant};

static CACHED_WIDTH: Mutex<Option<(u16, Instant)>> = 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<u16>, config_width: Option<u16>, 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<u16>, config_width: Option<u16>) -> 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::<u16>() {
            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::<u16>() {
            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<u16> {
    #[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<u16> {
    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::<u16>() {
                        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<u16> {
    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<u16> {
    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

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

pub struct Cache {
    dir: Option<PathBuf>,
}

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<String> {
        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<String> {
        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<PathBuf> {
        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

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<String> {
    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<i64> = 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::<Vec<_>>().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::<Vec<_>>().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<i64> = 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

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

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<String> {
    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<String>,
    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<SectionOutput>) — no traits.

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

#[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<SectionOutput> {
    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

use crate::color;
use crate::section::{RenderContext, SectionOutput};

pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
    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<String> {
    // 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::<u8>(), window[2].parse::<u8>()) {
                return Some(format!("{a}.{b}"));
            }
        }
        if window[2] == family {
            if let (Ok(a), Ok(b)) = (window[0].parse::<u8>(), window[1].parse::<u8>()) {
                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

use crate::color;
use crate::section::{RenderContext, SectionOutput};
use crate::width::WidthTier;

pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
    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

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

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<SectionOutput> {
    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<SectionOutput> {
    // 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<SectionOutput> {
    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.

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<Vec<String>> {
    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<String> {
    // Phase 1: Render all sections, collect active ones
    let mut active: Vec<ActiveSection> = 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<String> = layout.iter()
        .filter_map(|line_ids| render_line(line_ids, ctx, separator))
        .collect();

    lines.join("\n")
}

src/layout/priority.rs — Priority drop

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<ActiveSection>,
    term_width: u16,
    separator: &str,
) -> Vec<ActiveSection> {
    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

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<ActiveSection>,
    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<usize> = 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

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

use claude_statusline::{config, input, theme, width, cache, color, section};
use claude_statusline::section::RenderContext;
use std::io::Read;

fn main() {
    let args: Vec<String> = 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::<u16>().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 <path>           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 <cols>            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.

// 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<Option<(u16, Instant)>> 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 <cols> 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<SectionOutput> - 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

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

{ "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)