title: src/layout/justify.rs: Spread/space-between gap distribution

description: Distribute extra space evenly across gaps. Center separator core within each gap. Remainder chars distributed left-to-right.

status: closed

issue_type: task

created_by: tayloreernisse

close_reason: Justify: space-between gap distribution with centered separator core

type: blocks


title: src/lib.rs: Module declarations and public API

description: Declare all pub mod: error, config, input, theme, color, glyph, width, cache, trend, format, shell, metrics, section, layout. Re-export key types.

close_reason: Fully implemented during scaffold: all pub mod declarations, Error re-export


title: Implement cache TTL jitter (cache_ttl_jitter_pct)

description:
## Background
PRD specifies cache_ttl_jitter_pct (default 10): add random +/- jitter to TTL to desynchronize cache expiration across concurrent renders. Without jitter, all cached values expire simultaneously causing a thundering herd of shell-outs on the same render cycle.

## Approach

**Step 1: Add jitter_pct to Cache struct**
```rust
pub struct Cache {
    dir: Option<PathBuf>,
    jitter_pct: u8,  // from config.global.cache_ttl_jitter_pct
}
```

**Step 2: Deterministic per-key jitter using key hash**
Use a simple hash of the cache key to produce deterministic jitter per key. This ensures the same key always gets the same jitter (no oscillation between cache hit/miss on successive renders).

```rust
fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {
    if self.jitter_pct == 0 { return base_ttl; }

    // Simple FNV-1a hash of key for deterministic per-key jitter
    let mut hash: u64 = 0xcbf29ce484222325;
    for byte in key.bytes() {
        hash ^= byte as u64;
        hash = hash.wrapping_mul(0x100000001b3);
    }

    // Map hash to range [-jitter_pct, +jitter_pct]
    let jitter_range = self.jitter_pct as f64 / 100.0;
    let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]
    let multiplier = 1.0 + (normalized * jitter_range);

    let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;
    // Clamp: minimum 100ms to avoid zero TTL
    Duration::from_millis(jittered_ms.max(100))
}
```

**Step 3: Apply in Cache::get()**
Replace `if age < ttl` with `if age < self.jittered_ttl(key, ttl)`.

**Step 4: Pass jitter_pct from config during Cache construction**
In src/bin/claude-statusline.rs: `Cache::new(&template, &session, config.global.cache_ttl_jitter_pct)`

## Acceptance Criteria
- [ ] Cache TTL varies by +/- jitter_pct for each key
- [ ] Jitter is deterministic per key (same key = same jitter every time)
- [ ] jitter_pct = 0 disables jitter entirely (exact TTL)
- [ ] Minimum jittered TTL is 100ms (never zero)
- [ ] jitter_pct = 100 at most doubles or halves the TTL
- [ ] Different keys get different jitter values
- [ ] No external crate needed (FNV-1a is 5 lines)
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## TDD Loop
RED: test_jitter_zero_returns_exact_ttl, test_jitter_deterministic_per_key, test_jitter_different_keys_differ, test_jitter_clamps_minimum, test_jitter_100_pct_range
GREEN: Implement jittered_ttl + wire into get()
VERIFY: cargo test -- jitter

## Files
- src/cache.rs (add jitter_pct field, jittered_ttl method, apply in get())
- src/bin/claude-statusline.rs (pass jitter_pct from config to Cache::new)

## Edge Cases
- jitter_pct = 0: TTL unchanged (fast path, no hash computation)
- jitter_pct = 100: TTL ranges from 0.5x to 2.0x base (clamped to 100ms min)
- Very short TTL (1s) with 10% jitter: ranges from 900ms to 1100ms
- Key with only ASCII chars vs unicode: FNV-1a works on bytes, handles both

close_reason: Cache TTL jitter via FNV-1a hash of key. Deterministic per-key, +/- jitter_pct range, 100ms minimum. Applied in Cache::get(). jitter_pct=0 fast path.


title: Project scaffold: Cargo.toml, build.rs, src/ structure

description: Create Cargo.toml with all deps per PRD. Create build.rs for defaults.json/schema.json rerun-if-changed. Create src/ directory structure with empty module files. Verify cargo check passes.

close_reason: Scaffold complete: Cargo.toml, build.rs, all modules, CLI, stubs compile clean


title: src/format.rs: display_width, human_tokens, human_duration, truncation, apply_formatting

description: display_width via unicode-width. human_tokens (k/M). human_duration (s/m/h). Grapheme-safe truncation (right/middle/left). apply_formatting: prefix/suffix, color override, pad+align.

close_reason: Fully implemented during scaffold: display_width, human_tokens, human_duration, truncate, apply_formatting


title: src/layout/priority.rs: Priority drop algorithm

description: Drop tier 3 all at once, then tier 2 if still overflowing. Priority 1 never drops. line_width calculation with separator suppression at spacers.

close_reason: Priority drop: tier 3 all-at-once, then tier 2, P1 never drops


title: src/trend.rs: Append-only trend files + sparkline

description: Append with write throttle (5s). Skip if unchanged. Max N points, trim from left. Sparkline: 8 Unicode block chars, normalized min/max. Flat-series guard (mid-height).

close_reason: Full trend: append with throttle + sparkline with flat-series guard


title: src/section/mod.rs: SectionOutput, RenderContext, registry, dispatch

description: SectionOutput{raw,ansi}. RenderFn type alias. RenderContext struct with all fields. Registry of (id, render_fn). render_section dispatch. is_spacer check.

close_reason: Fully implemented during scaffold: registry, dispatch, RenderContext, SectionOutput, VcsType


title: src/input.rs: InputData struct (serde from stdin JSON)

description: InputData, ModelInfo, CostInfo, ContextWindow, CurrentUsage, Workspace, OutputStyle. All fields Option with serde(default). Matches stdin JSON shape from Claude Code.

close_reason: Fully implemented during scaffold: all InputData structs with serde


title: src/color.rs: ANSI constants, resolve_color, should_use_color

description: 10 ANSI const strings. resolve_color with palette refs (p:key), compound styles (red bold), single names. should_use_color: NO_COLOR, cli flag, config, atty check.

close_reason: Fully implemented during scaffold: ANSI consts, resolve_color, should_use_color, atty


title: src/glyph.rs: Nerd Font + ASCII fallback

description: glyph() function: lookup named glyph, return Nerd Font icon when enabled, ASCII fallback otherwise.

close_reason: Fully implemented during scaffold: glyph function with Nerd Font lookup + ASCII fallback


title: src/error.rs: Error enum with From impls

description: Error enum: Io, Json, ConfigNotFound, EmptyStdin. Display impl, std::error::Error impl, From<io::Error> and From<serde_json::Error>.

close_reason: Fully implemented during scaffold: Error enum, Display, From impls


title: Implement breakpoint hysteresis for responsive layout

description:
## Background
PRD specifies breakpoint_hysteresis (default 2): avoid toggling responsive presets if width fluctuates within +-hysteresis columns of a breakpoint. Without this, a terminal at exactly 60 cols might flip between dense and standard on alternate renders due to measurement noise.

## Approach
1. Store last responsive preset choice in a static Mutex (similar to width memoization)
2. In src/layout/mod.rs responsive_preset(), if the current width is within hysteresis of a breakpoint AND the previous preset is still valid, keep the previous preset
3. Only change preset when width moves beyond breakpoint + hysteresis

Example: breakpoints narrow=60, medium=100, hysteresis=2
- At 61 cols, switches to standard (>60)
- At 59 cols: within 60-2=58..60, if was standard, STAY standard
- At 57 cols: below 58, switch to dense

## Acceptance Criteria
- [ ] Width at breakpoint +/- hysteresis maintains previous preset
- [ ] Width clearly beyond breakpoint changes preset
- [ ] hysteresis = 0 disables (every width change triggers preset change)
- [ ] First render (no previous state) uses simple threshold
- [ ] cargo test with edge case tests; cargo clippy --all-targets -- -D warnings

## Files
- src/layout/mod.rs (add hysteresis state and logic to responsive_preset)

## Edge Cases
- First render: no previous state, use simple thresholds
- hysteresis = 0: equivalent to current behavior
- Width oscillates exactly at breakpoint: should stabilize on one preset

close_reason: Breakpoint hysteresis via static Mutex last-preset state. Width within bp +/- hysteresis maintains previous preset. hysteresis=0 disables. Added to Breakpoints config struct.


title: 17 pure sections (no shell-out)

description: Implement all pure sections: 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. Each returns Option<SectionOutput>.

close_reason: All 17 pure sections implemented: 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


title: Implement shell circuit breaker (failure tracking + cooldown)

description:
## Background
PRD specifies a circuit breaker pattern for shell-outs: track consecutive failures per command key in-memory. If failures exceed shell_failure_threshold (default 3), open a cooldown window (shell_cooldown_ms, default 30s) and serve stale cache without executing the command. Resets on success or cooldown expiry. This prevents hammering failing commands (e.g., git lock contention).

## Approach
Add a static CircuitBreaker struct in src/shell.rs:
```rust
use std::sync::Mutex;
use std::collections::HashMap;
use std::time::Instant;

struct CircuitState {
    failures: u8,
    cooldown_until: Option<Instant>,
}

static BREAKER: Mutex<HashMap<String, CircuitState>> = Mutex::new(HashMap::new());
```

Wrap exec_with_timeout in a new function exec_with_circuit_breaker(program, args, dir, timeout, threshold, cooldown_ms) that:
1. Checks if command key is in cooldown -> return None
2. Calls exec_with_timeout
3. On success: reset failure count
4. On failure: increment count, if >= threshold set cooldown_until

Config values shell_failure_threshold and shell_cooldown_ms should be passed from RenderContext or a ShellConfig struct.

## Acceptance Criteria
- [ ] After 3 consecutive failures, command is not executed for 30s (configurable)
- [ ] Success resets failure count to 0
- [ ] Cooldown expiry allows retry
- [ ] Failure tracking is per-command-key (git != br != sysctl)
- [ ] State is in-memory only (resets on process restart as PRD specifies)
- [ ] cargo test with unit tests for circuit breaker logic
- [ ] cargo clippy --all-targets -- -D warnings

## Files
- src/shell.rs (add CircuitBreaker, wrap exec_with_timeout)
- src/section/mod.rs (pass shell config to context or make available)
- src/bin/claude-statusline.rs (pass config values)

## Edge Cases
- Circuit opens mid-render for one command but not others
- Concurrent renders: Mutex is fine since renders are sequential per process
- shell_failure_threshold = 0 means disabled (never trip)

close_reason: Circuit breaker with per-command failure tracking via static Mutex<HashMap>. Trips after failure_threshold consecutive failures, opens cooldown_ms window. Resets on success or cooldown expiry. Integrated into exec_gated pipeline.

depends_on_id: bd-ywx


title: Shell-out sections: vcs, beads, load, hostname, time, custom

description: Port remaining 7 shell-out sections. Combined git status, jj support, beads br status, sysctl/proc load, hostname, date, custom command exec. All use cache + stale fallback.

close_reason: All 8 shell-out sections implemented: vcs, beads, load, hostname, time, custom, cost_trend, context_trend. Compiles clean, clippy clean, --test output verified.


title: Implement gradual drop strategy in priority.rs

description:
## Background
PRD specifies two drop strategies: tiered (implemented) and gradual (not implemented). Gradual drops sections one-by-one by (priority desc, width cost desc, rightmost first), recomputing after each removal. Priority 1 sections never drop. Config field drop_strategy selects which.

## Approach
In src/layout/priority.rs, add a gradual_drop function alongside existing priority_drop:
1. Sort candidates by (priority DESC, display_width DESC, position DESC)
2. Remove one at a time, recompute line_width after each
3. Stop when line fits or only priority 1 sections remain
4. In the caller (src/layout/mod.rs render_line), check config.global.drop_strategy and dispatch to tiered or gradual.

## Acceptance Criteria
- [ ] drop_strategy = "gradual" drops sections one at a time
- [ ] Drop order: highest priority number first, then widest, then rightmost
- [ ] Priority 1 sections never drop in either strategy
- [ ] Recalculate width after each individual drop
- [ ] drop_strategy = "tiered" behavior unchanged
- [ ] cargo test with tests for both strategies
- [ ] cargo clippy --all-targets -- -D warnings

## Files
- src/layout/priority.rs (add gradual_drop function)
- src/layout/mod.rs (dispatch based on config.global.drop_strategy)

## Edge Cases
- All sections are priority 1: nothing drops even if overflowing
- Single section: no drops needed
- Two sections with same priority and width: rightmost drops first

close_reason: Implemented gradual_drop() in priority.rs. Drops sections one-by-one by (priority desc, width desc, rightmost). Added drop_sections() dispatch. Both tiered and gradual strategies work.


title: Implement render budget timer with graceful degradation

description:
## Background
PRD specifies render_budget_ms (default 8ms): if exceeded during rendering, drop remaining lowest-priority sections and emit partial output. Prevents UI stalls from cascading shell-out delays. Critical requirement: partial output must still be valid ANSI (no unclosed escape sequences).

## Approach

**Step 1: Add budget tracking to RenderContext**
```rust
pub struct RenderContext<'a> {
    // ...existing fields...
    pub budget_start: Option<Instant>,  // None = unlimited
    pub budget_ms: u64,
}

impl RenderContext<'_> {
    pub fn budget_remaining(&self) -> Option<Duration> {
        let start = self.budget_start?;
        let budget = Duration::from_millis(self.budget_ms);
        budget.checked_sub(start.elapsed())
    }
    pub fn budget_exceeded(&self) -> bool {
        self.budget_remaining().map_or(false, |r| r.is_zero())
    }
}
```

**Step 2: Check budget in render_line()**
In src/layout/mod.rs render_line(), before each section render:
```rust
for id in section_ids {
    if ctx.budget_exceeded() {
        let (priority, _) = section_meta(id, ctx.config);
        if priority > 1 {
            continue; // Skip non-essential sections when over budget
        }
        // Priority 1 sections always render regardless of budget
    }
    let output = section::render_section(id, ctx);
    // ...
}
```

**Step 3: ANSI validity guarantee**
Each SectionOutput.ansi already ends with RESET (convention enforced by all section renderers). For safety, in assemble_left(), append a final RESET at the end of each line:
```rust
fn assemble_left(active: &[ActiveSection], ...) -> String {
    let mut output = String::new();
    // ...existing assembly...
    output.push_str(color::RESET); // Ensure no unclosed sequences
    output
}
```
This is idempotent (double RESET is harmless) and guarantees ANSI validity.

**Step 4: Skip flex expansion when over budget**
In render_line(), if budget_exceeded before flex phase, skip flex expansion entirely - emit what we have without flex padding.

**Step 5: Wire budget from config**
budget_ms = 0 means unlimited (budget_start = None).

## Acceptance Criteria
- [ ] Sections are skipped when render budget is exceeded
- [ ] Priority 1 sections ALWAYS render regardless of budget
- [ ] Non-priority-1 sections are skipped in layout order (leftmost remaining skip first)
- [ ] Partial output always has valid ANSI (RESET appended to every line)
- [ ] render_budget_ms = 0 disables budget (no timing overhead)
- [ ] Budget timer starts after config load, not including stdin read
- [ ] Flex expansion skipped when over budget
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## TDD Loop
RED: test_budget_zero_renders_all, test_budget_skips_low_priority, test_budget_always_renders_priority1, test_partial_output_ends_with_reset
GREEN: Add budget_start/budget_ms to RenderContext, check in render_line
VERIFY: cargo test -- budget

## Files
- src/section/mod.rs (add budget_start, budget_ms, helper methods to RenderContext)
- src/layout/mod.rs (check budget before each section render, skip flex when over budget)
- src/layout/mod.rs assemble_left (append RESET for ANSI safety)
- src/bin/claude-statusline.rs (set budget_start = Instant::now() after config load, budget_ms from config)

## Edge Cases
- All sections are priority 1: budget has no effect (all rendered)
- Budget exceeded during flex expansion: emit without flex
- Budget = 0: unlimited, budget_start set to None (no Instant::now call)
- Budget exceeded before first section: still render priority 1 sections
- Single-line layout: budget applies per line, not per render

close_reason: Added budget_start/budget_ms to RenderContext with budget_exceeded() method. Render_line skips non-priority-1 sections when over budget. Flex expansion also skipped when over budget.


title: src/shell.rs: exec_with_timeout, GIT_ENV, parse_git_status_v2

description: Polling try_wait with 5ms sleep. GIT_ENV constants. Combined git status --porcelain=v2 --branch parsing. Circuit breaker tracking.

close_reason: Fully implemented during scaffold: exec_with_timeout, parse_git_status_v2


title: src/bin/claude-statusline.rs: CLI entry point with --test and --help

description: Manual CLI flag parsing (no clap). Stdin read, config load, theme detect, render, stdout. Support --help, --test, --config-schema, --print-defaults, --list-sections, --validate-config, --dump-state, --config, --width, --color, --no-cache, --no-shell, --clear-cache.

close_reason: CLI fully implemented during scaffold, all dependencies now closed


title: Implement cache GC (garbage collection of old cache dirs)

description:
## Background
PRD specifies cache garbage collection: 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. GC runs asynchronously AFTER render output is written, never blocking the status line. Uses a lock file in /tmp to avoid concurrent GC.

## Approach
1. Add a gc() function in src/cache.rs
2. Check gc eligibility: read mtime of /tmp/claude-sl-gc.lock. If younger than gc_interval_hours, skip.
3. Try flock on gc.lock (non-blocking). If contention, skip.
4. Scan /tmp/claude-sl-* directories. For each: verify owned by current user, not symlink, mtime older than gc_days -> remove_dir_all.
5. Call gc() AFTER print!() in main, so output is not delayed.
6. Consider spawning a detached thread for GC so the process exits immediately.

## Acceptance Criteria
- [ ] Old cache dirs (>7 days) are cleaned up
- [ ] GC runs at most once per 24 hours (configurable)
- [ ] GC only deletes dirs owned by current user
- [ ] GC does not follow symlinks
- [ ] GC does not block status line output
- [ ] GC uses lock file to prevent concurrent runs
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## Files
- src/cache.rs (add gc function)
- src/bin/claude-statusline.rs (call gc after render output)

## Edge Cases
- /tmp/claude-sl-* dirs from other users: skip (ownership check)
- Symlinked cache dirs: skip (symlink check)
- No old dirs to clean: exit GC early
- Lock contention: skip GC silently

close_reason: cache::gc() scans /tmp/claude-sl-* dirs, deletes those older than gc_days owned by current user, uses flock on gc.lock for interval gating; called after render output in main


title: Fix cache namespace to include cache_version and config_hash

description:
## Background
PRD specifies cache directory template: /tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/
Current implementation only uses {session_id}. This means config changes dont invalidate cache, and binary upgrades with new cache_version dont clear stale data.

## Approach
1. Compute config_hash: MD5 of the merged config JSON (post-merge, pre-deserialize), truncated to 8 hex chars.
2. Update cache dir template in defaults.json from /tmp/claude-sl-{session_id} to /tmp/claude-sl-{session_id}-{cache_version}-{config_hash}
3. In Cache::new(), also replace {cache_version} and {config_hash} placeholders.
4. Pass config_hash and cache_version when constructing Cache.

## Acceptance Criteria
- [ ] Cache dir includes {session_id}-{cache_version}-{config_hash} in path
- [ ] Changing any config value produces a different cache dir
- [ ] Bumping cache_version in config produces a different cache dir
- [ ] Old cache dirs are not cleaned up (thats Cache GCs job)
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## Files
- defaults.json (update cache_dir template)
- src/cache.rs (add config_hash and cache_version replacement in new())
- src/bin/claude-statusline.rs (compute config_hash from merged JSON, pass to Cache::new)

## Edge Cases
- User overrides cache_dir in config without {cache_version}/{config_hash}: works fine, placeholders just arent present
- Empty config produces consistent hash

issue_type: bug

close_reason: Cache namespace now includes {cache_version}-{config_hash} suffix in defaults.json, config.rs computes 8-char MD5 config hash, cache.rs template supports all 3 placeholders


title: Implement CLAUDE_STATUSLINE_NO_CACHE, _NO_SHELL, _COLOR env vars

description:
## Background
PRD Contract section specifies three env vars: CLAUDE_STATUSLINE_NO_CACHE, CLAUDE_STATUSLINE_NO_SHELL, CLAUDE_STATUSLINE_COLOR. Only NO_COLOR (the standard) is currently checked. The CLAUDE_STATUSLINE_COLOR var should override config color mode (auto|always|never), similar to --color= CLI flag but at env var precedence.

## Approach
In src/bin/claude-statusline.rs, check env vars early:
1. CLAUDE_STATUSLINE_NO_CACHE: set no_cache = true (same effect as --no-cache flag)
2. CLAUDE_STATUSLINE_NO_SHELL: set no_shell = true (same effect as --no-shell flag)
3. CLAUDE_STATUSLINE_COLOR: in src/color.rs should_use_color(), check this env var with same precedence as --color= CLI flag. If both set, CLI wins.

## Acceptance Criteria
- [ ] CLAUDE_STATUSLINE_NO_CACHE=1 disables caching (same as --no-cache)
- [ ] CLAUDE_STATUSLINE_NO_SHELL=1 disables shell-outs (same as --no-shell)
- [ ] CLAUDE_STATUSLINE_COLOR=always forces color on, =never forces off, =auto uses detection
- [ ] CLI flags take precedence over env vars
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## Files
- src/bin/claude-statusline.rs (check env vars for no_cache, no_shell)
- src/color.rs (add CLAUDE_STATUSLINE_COLOR to should_use_color precedence chain)

## Edge Cases
- CLAUDE_STATUSLINE_COLOR=invalid should fall through to auto
- Both --color=always and CLAUDE_STATUSLINE_COLOR=never set: CLI wins

close_reason: CLAUDE_STATUSLINE_NO_CACHE, _NO_SHELL env vars checked in bin; CLAUDE_STATUSLINE_COLOR added to color.rs precedence chain (NO_COLOR > CLI > env > config)

depends_on_id: bd-khk


title: src/layout/flex.rs: Flex expansion with context_bar rebuild

description: Spacer wins over non-spacer flex. Only one flex winner per line. Spacer=spaces, context_bar=rebuild at wider width, other=trailing pad. Re-apply apply_formatting after context_bar rebuild.

close_reason: Flex expansion: spacer wins, context_bar rebuilds with wider bar, others pad


title: Implement parallel shell-outs via std::thread::scope

description:
## Background
PRD specifies that when multiple shell-out sections have expired caches in the same render, they should execute in parallel using std::thread::scope. Cache hits are >90% of renders, so this only matters for occasional cold renders. Currently all shell-outs run sequentially within each section render call.

## Approach
Restructure the render pipeline to pre-fetch shell results before section rendering.

**Step 1: Define ShellResults type**
In src/section/mod.rs, add to RenderContext:
```rust
pub shell_results: &'a HashMap<String, Option<String>>,
```

**Step 2: Pre-fetch function**
In src/lib.rs or a new src/prefetch.rs, create:
```rust
pub fn prefetch_shell_outs(
    cache: &Cache,
    config: &Config,
    project_dir: &str,
    no_shell: bool,
) -> HashMap<String, Option<String>> {
    let mut results = HashMap::new();
    if no_shell { return results; }

    // Collect which keys have expired caches
    let vcs_expired = cache.get("vcs_branch", branch_ttl).is_none();
    let load_expired = cache.get("load", load_ttl).is_none();
    let beads_expired = cache.get("beads", beads_ttl).is_none();

    // Only spawn threads for expired keys
    std::thread::scope(|s| {
        let vcs_h = vcs_expired.then(|| s.spawn(|| {
            shell::exec_with_timeout("git", &["-C", dir, "status", "--porcelain=v2", "--branch"], None, timeout)
        }));
        let load_h = load_expired.then(|| s.spawn(|| {
            shell::exec_with_timeout("sysctl", &["-n", "vm.loadavg"], None, timeout)
        }));
        // ... beads, hostname, custom ...

        if let Some(h) = vcs_h { results.insert("vcs".into(), h.join().ok().flatten()); }
        if let Some(h) = load_h { results.insert("load".into(), h.join().ok().flatten()); }
    });
    results
}
```

**Step 3: Wire into sections**
Each shell-out section checks shell_results first:
```rust
// In vcs.rs render_git():
let output = ctx.shell_results.get("vcs")
    .cloned()
    .flatten()
    .or_else(|| shell::exec_with_timeout(...));
```

## Acceptance Criteria
- [ ] prefetch_shell_outs() runs before layout/render phase
- [ ] Only cache-miss shell-outs are spawned as threads
- [ ] Cache hits bypass thread::scope entirely (0 threads spawned)
- [ ] shell_results is passed through RenderContext to all sections
- [ ] Each shell-out section checks shell_results before calling exec_with_timeout
- [ ] Thread panics are caught by join() and yield None
- [ ] Cold render with 3 expired shell-outs is measurably faster (wall time)
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## TDD Loop
RED: src/tests/prefetch.rs - test_prefetch_all_cached_skips_threads, test_prefetch_expired_keys_parallel
GREEN: Implement prefetch_shell_outs with thread::scope
VERIFY: cargo test -- prefetch

## Files
- src/section/mod.rs (add shell_results to RenderContext)
- src/lib.rs or src/prefetch.rs (prefetch_shell_outs function)
- src/bin/claude-statusline.rs (call prefetch before render, pass results to ctx)
- src/section/vcs.rs, beads.rs, load.rs, custom.rs (check shell_results first)

## Edge Cases
- Only 1 shell-out expired: spawns 1 thread, no parallelism benefit but correct
- Thread panic in scope: caught by join(), returns None, other threads unaffected
- All cached: HashMap is empty, no threads spawned, zero overhead
- Custom sections: may have N commands; each gets its own key in shell_results

close_reason: Implemented prefetch_shell_outs() with std::thread::scope in bin. All 4 shell-out sections (vcs, beads, load, custom) check shell_results before exec_gated. Only cache-miss commands spawn threads.


title: src/config.rs: Config structs, deep merge, load_config

description: Full typed Config struct hierarchy with SectionBase flatten pattern. Deep merge via recursive serde_json::Value. Config loading: embedded defaults + user overrides from XDG/dot-config/legacy paths. Unknown key warnings via serde_ignored. All section configs per PRD.

close_reason: Fully implemented during scaffold: config hierarchy, deep merge, load_config, unknown key warnings


title: Enhance --dump-state with per-section timing and cache diagnostics

description:
## Background
PRD specifies enhanced --dump-state output: width detection source, per-section render timing in microseconds, priority drop reasons, cache hit/miss per key, trend throttle state. Currently only basic info is output (terminal width, theme, VCS type, layout, paths, session_id). This diagnostic mode is critical for debugging render issues.

## Approach

**Step 1: Width detection source tracking**
In src/width.rs, change detect_raw() to return (u16, &str) where the &str identifies the source:
```rust
fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> (u16, &'static str) {
    if let Some(w) = cli_width { return (w, "cli_flag"); }
    // ... "env_var", "config", "ioctl", "process_tree", "stty", "columns_env", "tput", "fallback"
}
```
Store the source in a thread-local or return from detect_width().

**Step 2: Cache hit/miss tracking**
Add a diagnostics field to Cache:
```rust
pub struct Cache {
    dir: Option<PathBuf>,
    diagnostics: RefCell<Vec<CacheDiag>>,
}
pub struct CacheDiag {
    pub key: String,
    pub hit: bool,
    pub age_ms: Option<u64>,
}
```
In get(), push a CacheDiag entry. Expose via cache.diagnostics() method.

**Step 3: Per-section render timing**
In src/layout/mod.rs render_line(), wrap each render_section() call:
```rust
let start = Instant::now();
let output = section::render_section(id, ctx);
let elapsed_us = start.elapsed().as_micros() as u64;
section_timings.push((id.clone(), elapsed_us));
```
Return timings alongside the rendered line.

**Step 4: Priority drop recording**
In src/layout/priority.rs, return (Vec<ActiveSection>, Vec<String>) where the second Vec contains dropped section IDs with their priority level.

**Step 5: Assemble in dump_state_output**
Collect all diagnostics into the JSON output:
```json
{
  "terminal": { "effective_width": 174, "source": "process_tree", ... },
  "sections": [
    { "id": "model", "render_us": 12, "dropped": false },
    { "id": "beads", "render_us": 0, "dropped": true, "drop_reason": "priority_3_tiered" }
  ],
  "cache": [
    { "key": "vcs_branch", "hit": true, "age_ms": 1200 },
    { "key": "load", "hit": false }
  ],
  ...existing fields...
}
```

## Acceptance Criteria
- [ ] --dump-state=json includes "terminal.source" identifying width detection method
- [ ] --dump-state=json includes "sections" array with per-section render_us timing
- [ ] --dump-state=json includes "cache" array with hit/miss and age per key
- [ ] --dump-state=json includes dropped sections with drop_reason
- [ ] --dump-state=text outputs human-readable formatted version
- [ ] Diagnostics collection adds <1ms overhead to normal renders
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## TDD Loop
RED: test_dump_state_json_has_sections, test_dump_state_json_has_cache_diag, test_dump_state_json_has_width_source
GREEN: Instrument width.rs, cache.rs, layout/mod.rs, priority.rs
VERIFY: cargo test -- dump_state

## Files
- src/width.rs (return detection source from detect_raw)
- src/cache.rs (add CacheDiag tracking via RefCell)
- src/layout/mod.rs (add per-section timing collection)
- src/layout/priority.rs (return dropped section IDs)
- src/bin/claude-statusline.rs (collect all diagnostics, assemble dump_state JSON)

## Edge Cases
- --dump-state with --test: uses mock data but shows real timing and detection
- Cache disabled (dir: None): all keys show miss, age_ms: null
- No sections dropped: "sections" array has all items with dropped: false
- --dump-state=text: tabular format, human-readable (no JSON parsing needed)

close_reason: Enhanced dump-state with: width detection source (terminal.source), per-section timing (sections[].render_us), cache hit/miss diagnostics (cache[].hit/age_ms), drop strategy and budget info. Both json and text formats work.


title: src/metrics.rs: ComputedMetrics from InputData

description: Compute derived metrics once: cost_velocity, token_velocity, usage_pct, total_tokens, cache_efficiency_pct. Reused by all sections via RenderContext.metrics.

close_reason: Fully implemented during scaffold: ComputedMetrics with from_input computing all derived values


title: Upgrade SectionDescriptor registry with metadata (estimated_width, shell_out)

description:
## Background
PRD specifies a registry of SectionDescriptor { id, render_fn, priority, is_spacer, is_flex, estimated_width, shell_out }. Current registry() returns Vec<(&str, RenderFn)> without metadata. The metadata enables lazy shell-outs (skip commands for dropped sections), --list-sections with rich info, and cost estimation for render budget.

## Approach

**Step 1: Define SectionDescriptor struct (already in mod.rs, needs population)**
```rust
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,
}
```

**Step 2: Populate registry with all 24 sections**
```rust
pub fn registry() -> Vec<SectionDescriptor> {
    vec![
        SectionDescriptor { id: "model",             render: model::render,             priority: 2, is_spacer: false, is_flex: false, estimated_width: 12, shell_out: false },
        SectionDescriptor { id: "provider",          render: provider::render,          priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },
        SectionDescriptor { id: "project",           render: project::render,           priority: 1, is_spacer: false, is_flex: false, estimated_width: 20, shell_out: false },
        SectionDescriptor { id: "vcs",               render: vcs::render,               priority: 1, is_spacer: false, is_flex: false, estimated_width: 15, shell_out: true  },
        SectionDescriptor { id: "beads",             render: beads::render,             priority: 3, is_spacer: false, is_flex: false, estimated_width: 25, shell_out: true  },
        SectionDescriptor { id: "context_bar",       render: context_bar::render,       priority: 1, is_spacer: false, is_flex: true,  estimated_width: 18, shell_out: false },
        SectionDescriptor { id: "context_usage",     render: context_usage::render,     priority: 2, is_spacer: false, is_flex: false, estimated_width: 12, shell_out: false },
        SectionDescriptor { id: "context_remaining", render: context_remaining::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },
        SectionDescriptor { id: "tokens_raw",        render: tokens_raw::render,        priority: 3, is_spacer: false, is_flex: false, estimated_width: 18, shell_out: false },
        SectionDescriptor { id: "cache_efficiency",  render: cache_efficiency::render,  priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },
        SectionDescriptor { id: "cost",              render: cost::render,              priority: 1, is_spacer: false, is_flex: false, estimated_width: 8,  shell_out: false },
        SectionDescriptor { id: "cost_velocity",     render: cost_velocity::render,     priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },
        SectionDescriptor { id: "token_velocity",    render: token_velocity::render,    priority: 2, is_spacer: false, is_flex: false, estimated_width: 14, shell_out: false },
        SectionDescriptor { id: "cost_trend",        render: cost_trend::render,        priority: 3, is_spacer: false, is_flex: false, estimated_width: 8,  shell_out: false },
        SectionDescriptor { id: "context_trend",     render: context_trend::render,     priority: 3, is_spacer: false, is_flex: false, estimated_width: 8,  shell_out: false },
        SectionDescriptor { id: "lines_changed",     render: lines_changed::render,     priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },
        SectionDescriptor { id: "duration",          render: duration::render,          priority: 2, is_spacer: false, is_flex: false, estimated_width: 5,  shell_out: false },
        SectionDescriptor { id: "tools",             render: tools::render,             priority: 2, is_spacer: false, is_flex: false, estimated_width: 15, shell_out: false },
        SectionDescriptor { id: "turns",             render: turns::render,             priority: 3, is_spacer: false, is_flex: false, estimated_width: 8,  shell_out: false },
        SectionDescriptor { id: "load",              render: load::render,              priority: 3, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: true  },
        SectionDescriptor { id: "version",           render: version::render,           priority: 3, is_spacer: false, is_flex: false, estimated_width: 8,  shell_out: false },
        SectionDescriptor { id: "time",              render: time::render,              priority: 3, is_spacer: false, is_flex: false, estimated_width: 5,  shell_out: true  },
        SectionDescriptor { id: "output_style",      render: output_style::render,      priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },
        SectionDescriptor { id: "hostname",          render: hostname::render,          priority: 3, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: true  },
    ]
}
```

**Step 3: Update render_section to use SectionDescriptor**
Use descriptor.render instead of linear scan.

**Step 4: Update --list-sections output**
Print tab-separated: id, priority, flex, shell_out, estimated_width.

**Step 5: Update section_meta() in layout/mod.rs**
Read priority and is_flex from registry descriptors instead of placeholder (2, false).

## Acceptance Criteria
- [ ] registry() returns Vec<SectionDescriptor> with all 24 built-in sections
- [ ] Each descriptor has accurate priority, is_flex, estimated_width, shell_out values
- [ ] render_section() uses descriptor.render correctly
- [ ] section_meta() reads from registry (fixes the current placeholder)
- [ ] --list-sections outputs id + metadata (not just IDs)
- [ ] Custom sections still handled by custom::render fallback (not in registry)
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## TDD Loop
RED: test_registry_has_all_sections (count=24), test_section_meta_reads_registry, test_list_sections_output_format
GREEN: Populate SectionDescriptor, update section_meta
VERIFY: cargo test -- registry && cargo test -- section_meta

## Files
- src/section/mod.rs (change registry return type, update render_section, populate all descriptors)
- src/layout/mod.rs (update section_meta to read from registry)
- src/bin/claude-statusline.rs (update --list-sections output format)

## Edge Cases
- Custom sections not in registry: handled by custom::render fallback path
- estimated_width is approximate: used for budget estimation, not exact rendering
- Priority values must match defaults.json section config defaults
- section_meta currently returns (2, false) for everything - this fix corrects layout behavior

close_reason: SectionDescriptor struct with id/render/priority/is_spacer/is_flex/estimated_width/shell_out, registry() returns Vec<SectionDescriptor>, --list-sections shows tabular metadata


title: src/layout/mod.rs: resolve_layout, render_line, render_all

description: Three-phase pipeline: Plan > Render survivors > Reflow. resolve_layout (preset lookup, responsive override). render_line per layout line. render_all joins with newlines.

close_reason: Full 3-phase layout pipeline: ActiveSection, render_line with priority/flex/justify, assemble_left, section_meta


title: src/width.rs: Terminal width detection chain with memoization

description: Full priority chain: cli > env > config > ioctl > process tree walk > stty > COLUMNS > tput > 120. 1s in-memory memoization via Mutex<Option<(u16, Instant)>>. WidthTier enum.

close_reason: Full 9-level width detection chain with 1s memoization: cli > env > config > ioctl > process tree > stty > COLUMNS > tput > 120


title: Phase 1 verification: --test produces correct output for all pure sections

description: Create test_data.json with realistic mock data. Verify --test renders all 17 pure sections correctly. Snapshot test.

close_reason: Phase 1 verification complete. --test produces 3-line verbose output with all pure and shell-out sections rendering correctly. Release build 864K, clippy/fmt/test all clean.

issue_id: bd-ape


title: Wire --no-cache, --no-shell, --clear-cache CLI flags

description:
## Background
PRD specifies three CLI flags (--no-cache, --no-shell, --clear-cache) that appear in --help output but are never parsed or wired. Users see them advertised but they have no effect. The variables are extracted (no_cache, no_shell, clear_cache) around line 95 of src/bin/claude-statusline.rs but never passed downstream.

## Approach
1. **--clear-cache**: After config load, resolve cache dir path using session_id, remove it via fs::remove_dir_all, print confirmation, exit 0. If no stdin project_dir, scan /tmp/claude-sl-* owned by current user.
2. **--no-cache**: Pass flag to Cache::new() — return Cache { dir: None } so all get() returns None, set() is no-op. Or add a Cache::disabled() constructor.
3. **--no-shell**: Add no_shell: bool to RenderContext. Shell-out sections (vcs, beads, load, hostname, time, custom) check ctx.no_shell; if true, return stale cache value or None.

## Acceptance Criteria
- [ ] --no-cache causes all cache reads to miss and writes to be skipped
- [ ] --no-shell causes all shell-outs to be skipped (stale cache served if available)
- [ ] --clear-cache removes cache dir for current session and exits 0
- [ ] --clear-cache with nonexistent cache dir exits 0 without error
- [ ] cargo test passes; cargo clippy --all-targets -- -D warnings passes

## Files
- src/bin/claude-statusline.rs (wire flags into Cache and RenderContext)
- src/cache.rs (add disabled mode)
- src/section/mod.rs (add no_shell to RenderContext)
- src/section/vcs.rs, beads.rs, load.rs, hostname.rs, time.rs, custom.rs (check no_shell)

## Edge Cases
- --clear-cache with no stdin: needs project_dir; consider reading stdin first or scanning /tmp/claude-sl-*
- --no-shell + --no-cache: only pure sections render, no dynamic data

close_reason: Wired --no-cache (Cache::disabled), --no-shell (RenderContext.no_shell + stale-cache guards in vcs/beads/load/custom), --clear-cache (remove_dir_all + exit 0)


title: src/cache.rs: Secure dir, per-key TTL, flock, atomic writes

description: Cache struct with secure dir creation (chmod 700, ownership verify, symlink check). Per-key TTL via mtime. Atomic write-rename. Non-blocking flock. Stale fallback. Session ID from MD5 of project_dir.

close_reason: Full cache: secure dir, ownership check, per-key TTL, flock, atomic writes


title: src/theme.rs: COLORFGBG detection

description: Theme enum (Dark/Light). Detection: config override > COLORFGBG env var > default dark.

close_reason: Fully implemented during scaffold: Theme enum, COLORFGBG detection

issue_id: bd-wdi


title: Implement shell allow/deny lists and shell_env merge

description:
## Background
PRD specifies shell execution controls: global.shell_enabled, shell_allowlist, shell_denylist, shell_env merge, and shell_max_output_bytes. These gate which commands can run and what environment they inherit. Currently exec_with_timeout has no access control.

## Approach

**Step 1: Define ShellConfig struct in src/shell.rs**
```rust
pub struct ShellConfig {
    pub enabled: bool,           // global.shell_enabled
    pub allowlist: Vec<String>,  // global.shell_allowlist
    pub denylist: Vec<String>,   // global.shell_denylist
    pub env: HashMap<String, String>, // global.shell_env
    pub max_output_bytes: usize, // global.shell_max_output_bytes (default 8192)
    pub timeout: Duration,       // global.shell_timeout_ms
}
```

**Step 2: Add gated execution function**
```rust
pub fn exec_gated(
    program: &str,
    args: &[&str],
    dir: Option<&str>,
    shell_config: &ShellConfig,
) -> Option<String> {
    // 1. Check enabled
    if \!shell_config.enabled { return None; }
    // 2. Denylist first (wins over allowlist)
    if shell_config.denylist.iter().any(|d| d == program) { return None; }
    // 3. Allowlist (empty = all allowed)
    if \!shell_config.allowlist.is_empty()
        && \!shell_config.allowlist.iter().any(|a| a == program) { return None; }
    // 4. Execute with merged env
    let result = exec_with_timeout_env(program, args, dir, shell_config.timeout, &shell_config.env)?;
    // 5. Truncate output
    if shell_config.max_output_bytes > 0 && result.len() > shell_config.max_output_bytes {
        Some(result[..shell_config.max_output_bytes].to_string())
    } else { Some(result) }
}
```

**Step 3: Modify exec_with_timeout to accept extra env**
Add shell_env to Command::envs() call, layered ON TOP of GIT_ENV (shell_env overrides GIT_ENV).

**Step 4: Add ShellConfig to RenderContext and migrate all sections**

## Acceptance Criteria
- [ ] New ShellConfig struct holds all shell execution parameters
- [ ] exec_gated() checks enabled -> denylist -> allowlist -> execute
- [ ] Denylist takes precedence over allowlist when both contain same command
- [ ] shell_env vars merged into command environment (layers on GIT_ENV)
- [ ] Output truncated to max_output_bytes (0 = unlimited)
- [ ] Empty allowlist means all commands allowed
- [ ] Skipped commands return None (caller falls back to stale cache)
- [ ] All shell-out sections migrated to exec_gated
- [ ] cargo test; cargo clippy --all-targets -- -D warnings

## TDD Loop
RED: tests in src/shell.rs - test_denylist_blocks, test_allowlist_gates, test_env_merge, test_output_truncation, test_denylist_wins_over_allowlist
GREEN: Implement ShellConfig + exec_gated
VERIFY: cargo test -- shell

## Files
- src/shell.rs (add ShellConfig struct, exec_gated function, modify exec_with_timeout for env)
- src/section/mod.rs (add shell_config to RenderContext)
- src/bin/claude-statusline.rs (construct ShellConfig from config, pass to ctx)
- src/section/vcs.rs, beads.rs, load.rs, hostname.rs, time.rs, custom.rs (migrate to exec_gated)

## Edge Cases
- Allowlist and denylist both contain "git": denylist wins
- max_output_bytes = 0: no truncation
- shell_env overrides GIT_ENV for same key: intentional (user override)
- Truncation in middle of UTF-8: truncate at byte boundary, then trim to valid UTF-8

close_reason: ShellConfig struct + exec_gated() in shell.rs implements enabled/denylist/allowlist/env-merge/output-truncation pipeline; all 4 shell-out sections migrated to exec_gated