diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4c60727..6a5b48e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,6 @@ {"id":"bd-18h","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","priority":2,"issue_type":"task","created_at":"2026-02-06T18:11:04.287537Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:14:32.967113Z","closed_at":"2026-02-06T19:14:32.967066Z","close_reason":"Justify: space-between gap distribution with centered separator core","compaction_level":0,"original_size":0,"labels":["layout","phase-2"],"dependencies":[{"issue_id":"bd-18h","depends_on_id":"bd-7nx","type":"blocks","created_at":"2026-02-06T18:11:35.764576Z","created_by":"tayloreernisse"}]} {"id":"bd-1ac","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.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-02-06T18:10:48.931855Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:29.000390Z","closed_at":"2026-02-06T19:08:29.000345Z","close_reason":"Fully implemented during scaffold: all pub mod declarations, Error re-export","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-1ac","depends_on_id":"bd-1m2","type":"blocks","created_at":"2026-02-06T18:11:30.510508Z","created_by":"tayloreernisse"}]} -{"id":"bd-1if","title":"Implement cache TTL jitter (cache_ttl_jitter_pct)","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Add jitter_pct to Cache struct**\n```rust\npub struct Cache {\n dir: Option,\n jitter_pct: u8, // from config.global.cache_ttl_jitter_pct\n}\n```\n\n**Step 2: Deterministic per-key jitter using key hash**\nUse 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).\n\n```rust\nfn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {\n if self.jitter_pct == 0 { return base_ttl; }\n\n // Simple FNV-1a hash of key for deterministic per-key jitter\n let mut hash: u64 = 0xcbf29ce484222325;\n for byte in key.bytes() {\n hash ^= byte as u64;\n hash = hash.wrapping_mul(0x100000001b3);\n }\n\n // Map hash to range [-jitter_pct, +jitter_pct]\n let jitter_range = self.jitter_pct as f64 / 100.0;\n let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]\n let multiplier = 1.0 + (normalized * jitter_range);\n\n let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;\n // Clamp: minimum 100ms to avoid zero TTL\n Duration::from_millis(jittered_ms.max(100))\n}\n```\n\n**Step 3: Apply in Cache::get()**\nReplace `if age < ttl` with `if age < self.jittered_ttl(key, ttl)`.\n\n**Step 4: Pass jitter_pct from config during Cache construction**\nIn src/bin/claude-statusline.rs: `Cache::new(&template, &session, config.global.cache_ttl_jitter_pct)`\n\n## Acceptance Criteria\n- [ ] Cache TTL varies by +/- jitter_pct for each key\n- [ ] Jitter is deterministic per key (same key = same jitter every time)\n- [ ] jitter_pct = 0 disables jitter entirely (exact TTL)\n- [ ] Minimum jittered TTL is 100ms (never zero)\n- [ ] jitter_pct = 100 at most doubles or halves the TTL\n- [ ] Different keys get different jitter values\n- [ ] No external crate needed (FNV-1a is 5 lines)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: 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\nGREEN: Implement jittered_ttl + wire into get()\nVERIFY: cargo test -- jitter\n\n## Files\n- src/cache.rs (add jitter_pct field, jittered_ttl method, apply in get())\n- src/bin/claude-statusline.rs (pass jitter_pct from config to Cache::new)\n\n## Edge Cases\n- jitter_pct = 0: TTL unchanged (fast path, no hash computation)\n- jitter_pct = 100: TTL ranges from 0.5x to 2.0x base (clamped to 100ms min)\n- Very short TTL (1s) with 10% jitter: ranges from 900ms to 1100ms\n- Key with only ASCII chars vs unicode: FNV-1a works on bytes, handles both","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-06T20:20:01.522601Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:24:26.763244Z","compaction_level":0,"original_size":0} +{"id":"bd-1if","title":"Implement cache TTL jitter (cache_ttl_jitter_pct)","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Add jitter_pct to Cache struct**\n```rust\npub struct Cache {\n dir: Option,\n jitter_pct: u8, // from config.global.cache_ttl_jitter_pct\n}\n```\n\n**Step 2: Deterministic per-key jitter using key hash**\nUse 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).\n\n```rust\nfn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {\n if self.jitter_pct == 0 { return base_ttl; }\n\n // Simple FNV-1a hash of key for deterministic per-key jitter\n let mut hash: u64 = 0xcbf29ce484222325;\n for byte in key.bytes() {\n hash ^= byte as u64;\n hash = hash.wrapping_mul(0x100000001b3);\n }\n\n // Map hash to range [-jitter_pct, +jitter_pct]\n let jitter_range = self.jitter_pct as f64 / 100.0;\n let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]\n let multiplier = 1.0 + (normalized * jitter_range);\n\n let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;\n // Clamp: minimum 100ms to avoid zero TTL\n Duration::from_millis(jittered_ms.max(100))\n}\n```\n\n**Step 3: Apply in Cache::get()**\nReplace `if age < ttl` with `if age < self.jittered_ttl(key, ttl)`.\n\n**Step 4: Pass jitter_pct from config during Cache construction**\nIn src/bin/claude-statusline.rs: `Cache::new(&template, &session, config.global.cache_ttl_jitter_pct)`\n\n## Acceptance Criteria\n- [ ] Cache TTL varies by +/- jitter_pct for each key\n- [ ] Jitter is deterministic per key (same key = same jitter every time)\n- [ ] jitter_pct = 0 disables jitter entirely (exact TTL)\n- [ ] Minimum jittered TTL is 100ms (never zero)\n- [ ] jitter_pct = 100 at most doubles or halves the TTL\n- [ ] Different keys get different jitter values\n- [ ] No external crate needed (FNV-1a is 5 lines)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: 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\nGREEN: Implement jittered_ttl + wire into get()\nVERIFY: cargo test -- jitter\n\n## Files\n- src/cache.rs (add jitter_pct field, jittered_ttl method, apply in get())\n- src/bin/claude-statusline.rs (pass jitter_pct from config to Cache::new)\n\n## Edge Cases\n- jitter_pct = 0: TTL unchanged (fast path, no hash computation)\n- jitter_pct = 100: TTL ranges from 0.5x to 2.0x base (clamped to 100ms min)\n- Very short TTL (1s) with 10% jitter: ranges from 900ms to 1100ms\n- Key with only ASCII chars vs unicode: FNV-1a works on bytes, handles both","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-06T20:20:01.522601Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:48:31.413894Z","closed_at":"2026-02-06T20:48:31.413815Z","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.","compaction_level":0,"original_size":0} {"id":"bd-1m2","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.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-02-06T18:10:14.965071Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:07:44.162081Z","closed_at":"2026-02-06T19:07:44.162012Z","close_reason":"Scaffold complete: Cargo.toml, build.rs, all modules, CLI, stubs compile clean","compaction_level":0,"original_size":0,"labels":["phase-1","scaffold"]} {"id":"bd-1pp","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:32.654555Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.906312Z","closed_at":"2026-02-06T19:08:28.906260Z","close_reason":"Fully implemented during scaffold: display_width, human_tokens, human_duration, truncate, apply_formatting","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-1pp","depends_on_id":"bd-2ck","type":"blocks","created_at":"2026-02-06T18:11:33.078938Z","created_by":"tayloreernisse"}]} {"id":"bd-1rm","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:59.275122Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:14:32.924870Z","closed_at":"2026-02-06T19:14:32.924822Z","close_reason":"Priority drop: tier 3 all-at-once, then tier 2, P1 never drops","compaction_level":0,"original_size":0,"labels":["layout","phase-2"],"dependencies":[{"issue_id":"bd-1rm","depends_on_id":"bd-7nx","type":"blocks","created_at":"2026-02-06T18:11:35.728197Z","created_by":"tayloreernisse"}]} @@ -10,27 +10,27 @@ {"id":"bd-2ck","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:25.055555Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.861365Z","closed_at":"2026-02-06T19:08:28.861316Z","close_reason":"Fully implemented during scaffold: ANSI consts, resolve_color, should_use_color, atty","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-2ck","depends_on_id":"bd-1m2","type":"blocks","created_at":"2026-02-06T18:11:30.471417Z","created_by":"tayloreernisse"}]} {"id":"bd-2or","title":"src/glyph.rs: Nerd Font + ASCII fallback","description":"glyph() function: lookup named glyph, return Nerd Font icon when enabled, ASCII fallback otherwise.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-06T18:10:34.834222Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.928218Z","closed_at":"2026-02-06T19:08:28.928171Z","close_reason":"Fully implemented during scaffold: glyph function with Nerd Font lookup + ASCII fallback","compaction_level":0,"original_size":0,"labels":["core","phase-1"]} {"id":"bd-2ov","title":"src/error.rs: Error enum with From impls","description":"Error enum: Io, Json, ConfigNotFound, EmptyStdin. Display impl, std::error::Error impl, From and From.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:16.662422Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.790207Z","closed_at":"2026-02-06T19:08:28.790Z","close_reason":"Fully implemented during scaffold: Error enum, Display, From impls","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-2ov","depends_on_id":"bd-1m2","type":"blocks","created_at":"2026-02-06T18:11:30.411272Z","created_by":"tayloreernisse"}]} -{"id":"bd-2qr","title":"Implement breakpoint hysteresis for responsive layout","description":"## Background\nPRD 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.\n\n## Approach\n1. Store last responsive preset choice in a static Mutex (similar to width memoization)\n2. 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\n3. Only change preset when width moves beyond breakpoint + hysteresis\n\nExample: breakpoints narrow=60, medium=100, hysteresis=2\n- At 61 cols, switches to standard (>60)\n- At 59 cols: within 60-2=58..60, if was standard, STAY standard\n- At 57 cols: below 58, switch to dense\n\n## Acceptance Criteria\n- [ ] Width at breakpoint +/- hysteresis maintains previous preset\n- [ ] Width clearly beyond breakpoint changes preset\n- [ ] hysteresis = 0 disables (every width change triggers preset change)\n- [ ] First render (no previous state) uses simple threshold\n- [ ] cargo test with edge case tests; cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/layout/mod.rs (add hysteresis state and logic to responsive_preset)\n\n## Edge Cases\n- First render: no previous state, use simple thresholds\n- hysteresis = 0: equivalent to current behavior\n- Width oscillates exactly at breakpoint: should stabilize on one preset","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-06T20:19:53.754463Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:19:53.754463Z","compaction_level":0,"original_size":0} +{"id":"bd-2qr","title":"Implement breakpoint hysteresis for responsive layout","description":"## Background\nPRD 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.\n\n## Approach\n1. Store last responsive preset choice in a static Mutex (similar to width memoization)\n2. 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\n3. Only change preset when width moves beyond breakpoint + hysteresis\n\nExample: breakpoints narrow=60, medium=100, hysteresis=2\n- At 61 cols, switches to standard (>60)\n- At 59 cols: within 60-2=58..60, if was standard, STAY standard\n- At 57 cols: below 58, switch to dense\n\n## Acceptance Criteria\n- [ ] Width at breakpoint +/- hysteresis maintains previous preset\n- [ ] Width clearly beyond breakpoint changes preset\n- [ ] hysteresis = 0 disables (every width change triggers preset change)\n- [ ] First render (no previous state) uses simple threshold\n- [ ] cargo test with edge case tests; cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/layout/mod.rs (add hysteresis state and logic to responsive_preset)\n\n## Edge Cases\n- First render: no previous state, use simple thresholds\n- hysteresis = 0: equivalent to current behavior\n- Width oscillates exactly at breakpoint: should stabilize on one preset","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-06T20:19:53.754463Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:48:32.863625Z","closed_at":"2026-02-06T20:48:32.863577Z","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.","compaction_level":0,"original_size":0} {"id":"bd-2u8","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:40.742273Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:12:26.989673Z","closed_at":"2026-02-06T19:12:26.989485Z","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","compaction_level":0,"original_size":0,"labels":["phase-1","sections"],"dependencies":[{"issue_id":"bd-2u8","depends_on_id":"bd-1pp","type":"blocks","created_at":"2026-02-06T18:11:33.180547Z","created_by":"tayloreernisse"},{"issue_id":"bd-2u8","depends_on_id":"bd-1ze","type":"blocks","created_at":"2026-02-06T18:11:33.162497Z","created_by":"tayloreernisse"},{"issue_id":"bd-2u8","depends_on_id":"bd-4z1","type":"blocks","created_at":"2026-02-06T18:11:33.200843Z","created_by":"tayloreernisse"}]} -{"id":"bd-2vm","title":"Implement shell circuit breaker (failure tracking + cooldown)","description":"## Background\nPRD 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).\n\n## Approach\nAdd a static CircuitBreaker struct in src/shell.rs:\n```rust\nuse std::sync::Mutex;\nuse std::collections::HashMap;\nuse std::time::Instant;\n\nstruct CircuitState {\n failures: u8,\n cooldown_until: Option,\n}\n\nstatic BREAKER: Mutex> = Mutex::new(HashMap::new());\n```\n\nWrap exec_with_timeout in a new function exec_with_circuit_breaker(program, args, dir, timeout, threshold, cooldown_ms) that:\n1. Checks if command key is in cooldown -> return None\n2. Calls exec_with_timeout\n3. On success: reset failure count\n4. On failure: increment count, if >= threshold set cooldown_until\n\nConfig values shell_failure_threshold and shell_cooldown_ms should be passed from RenderContext or a ShellConfig struct.\n\n## Acceptance Criteria\n- [ ] After 3 consecutive failures, command is not executed for 30s (configurable)\n- [ ] Success resets failure count to 0\n- [ ] Cooldown expiry allows retry\n- [ ] Failure tracking is per-command-key (git != br != sysctl)\n- [ ] State is in-memory only (resets on process restart as PRD specifies)\n- [ ] cargo test with unit tests for circuit breaker logic\n- [ ] cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/shell.rs (add CircuitBreaker, wrap exec_with_timeout)\n- src/section/mod.rs (pass shell config to context or make available)\n- src/bin/claude-statusline.rs (pass config values)\n\n## Edge Cases\n- Circuit opens mid-render for one command but not others\n- Concurrent renders: Mutex is fine since renders are sequential per process\n- shell_failure_threshold = 0 means disabled (never trip)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-06T20:19:02.633385Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:20:43.664128Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2vm","depends_on_id":"bd-ywx","type":"blocks","created_at":"2026-02-06T20:20:43.664112Z","created_by":"tayloreernisse"}]} +{"id":"bd-2vm","title":"Implement shell circuit breaker (failure tracking + cooldown)","description":"## Background\nPRD 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).\n\n## Approach\nAdd a static CircuitBreaker struct in src/shell.rs:\n```rust\nuse std::sync::Mutex;\nuse std::collections::HashMap;\nuse std::time::Instant;\n\nstruct CircuitState {\n failures: u8,\n cooldown_until: Option,\n}\n\nstatic BREAKER: Mutex> = Mutex::new(HashMap::new());\n```\n\nWrap exec_with_timeout in a new function exec_with_circuit_breaker(program, args, dir, timeout, threshold, cooldown_ms) that:\n1. Checks if command key is in cooldown -> return None\n2. Calls exec_with_timeout\n3. On success: reset failure count\n4. On failure: increment count, if >= threshold set cooldown_until\n\nConfig values shell_failure_threshold and shell_cooldown_ms should be passed from RenderContext or a ShellConfig struct.\n\n## Acceptance Criteria\n- [ ] After 3 consecutive failures, command is not executed for 30s (configurable)\n- [ ] Success resets failure count to 0\n- [ ] Cooldown expiry allows retry\n- [ ] Failure tracking is per-command-key (git != br != sysctl)\n- [ ] State is in-memory only (resets on process restart as PRD specifies)\n- [ ] cargo test with unit tests for circuit breaker logic\n- [ ] cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/shell.rs (add CircuitBreaker, wrap exec_with_timeout)\n- src/section/mod.rs (pass shell config to context or make available)\n- src/bin/claude-statusline.rs (pass config values)\n\n## Edge Cases\n- Circuit opens mid-render for one command but not others\n- Concurrent renders: Mutex is fine since renders are sequential per process\n- shell_failure_threshold = 0 means disabled (never trip)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-06T20:19:02.633385Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:48:25.970301Z","closed_at":"2026-02-06T20:48:25.970238Z","close_reason":"Circuit breaker with per-command failure tracking via static Mutex. Trips after failure_threshold consecutive failures, opens cooldown_ms window. Resets on success or cooldown expiry. Integrated into exec_gated pipeline.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2vm","depends_on_id":"bd-ywx","type":"blocks","created_at":"2026-02-06T20:20:43.664112Z","created_by":"tayloreernisse"}]} {"id":"bd-2z0","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:11:17.544497Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:21:20.470989Z","closed_at":"2026-02-06T19:21:20.470915Z","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.","compaction_level":0,"original_size":0,"labels":["phase-3","sections"],"dependencies":[{"issue_id":"bd-2z0","depends_on_id":"bd-3hb","type":"blocks","created_at":"2026-02-06T18:11:35.826685Z","created_by":"tayloreernisse"},{"issue_id":"bd-2z0","depends_on_id":"bd-7nx","type":"blocks","created_at":"2026-02-06T18:11:35.785781Z","created_by":"tayloreernisse"},{"issue_id":"bd-2z0","depends_on_id":"bd-s6n","type":"blocks","created_at":"2026-02-06T18:11:35.805900Z","created_by":"tayloreernisse"}]} -{"id":"bd-30o","title":"Implement gradual drop strategy in priority.rs","description":"## Background\nPRD 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.\n\n## Approach\nIn src/layout/priority.rs, add a gradual_drop function alongside existing priority_drop:\n1. Sort candidates by (priority DESC, display_width DESC, position DESC)\n2. Remove one at a time, recompute line_width after each\n3. Stop when line fits or only priority 1 sections remain\n4. In the caller (src/layout/mod.rs render_line), check config.global.drop_strategy and dispatch to tiered or gradual.\n\n## Acceptance Criteria\n- [ ] drop_strategy = \"gradual\" drops sections one at a time\n- [ ] Drop order: highest priority number first, then widest, then rightmost\n- [ ] Priority 1 sections never drop in either strategy\n- [ ] Recalculate width after each individual drop\n- [ ] drop_strategy = \"tiered\" behavior unchanged\n- [ ] cargo test with tests for both strategies\n- [ ] cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/layout/priority.rs (add gradual_drop function)\n- src/layout/mod.rs (dispatch based on config.global.drop_strategy)\n\n## Edge Cases\n- All sections are priority 1: nothing drops even if overflowing\n- Single section: no drops needed\n- Two sections with same priority and width: rightmost drops first","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-06T20:19:34.219695Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:19:34.219695Z","compaction_level":0,"original_size":0} -{"id":"bd-3ax","title":"Implement render budget timer with graceful degradation","description":"## Background\nPRD 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).\n\n## Approach\n\n**Step 1: Add budget tracking to RenderContext**\n```rust\npub struct RenderContext<'a> {\n // ...existing fields...\n pub budget_start: Option, // None = unlimited\n pub budget_ms: u64,\n}\n\nimpl RenderContext<'_> {\n pub fn budget_remaining(&self) -> Option {\n let start = self.budget_start?;\n let budget = Duration::from_millis(self.budget_ms);\n budget.checked_sub(start.elapsed())\n }\n pub fn budget_exceeded(&self) -> bool {\n self.budget_remaining().map_or(false, |r| r.is_zero())\n }\n}\n```\n\n**Step 2: Check budget in render_line()**\nIn src/layout/mod.rs render_line(), before each section render:\n```rust\nfor id in section_ids {\n if ctx.budget_exceeded() {\n let (priority, _) = section_meta(id, ctx.config);\n if priority > 1 {\n continue; // Skip non-essential sections when over budget\n }\n // Priority 1 sections always render regardless of budget\n }\n let output = section::render_section(id, ctx);\n // ...\n}\n```\n\n**Step 3: ANSI validity guarantee**\nEach 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:\n```rust\nfn assemble_left(active: &[ActiveSection], ...) -> String {\n let mut output = String::new();\n // ...existing assembly...\n output.push_str(color::RESET); // Ensure no unclosed sequences\n output\n}\n```\nThis is idempotent (double RESET is harmless) and guarantees ANSI validity.\n\n**Step 4: Skip flex expansion when over budget**\nIn render_line(), if budget_exceeded before flex phase, skip flex expansion entirely - emit what we have without flex padding.\n\n**Step 5: Wire budget from config**\nbudget_ms = 0 means unlimited (budget_start = None).\n\n## Acceptance Criteria\n- [ ] Sections are skipped when render budget is exceeded\n- [ ] Priority 1 sections ALWAYS render regardless of budget\n- [ ] Non-priority-1 sections are skipped in layout order (leftmost remaining skip first)\n- [ ] Partial output always has valid ANSI (RESET appended to every line)\n- [ ] render_budget_ms = 0 disables budget (no timing overhead)\n- [ ] Budget timer starts after config load, not including stdin read\n- [ ] Flex expansion skipped when over budget\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: test_budget_zero_renders_all, test_budget_skips_low_priority, test_budget_always_renders_priority1, test_partial_output_ends_with_reset\nGREEN: Add budget_start/budget_ms to RenderContext, check in render_line\nVERIFY: cargo test -- budget\n\n## Files\n- src/section/mod.rs (add budget_start, budget_ms, helper methods to RenderContext)\n- src/layout/mod.rs (check budget before each section render, skip flex when over budget)\n- src/layout/mod.rs assemble_left (append RESET for ANSI safety)\n- src/bin/claude-statusline.rs (set budget_start = Instant::now() after config load, budget_ms from config)\n\n## Edge Cases\n- All sections are priority 1: budget has no effect (all rendered)\n- Budget exceeded during flex expansion: emit without flex\n- Budget = 0: unlimited, budget_start set to None (no Instant::now call)\n- Budget exceeded before first section: still render priority 1 sections\n- Single-line layout: budget applies per line, not per render","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-06T20:19:43.552342Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:24:46.124093Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ax","depends_on_id":"bd-62g","type":"blocks","created_at":"2026-02-06T20:20:43.735777Z","created_by":"tayloreernisse"}]} +{"id":"bd-30o","title":"Implement gradual drop strategy in priority.rs","description":"## Background\nPRD 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.\n\n## Approach\nIn src/layout/priority.rs, add a gradual_drop function alongside existing priority_drop:\n1. Sort candidates by (priority DESC, display_width DESC, position DESC)\n2. Remove one at a time, recompute line_width after each\n3. Stop when line fits or only priority 1 sections remain\n4. In the caller (src/layout/mod.rs render_line), check config.global.drop_strategy and dispatch to tiered or gradual.\n\n## Acceptance Criteria\n- [ ] drop_strategy = \"gradual\" drops sections one at a time\n- [ ] Drop order: highest priority number first, then widest, then rightmost\n- [ ] Priority 1 sections never drop in either strategy\n- [ ] Recalculate width after each individual drop\n- [ ] drop_strategy = \"tiered\" behavior unchanged\n- [ ] cargo test with tests for both strategies\n- [ ] cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/layout/priority.rs (add gradual_drop function)\n- src/layout/mod.rs (dispatch based on config.global.drop_strategy)\n\n## Edge Cases\n- All sections are priority 1: nothing drops even if overflowing\n- Single section: no drops needed\n- Two sections with same priority and width: rightmost drops first","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-06T20:19:34.219695Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:54:39.455735Z","closed_at":"2026-02-06T20:54:39.455688Z","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.","compaction_level":0,"original_size":0} +{"id":"bd-3ax","title":"Implement render budget timer with graceful degradation","description":"## Background\nPRD 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).\n\n## Approach\n\n**Step 1: Add budget tracking to RenderContext**\n```rust\npub struct RenderContext<'a> {\n // ...existing fields...\n pub budget_start: Option, // None = unlimited\n pub budget_ms: u64,\n}\n\nimpl RenderContext<'_> {\n pub fn budget_remaining(&self) -> Option {\n let start = self.budget_start?;\n let budget = Duration::from_millis(self.budget_ms);\n budget.checked_sub(start.elapsed())\n }\n pub fn budget_exceeded(&self) -> bool {\n self.budget_remaining().map_or(false, |r| r.is_zero())\n }\n}\n```\n\n**Step 2: Check budget in render_line()**\nIn src/layout/mod.rs render_line(), before each section render:\n```rust\nfor id in section_ids {\n if ctx.budget_exceeded() {\n let (priority, _) = section_meta(id, ctx.config);\n if priority > 1 {\n continue; // Skip non-essential sections when over budget\n }\n // Priority 1 sections always render regardless of budget\n }\n let output = section::render_section(id, ctx);\n // ...\n}\n```\n\n**Step 3: ANSI validity guarantee**\nEach 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:\n```rust\nfn assemble_left(active: &[ActiveSection], ...) -> String {\n let mut output = String::new();\n // ...existing assembly...\n output.push_str(color::RESET); // Ensure no unclosed sequences\n output\n}\n```\nThis is idempotent (double RESET is harmless) and guarantees ANSI validity.\n\n**Step 4: Skip flex expansion when over budget**\nIn render_line(), if budget_exceeded before flex phase, skip flex expansion entirely - emit what we have without flex padding.\n\n**Step 5: Wire budget from config**\nbudget_ms = 0 means unlimited (budget_start = None).\n\n## Acceptance Criteria\n- [ ] Sections are skipped when render budget is exceeded\n- [ ] Priority 1 sections ALWAYS render regardless of budget\n- [ ] Non-priority-1 sections are skipped in layout order (leftmost remaining skip first)\n- [ ] Partial output always has valid ANSI (RESET appended to every line)\n- [ ] render_budget_ms = 0 disables budget (no timing overhead)\n- [ ] Budget timer starts after config load, not including stdin read\n- [ ] Flex expansion skipped when over budget\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: test_budget_zero_renders_all, test_budget_skips_low_priority, test_budget_always_renders_priority1, test_partial_output_ends_with_reset\nGREEN: Add budget_start/budget_ms to RenderContext, check in render_line\nVERIFY: cargo test -- budget\n\n## Files\n- src/section/mod.rs (add budget_start, budget_ms, helper methods to RenderContext)\n- src/layout/mod.rs (check budget before each section render, skip flex when over budget)\n- src/layout/mod.rs assemble_left (append RESET for ANSI safety)\n- src/bin/claude-statusline.rs (set budget_start = Instant::now() after config load, budget_ms from config)\n\n## Edge Cases\n- All sections are priority 1: budget has no effect (all rendered)\n- Budget exceeded during flex expansion: emit without flex\n- Budget = 0: unlimited, budget_start set to None (no Instant::now call)\n- Budget exceeded before first section: still render priority 1 sections\n- Single-line layout: budget applies per line, not per render","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-06T20:19:43.552342Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:54:40.630832Z","closed_at":"2026-02-06T20:54:40.630783Z","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.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ax","depends_on_id":"bd-62g","type":"blocks","created_at":"2026-02-06T20:20:43.735777Z","created_by":"tayloreernisse"}]} {"id":"bd-3hb","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:11:12.323223Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:29.022076Z","closed_at":"2026-02-06T19:08:29.022033Z","close_reason":"Fully implemented during scaffold: exec_with_timeout, parse_git_status_v2","compaction_level":0,"original_size":0,"labels":["core","phase-3"]} {"id":"bd-3hc","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:45.407479Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:12:33.169152Z","closed_at":"2026-02-06T19:12:33.169044Z","close_reason":"CLI fully implemented during scaffold, all dependencies now closed","compaction_level":0,"original_size":0,"labels":["cli","phase-1"],"dependencies":[{"issue_id":"bd-3hc","depends_on_id":"bd-1ac","type":"blocks","created_at":"2026-02-06T18:11:33.241844Z","created_by":"tayloreernisse"},{"issue_id":"bd-3hc","depends_on_id":"bd-2u8","type":"blocks","created_at":"2026-02-06T18:11:33.260886Z","created_by":"tayloreernisse"}]} -{"id":"bd-3l2","title":"Implement cache GC (garbage collection of old cache dirs)","description":"## Background\nPRD 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.\n\n## Approach\n1. Add a gc() function in src/cache.rs\n2. Check gc eligibility: read mtime of /tmp/claude-sl-gc.lock. If younger than gc_interval_hours, skip.\n3. Try flock on gc.lock (non-blocking). If contention, skip.\n4. Scan /tmp/claude-sl-* directories. For each: verify owned by current user, not symlink, mtime older than gc_days -> remove_dir_all.\n5. Call gc() AFTER print!() in main, so output is not delayed.\n6. Consider spawning a detached thread for GC so the process exits immediately.\n\n## Acceptance Criteria\n- [ ] Old cache dirs (>7 days) are cleaned up\n- [ ] GC runs at most once per 24 hours (configurable)\n- [ ] GC only deletes dirs owned by current user\n- [ ] GC does not follow symlinks\n- [ ] GC does not block status line output\n- [ ] GC uses lock file to prevent concurrent runs\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/cache.rs (add gc function)\n- src/bin/claude-statusline.rs (call gc after render output)\n\n## Edge Cases\n- /tmp/claude-sl-* dirs from other users: skip (ownership check)\n- Symlinked cache dirs: skip (symlink check)\n- No old dirs to clean: exit GC early\n- Lock contention: skip GC silently","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-06T20:19:14.041202Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:19:14.041202Z","compaction_level":0,"original_size":0} -{"id":"bd-3oy","title":"Fix cache namespace to include cache_version and config_hash","description":"## Background\nPRD specifies cache directory template: /tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/\nCurrent implementation only uses {session_id}. This means config changes dont invalidate cache, and binary upgrades with new cache_version dont clear stale data.\n\n## Approach\n1. Compute config_hash: MD5 of the merged config JSON (post-merge, pre-deserialize), truncated to 8 hex chars.\n2. Update cache dir template in defaults.json from /tmp/claude-sl-{session_id} to /tmp/claude-sl-{session_id}-{cache_version}-{config_hash}\n3. In Cache::new(), also replace {cache_version} and {config_hash} placeholders.\n4. Pass config_hash and cache_version when constructing Cache.\n\n## Acceptance Criteria\n- [ ] Cache dir includes {session_id}-{cache_version}-{config_hash} in path\n- [ ] Changing any config value produces a different cache dir\n- [ ] Bumping cache_version in config produces a different cache dir\n- [ ] Old cache dirs are not cleaned up (thats Cache GCs job)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## Files\n- defaults.json (update cache_dir template)\n- src/cache.rs (add config_hash and cache_version replacement in new())\n- src/bin/claude-statusline.rs (compute config_hash from merged JSON, pass to Cache::new)\n\n## Edge Cases\n- User overrides cache_dir in config without {cache_version}/{config_hash}: works fine, placeholders just arent present\n- Empty config produces consistent hash","status":"open","priority":1,"issue_type":"bug","created_at":"2026-02-06T20:18:46.944106Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:18:46.944106Z","compaction_level":0,"original_size":0} -{"id":"bd-3q1","title":"Implement CLAUDE_STATUSLINE_NO_CACHE, _NO_SHELL, _COLOR env vars","description":"## Background\nPRD 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.\n\n## Approach\nIn src/bin/claude-statusline.rs, check env vars early:\n1. CLAUDE_STATUSLINE_NO_CACHE: set no_cache = true (same effect as --no-cache flag)\n2. CLAUDE_STATUSLINE_NO_SHELL: set no_shell = true (same effect as --no-shell flag)\n3. 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.\n\n## Acceptance Criteria\n- [ ] CLAUDE_STATUSLINE_NO_CACHE=1 disables caching (same as --no-cache)\n- [ ] CLAUDE_STATUSLINE_NO_SHELL=1 disables shell-outs (same as --no-shell)\n- [ ] CLAUDE_STATUSLINE_COLOR=always forces color on, =never forces off, =auto uses detection\n- [ ] CLI flags take precedence over env vars\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/bin/claude-statusline.rs (check env vars for no_cache, no_shell)\n- src/color.rs (add CLAUDE_STATUSLINE_COLOR to should_use_color precedence chain)\n\n## Edge Cases\n- CLAUDE_STATUSLINE_COLOR=invalid should fall through to auto\n- Both --color=always and CLAUDE_STATUSLINE_COLOR=never set: CLI wins","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-06T20:18:38.622692Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:20:43.639516Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3q1","depends_on_id":"bd-khk","type":"blocks","created_at":"2026-02-06T20:20:43.639484Z","created_by":"tayloreernisse"}]} +{"id":"bd-3l2","title":"Implement cache GC (garbage collection of old cache dirs)","description":"## Background\nPRD 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.\n\n## Approach\n1. Add a gc() function in src/cache.rs\n2. Check gc eligibility: read mtime of /tmp/claude-sl-gc.lock. If younger than gc_interval_hours, skip.\n3. Try flock on gc.lock (non-blocking). If contention, skip.\n4. Scan /tmp/claude-sl-* directories. For each: verify owned by current user, not symlink, mtime older than gc_days -> remove_dir_all.\n5. Call gc() AFTER print!() in main, so output is not delayed.\n6. Consider spawning a detached thread for GC so the process exits immediately.\n\n## Acceptance Criteria\n- [ ] Old cache dirs (>7 days) are cleaned up\n- [ ] GC runs at most once per 24 hours (configurable)\n- [ ] GC only deletes dirs owned by current user\n- [ ] GC does not follow symlinks\n- [ ] GC does not block status line output\n- [ ] GC uses lock file to prevent concurrent runs\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/cache.rs (add gc function)\n- src/bin/claude-statusline.rs (call gc after render output)\n\n## Edge Cases\n- /tmp/claude-sl-* dirs from other users: skip (ownership check)\n- Symlinked cache dirs: skip (symlink check)\n- No old dirs to clean: exit GC early\n- Lock contention: skip GC silently","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-06T20:19:14.041202Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:44:41.522336Z","closed_at":"2026-02-06T20:44:41.522288Z","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","compaction_level":0,"original_size":0} +{"id":"bd-3oy","title":"Fix cache namespace to include cache_version and config_hash","description":"## Background\nPRD specifies cache directory template: /tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/\nCurrent implementation only uses {session_id}. This means config changes dont invalidate cache, and binary upgrades with new cache_version dont clear stale data.\n\n## Approach\n1. Compute config_hash: MD5 of the merged config JSON (post-merge, pre-deserialize), truncated to 8 hex chars.\n2. Update cache dir template in defaults.json from /tmp/claude-sl-{session_id} to /tmp/claude-sl-{session_id}-{cache_version}-{config_hash}\n3. In Cache::new(), also replace {cache_version} and {config_hash} placeholders.\n4. Pass config_hash and cache_version when constructing Cache.\n\n## Acceptance Criteria\n- [ ] Cache dir includes {session_id}-{cache_version}-{config_hash} in path\n- [ ] Changing any config value produces a different cache dir\n- [ ] Bumping cache_version in config produces a different cache dir\n- [ ] Old cache dirs are not cleaned up (thats Cache GCs job)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## Files\n- defaults.json (update cache_dir template)\n- src/cache.rs (add config_hash and cache_version replacement in new())\n- src/bin/claude-statusline.rs (compute config_hash from merged JSON, pass to Cache::new)\n\n## Edge Cases\n- User overrides cache_dir in config without {cache_version}/{config_hash}: works fine, placeholders just arent present\n- Empty config produces consistent hash","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-06T20:18:46.944106Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:40:11.696907Z","closed_at":"2026-02-06T20:40:11.696847Z","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","compaction_level":0,"original_size":0} +{"id":"bd-3q1","title":"Implement CLAUDE_STATUSLINE_NO_CACHE, _NO_SHELL, _COLOR env vars","description":"## Background\nPRD 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.\n\n## Approach\nIn src/bin/claude-statusline.rs, check env vars early:\n1. CLAUDE_STATUSLINE_NO_CACHE: set no_cache = true (same effect as --no-cache flag)\n2. CLAUDE_STATUSLINE_NO_SHELL: set no_shell = true (same effect as --no-shell flag)\n3. 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.\n\n## Acceptance Criteria\n- [ ] CLAUDE_STATUSLINE_NO_CACHE=1 disables caching (same as --no-cache)\n- [ ] CLAUDE_STATUSLINE_NO_SHELL=1 disables shell-outs (same as --no-shell)\n- [ ] CLAUDE_STATUSLINE_COLOR=always forces color on, =never forces off, =auto uses detection\n- [ ] CLI flags take precedence over env vars\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## Files\n- src/bin/claude-statusline.rs (check env vars for no_cache, no_shell)\n- src/color.rs (add CLAUDE_STATUSLINE_COLOR to should_use_color precedence chain)\n\n## Edge Cases\n- CLAUDE_STATUSLINE_COLOR=invalid should fall through to auto\n- Both --color=always and CLAUDE_STATUSLINE_COLOR=never set: CLI wins","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T20:18:38.622692Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:44:36.485394Z","closed_at":"2026-02-06T20:44:36.485317Z","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)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3q1","depends_on_id":"bd-khk","type":"blocks","created_at":"2026-02-06T20:20:43.639484Z","created_by":"tayloreernisse"}]} {"id":"bd-3s1","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:11:03.583651Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:14:32.944474Z","closed_at":"2026-02-06T19:14:32.944429Z","close_reason":"Flex expansion: spacer wins, context_bar rebuilds with wider bar, others pad","compaction_level":0,"original_size":0,"labels":["layout","phase-2"],"dependencies":[{"issue_id":"bd-3s1","depends_on_id":"bd-7nx","type":"blocks","created_at":"2026-02-06T18:11:35.747152Z","created_by":"tayloreernisse"}]} -{"id":"bd-3uw","title":"Implement parallel shell-outs via std::thread::scope","description":"## Background\nPRD 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.\n\n## Approach\nRestructure the render pipeline to pre-fetch shell results before section rendering.\n\n**Step 1: Define ShellResults type**\nIn src/section/mod.rs, add to RenderContext:\n```rust\npub shell_results: &'a HashMap>,\n```\n\n**Step 2: Pre-fetch function**\nIn src/lib.rs or a new src/prefetch.rs, create:\n```rust\npub fn prefetch_shell_outs(\n cache: &Cache,\n config: &Config,\n project_dir: &str,\n no_shell: bool,\n) -> HashMap> {\n let mut results = HashMap::new();\n if no_shell { return results; }\n\n // Collect which keys have expired caches\n let vcs_expired = cache.get(\"vcs_branch\", branch_ttl).is_none();\n let load_expired = cache.get(\"load\", load_ttl).is_none();\n let beads_expired = cache.get(\"beads\", beads_ttl).is_none();\n\n // Only spawn threads for expired keys\n std::thread::scope(|s| {\n let vcs_h = vcs_expired.then(|| s.spawn(|| {\n shell::exec_with_timeout(\"git\", &[\"-C\", dir, \"status\", \"--porcelain=v2\", \"--branch\"], None, timeout)\n }));\n let load_h = load_expired.then(|| s.spawn(|| {\n shell::exec_with_timeout(\"sysctl\", &[\"-n\", \"vm.loadavg\"], None, timeout)\n }));\n // ... beads, hostname, custom ...\n\n if let Some(h) = vcs_h { results.insert(\"vcs\".into(), h.join().ok().flatten()); }\n if let Some(h) = load_h { results.insert(\"load\".into(), h.join().ok().flatten()); }\n });\n results\n}\n```\n\n**Step 3: Wire into sections**\nEach shell-out section checks shell_results first:\n```rust\n// In vcs.rs render_git():\nlet output = ctx.shell_results.get(\"vcs\")\n .cloned()\n .flatten()\n .or_else(|| shell::exec_with_timeout(...));\n```\n\n## Acceptance Criteria\n- [ ] prefetch_shell_outs() runs before layout/render phase\n- [ ] Only cache-miss shell-outs are spawned as threads\n- [ ] Cache hits bypass thread::scope entirely (0 threads spawned)\n- [ ] shell_results is passed through RenderContext to all sections\n- [ ] Each shell-out section checks shell_results before calling exec_with_timeout\n- [ ] Thread panics are caught by join() and yield None\n- [ ] Cold render with 3 expired shell-outs is measurably faster (wall time)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: src/tests/prefetch.rs - test_prefetch_all_cached_skips_threads, test_prefetch_expired_keys_parallel\nGREEN: Implement prefetch_shell_outs with thread::scope\nVERIFY: cargo test -- prefetch\n\n## Files\n- src/section/mod.rs (add shell_results to RenderContext)\n- src/lib.rs or src/prefetch.rs (prefetch_shell_outs function)\n- src/bin/claude-statusline.rs (call prefetch before render, pass results to ctx)\n- src/section/vcs.rs, beads.rs, load.rs, custom.rs (check shell_results first)\n\n## Edge Cases\n- Only 1 shell-out expired: spawns 1 thread, no parallelism benefit but correct\n- Thread panic in scope: caught by join(), returns None, other threads unaffected\n- All cached: HashMap is empty, no threads spawned, zero overhead\n- Custom sections: may have N commands; each gets its own key in shell_results","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-06T20:20:16.961440Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:23:09.400495Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3uw","depends_on_id":"bd-2vm","type":"blocks","created_at":"2026-02-06T20:20:43.683961Z","created_by":"tayloreernisse"}]} +{"id":"bd-3uw","title":"Implement parallel shell-outs via std::thread::scope","description":"## Background\nPRD 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.\n\n## Approach\nRestructure the render pipeline to pre-fetch shell results before section rendering.\n\n**Step 1: Define ShellResults type**\nIn src/section/mod.rs, add to RenderContext:\n```rust\npub shell_results: &'a HashMap>,\n```\n\n**Step 2: Pre-fetch function**\nIn src/lib.rs or a new src/prefetch.rs, create:\n```rust\npub fn prefetch_shell_outs(\n cache: &Cache,\n config: &Config,\n project_dir: &str,\n no_shell: bool,\n) -> HashMap> {\n let mut results = HashMap::new();\n if no_shell { return results; }\n\n // Collect which keys have expired caches\n let vcs_expired = cache.get(\"vcs_branch\", branch_ttl).is_none();\n let load_expired = cache.get(\"load\", load_ttl).is_none();\n let beads_expired = cache.get(\"beads\", beads_ttl).is_none();\n\n // Only spawn threads for expired keys\n std::thread::scope(|s| {\n let vcs_h = vcs_expired.then(|| s.spawn(|| {\n shell::exec_with_timeout(\"git\", &[\"-C\", dir, \"status\", \"--porcelain=v2\", \"--branch\"], None, timeout)\n }));\n let load_h = load_expired.then(|| s.spawn(|| {\n shell::exec_with_timeout(\"sysctl\", &[\"-n\", \"vm.loadavg\"], None, timeout)\n }));\n // ... beads, hostname, custom ...\n\n if let Some(h) = vcs_h { results.insert(\"vcs\".into(), h.join().ok().flatten()); }\n if let Some(h) = load_h { results.insert(\"load\".into(), h.join().ok().flatten()); }\n });\n results\n}\n```\n\n**Step 3: Wire into sections**\nEach shell-out section checks shell_results first:\n```rust\n// In vcs.rs render_git():\nlet output = ctx.shell_results.get(\"vcs\")\n .cloned()\n .flatten()\n .or_else(|| shell::exec_with_timeout(...));\n```\n\n## Acceptance Criteria\n- [ ] prefetch_shell_outs() runs before layout/render phase\n- [ ] Only cache-miss shell-outs are spawned as threads\n- [ ] Cache hits bypass thread::scope entirely (0 threads spawned)\n- [ ] shell_results is passed through RenderContext to all sections\n- [ ] Each shell-out section checks shell_results before calling exec_with_timeout\n- [ ] Thread panics are caught by join() and yield None\n- [ ] Cold render with 3 expired shell-outs is measurably faster (wall time)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: src/tests/prefetch.rs - test_prefetch_all_cached_skips_threads, test_prefetch_expired_keys_parallel\nGREEN: Implement prefetch_shell_outs with thread::scope\nVERIFY: cargo test -- prefetch\n\n## Files\n- src/section/mod.rs (add shell_results to RenderContext)\n- src/lib.rs or src/prefetch.rs (prefetch_shell_outs function)\n- src/bin/claude-statusline.rs (call prefetch before render, pass results to ctx)\n- src/section/vcs.rs, beads.rs, load.rs, custom.rs (check shell_results first)\n\n## Edge Cases\n- Only 1 shell-out expired: spawns 1 thread, no parallelism benefit but correct\n- Thread panic in scope: caught by join(), returns None, other threads unaffected\n- All cached: HashMap is empty, no threads spawned, zero overhead\n- Custom sections: may have N commands; each gets its own key in shell_results","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-06T20:20:16.961440Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:54:37.510767Z","closed_at":"2026-02-06T20:54:37.510719Z","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.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3uw","depends_on_id":"bd-2vm","type":"blocks","created_at":"2026-02-06T20:20:43.683961Z","created_by":"tayloreernisse"}]} {"id":"bd-3w4","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.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-02-06T18:10:21.652551Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.839621Z","closed_at":"2026-02-06T19:08:28.839576Z","close_reason":"Fully implemented during scaffold: config hierarchy, deep merge, load_config, unknown key warnings","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-3w4","depends_on_id":"bd-1m2","type":"blocks","created_at":"2026-02-06T18:11:30.450820Z","created_by":"tayloreernisse"}]} -{"id":"bd-4b1","title":"Enhance --dump-state with per-section timing and cache diagnostics","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Width detection source tracking**\nIn src/width.rs, change detect_raw() to return (u16, &str) where the &str identifies the source:\n```rust\nfn detect_raw(cli_width: Option, config_width: Option) -> (u16, &'static str) {\n if let Some(w) = cli_width { return (w, \"cli_flag\"); }\n // ... \"env_var\", \"config\", \"ioctl\", \"process_tree\", \"stty\", \"columns_env\", \"tput\", \"fallback\"\n}\n```\nStore the source in a thread-local or return from detect_width().\n\n**Step 2: Cache hit/miss tracking**\nAdd a diagnostics field to Cache:\n```rust\npub struct Cache {\n dir: Option,\n diagnostics: RefCell>,\n}\npub struct CacheDiag {\n pub key: String,\n pub hit: bool,\n pub age_ms: Option,\n}\n```\nIn get(), push a CacheDiag entry. Expose via cache.diagnostics() method.\n\n**Step 3: Per-section render timing**\nIn src/layout/mod.rs render_line(), wrap each render_section() call:\n```rust\nlet start = Instant::now();\nlet output = section::render_section(id, ctx);\nlet elapsed_us = start.elapsed().as_micros() as u64;\nsection_timings.push((id.clone(), elapsed_us));\n```\nReturn timings alongside the rendered line.\n\n**Step 4: Priority drop recording**\nIn src/layout/priority.rs, return (Vec, Vec) where the second Vec contains dropped section IDs with their priority level.\n\n**Step 5: Assemble in dump_state_output**\nCollect all diagnostics into the JSON output:\n```json\n{\n \"terminal\": { \"effective_width\": 174, \"source\": \"process_tree\", ... },\n \"sections\": [\n { \"id\": \"model\", \"render_us\": 12, \"dropped\": false },\n { \"id\": \"beads\", \"render_us\": 0, \"dropped\": true, \"drop_reason\": \"priority_3_tiered\" }\n ],\n \"cache\": [\n { \"key\": \"vcs_branch\", \"hit\": true, \"age_ms\": 1200 },\n { \"key\": \"load\", \"hit\": false }\n ],\n ...existing fields...\n}\n```\n\n## Acceptance Criteria\n- [ ] --dump-state=json includes \"terminal.source\" identifying width detection method\n- [ ] --dump-state=json includes \"sections\" array with per-section render_us timing\n- [ ] --dump-state=json includes \"cache\" array with hit/miss and age per key\n- [ ] --dump-state=json includes dropped sections with drop_reason\n- [ ] --dump-state=text outputs human-readable formatted version\n- [ ] Diagnostics collection adds <1ms overhead to normal renders\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: test_dump_state_json_has_sections, test_dump_state_json_has_cache_diag, test_dump_state_json_has_width_source\nGREEN: Instrument width.rs, cache.rs, layout/mod.rs, priority.rs\nVERIFY: cargo test -- dump_state\n\n## Files\n- src/width.rs (return detection source from detect_raw)\n- src/cache.rs (add CacheDiag tracking via RefCell)\n- src/layout/mod.rs (add per-section timing collection)\n- src/layout/priority.rs (return dropped section IDs)\n- src/bin/claude-statusline.rs (collect all diagnostics, assemble dump_state JSON)\n\n## Edge Cases\n- --dump-state with --test: uses mock data but shows real timing and detection\n- Cache disabled (dir: None): all keys show miss, age_ms: null\n- No sections dropped: \"sections\" array has all items with dropped: false\n- --dump-state=text: tabular format, human-readable (no JSON parsing needed)","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-06T20:20:25.542018Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:24:09.847667Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4b1","depends_on_id":"bd-62g","type":"blocks","created_at":"2026-02-06T20:20:43.707878Z","created_by":"tayloreernisse"}]} +{"id":"bd-4b1","title":"Enhance --dump-state with per-section timing and cache diagnostics","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Width detection source tracking**\nIn src/width.rs, change detect_raw() to return (u16, &str) where the &str identifies the source:\n```rust\nfn detect_raw(cli_width: Option, config_width: Option) -> (u16, &'static str) {\n if let Some(w) = cli_width { return (w, \"cli_flag\"); }\n // ... \"env_var\", \"config\", \"ioctl\", \"process_tree\", \"stty\", \"columns_env\", \"tput\", \"fallback\"\n}\n```\nStore the source in a thread-local or return from detect_width().\n\n**Step 2: Cache hit/miss tracking**\nAdd a diagnostics field to Cache:\n```rust\npub struct Cache {\n dir: Option,\n diagnostics: RefCell>,\n}\npub struct CacheDiag {\n pub key: String,\n pub hit: bool,\n pub age_ms: Option,\n}\n```\nIn get(), push a CacheDiag entry. Expose via cache.diagnostics() method.\n\n**Step 3: Per-section render timing**\nIn src/layout/mod.rs render_line(), wrap each render_section() call:\n```rust\nlet start = Instant::now();\nlet output = section::render_section(id, ctx);\nlet elapsed_us = start.elapsed().as_micros() as u64;\nsection_timings.push((id.clone(), elapsed_us));\n```\nReturn timings alongside the rendered line.\n\n**Step 4: Priority drop recording**\nIn src/layout/priority.rs, return (Vec, Vec) where the second Vec contains dropped section IDs with their priority level.\n\n**Step 5: Assemble in dump_state_output**\nCollect all diagnostics into the JSON output:\n```json\n{\n \"terminal\": { \"effective_width\": 174, \"source\": \"process_tree\", ... },\n \"sections\": [\n { \"id\": \"model\", \"render_us\": 12, \"dropped\": false },\n { \"id\": \"beads\", \"render_us\": 0, \"dropped\": true, \"drop_reason\": \"priority_3_tiered\" }\n ],\n \"cache\": [\n { \"key\": \"vcs_branch\", \"hit\": true, \"age_ms\": 1200 },\n { \"key\": \"load\", \"hit\": false }\n ],\n ...existing fields...\n}\n```\n\n## Acceptance Criteria\n- [ ] --dump-state=json includes \"terminal.source\" identifying width detection method\n- [ ] --dump-state=json includes \"sections\" array with per-section render_us timing\n- [ ] --dump-state=json includes \"cache\" array with hit/miss and age per key\n- [ ] --dump-state=json includes dropped sections with drop_reason\n- [ ] --dump-state=text outputs human-readable formatted version\n- [ ] Diagnostics collection adds <1ms overhead to normal renders\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: test_dump_state_json_has_sections, test_dump_state_json_has_cache_diag, test_dump_state_json_has_width_source\nGREEN: Instrument width.rs, cache.rs, layout/mod.rs, priority.rs\nVERIFY: cargo test -- dump_state\n\n## Files\n- src/width.rs (return detection source from detect_raw)\n- src/cache.rs (add CacheDiag tracking via RefCell)\n- src/layout/mod.rs (add per-section timing collection)\n- src/layout/priority.rs (return dropped section IDs)\n- src/bin/claude-statusline.rs (collect all diagnostics, assemble dump_state JSON)\n\n## Edge Cases\n- --dump-state with --test: uses mock data but shows real timing and detection\n- Cache disabled (dir: None): all keys show miss, age_ms: null\n- No sections dropped: \"sections\" array has all items with dropped: false\n- --dump-state=text: tabular format, human-readable (no JSON parsing needed)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-06T20:20:25.542018Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:58:06.948722Z","closed_at":"2026-02-06T20:58:06.948508Z","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.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4b1","depends_on_id":"bd-62g","type":"blocks","created_at":"2026-02-06T20:20:43.707878Z","created_by":"tayloreernisse"}]} {"id":"bd-4z1","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:42.944244Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.970834Z","closed_at":"2026-02-06T19:08:28.970785Z","close_reason":"Fully implemented during scaffold: ComputedMetrics with from_input computing all derived values","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-4z1","depends_on_id":"bd-2bo","type":"blocks","created_at":"2026-02-06T18:11:33.222149Z","created_by":"tayloreernisse"}]} -{"id":"bd-62g","title":"Upgrade SectionDescriptor registry with metadata (estimated_width, shell_out)","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Define SectionDescriptor struct (already in mod.rs, needs population)**\n```rust\npub struct SectionDescriptor {\n pub id: &'static str,\n pub render: RenderFn,\n pub priority: u8,\n pub is_spacer: bool,\n pub is_flex: bool,\n pub estimated_width: u16,\n pub shell_out: bool,\n}\n```\n\n**Step 2: Populate registry with all 24 sections**\n```rust\npub fn registry() -> Vec {\n vec![\n SectionDescriptor { id: \"model\", render: model::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 12, shell_out: false },\n SectionDescriptor { id: \"provider\", render: provider::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"project\", render: project::render, priority: 1, is_spacer: false, is_flex: false, estimated_width: 20, shell_out: false },\n SectionDescriptor { id: \"vcs\", render: vcs::render, priority: 1, is_spacer: false, is_flex: false, estimated_width: 15, shell_out: true },\n SectionDescriptor { id: \"beads\", render: beads::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 25, shell_out: true },\n SectionDescriptor { id: \"context_bar\", render: context_bar::render, priority: 1, is_spacer: false, is_flex: true, estimated_width: 18, shell_out: false },\n SectionDescriptor { id: \"context_usage\", render: context_usage::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 12, shell_out: false },\n SectionDescriptor { id: \"context_remaining\", render: context_remaining::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"tokens_raw\", render: tokens_raw::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 18, shell_out: false },\n SectionDescriptor { id: \"cache_efficiency\", render: cache_efficiency::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"cost\", render: cost::render, priority: 1, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"cost_velocity\", render: cost_velocity::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"token_velocity\", render: token_velocity::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 14, shell_out: false },\n SectionDescriptor { id: \"cost_trend\", render: cost_trend::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"context_trend\", render: context_trend::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"lines_changed\", render: lines_changed::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"duration\", render: duration::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 5, shell_out: false },\n SectionDescriptor { id: \"tools\", render: tools::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 15, shell_out: false },\n SectionDescriptor { id: \"turns\", render: turns::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"load\", render: load::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: true },\n SectionDescriptor { id: \"version\", render: version::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"time\", render: time::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 5, shell_out: true },\n SectionDescriptor { id: \"output_style\", render: output_style::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"hostname\", render: hostname::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: true },\n ]\n}\n```\n\n**Step 3: Update render_section to use SectionDescriptor**\nUse descriptor.render instead of linear scan.\n\n**Step 4: Update --list-sections output**\nPrint tab-separated: id, priority, flex, shell_out, estimated_width.\n\n**Step 5: Update section_meta() in layout/mod.rs**\nRead priority and is_flex from registry descriptors instead of placeholder (2, false).\n\n## Acceptance Criteria\n- [ ] registry() returns Vec with all 24 built-in sections\n- [ ] Each descriptor has accurate priority, is_flex, estimated_width, shell_out values\n- [ ] render_section() uses descriptor.render correctly\n- [ ] section_meta() reads from registry (fixes the current placeholder)\n- [ ] --list-sections outputs id + metadata (not just IDs)\n- [ ] Custom sections still handled by custom::render fallback (not in registry)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: test_registry_has_all_sections (count=24), test_section_meta_reads_registry, test_list_sections_output_format\nGREEN: Populate SectionDescriptor, update section_meta\nVERIFY: cargo test -- registry && cargo test -- section_meta\n\n## Files\n- src/section/mod.rs (change registry return type, update render_section, populate all descriptors)\n- src/layout/mod.rs (update section_meta to read from registry)\n- src/bin/claude-statusline.rs (update --list-sections output format)\n\n## Edge Cases\n- Custom sections not in registry: handled by custom::render fallback path\n- estimated_width is approximate: used for budget estimation, not exact rendering\n- Priority values must match defaults.json section config defaults\n- section_meta currently returns (2, false) for everything - this fix corrects layout behavior","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-06T20:20:34.234266Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:23:41.949637Z","compaction_level":0,"original_size":0} +{"id":"bd-62g","title":"Upgrade SectionDescriptor registry with metadata (estimated_width, shell_out)","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Define SectionDescriptor struct (already in mod.rs, needs population)**\n```rust\npub struct SectionDescriptor {\n pub id: &'static str,\n pub render: RenderFn,\n pub priority: u8,\n pub is_spacer: bool,\n pub is_flex: bool,\n pub estimated_width: u16,\n pub shell_out: bool,\n}\n```\n\n**Step 2: Populate registry with all 24 sections**\n```rust\npub fn registry() -> Vec {\n vec![\n SectionDescriptor { id: \"model\", render: model::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 12, shell_out: false },\n SectionDescriptor { id: \"provider\", render: provider::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"project\", render: project::render, priority: 1, is_spacer: false, is_flex: false, estimated_width: 20, shell_out: false },\n SectionDescriptor { id: \"vcs\", render: vcs::render, priority: 1, is_spacer: false, is_flex: false, estimated_width: 15, shell_out: true },\n SectionDescriptor { id: \"beads\", render: beads::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 25, shell_out: true },\n SectionDescriptor { id: \"context_bar\", render: context_bar::render, priority: 1, is_spacer: false, is_flex: true, estimated_width: 18, shell_out: false },\n SectionDescriptor { id: \"context_usage\", render: context_usage::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 12, shell_out: false },\n SectionDescriptor { id: \"context_remaining\", render: context_remaining::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"tokens_raw\", render: tokens_raw::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 18, shell_out: false },\n SectionDescriptor { id: \"cache_efficiency\", render: cache_efficiency::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"cost\", render: cost::render, priority: 1, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"cost_velocity\", render: cost_velocity::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"token_velocity\", render: token_velocity::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 14, shell_out: false },\n SectionDescriptor { id: \"cost_trend\", render: cost_trend::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"context_trend\", render: context_trend::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"lines_changed\", render: lines_changed::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"duration\", render: duration::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 5, shell_out: false },\n SectionDescriptor { id: \"tools\", render: tools::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 15, shell_out: false },\n SectionDescriptor { id: \"turns\", render: turns::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"load\", render: load::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: true },\n SectionDescriptor { id: \"version\", render: version::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 8, shell_out: false },\n SectionDescriptor { id: \"time\", render: time::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 5, shell_out: true },\n SectionDescriptor { id: \"output_style\", render: output_style::render, priority: 2, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: false },\n SectionDescriptor { id: \"hostname\", render: hostname::render, priority: 3, is_spacer: false, is_flex: false, estimated_width: 10, shell_out: true },\n ]\n}\n```\n\n**Step 3: Update render_section to use SectionDescriptor**\nUse descriptor.render instead of linear scan.\n\n**Step 4: Update --list-sections output**\nPrint tab-separated: id, priority, flex, shell_out, estimated_width.\n\n**Step 5: Update section_meta() in layout/mod.rs**\nRead priority and is_flex from registry descriptors instead of placeholder (2, false).\n\n## Acceptance Criteria\n- [ ] registry() returns Vec with all 24 built-in sections\n- [ ] Each descriptor has accurate priority, is_flex, estimated_width, shell_out values\n- [ ] render_section() uses descriptor.render correctly\n- [ ] section_meta() reads from registry (fixes the current placeholder)\n- [ ] --list-sections outputs id + metadata (not just IDs)\n- [ ] Custom sections still handled by custom::render fallback (not in registry)\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: test_registry_has_all_sections (count=24), test_section_meta_reads_registry, test_list_sections_output_format\nGREEN: Populate SectionDescriptor, update section_meta\nVERIFY: cargo test -- registry && cargo test -- section_meta\n\n## Files\n- src/section/mod.rs (change registry return type, update render_section, populate all descriptors)\n- src/layout/mod.rs (update section_meta to read from registry)\n- src/bin/claude-statusline.rs (update --list-sections output format)\n\n## Edge Cases\n- Custom sections not in registry: handled by custom::render fallback path\n- estimated_width is approximate: used for budget estimation, not exact rendering\n- Priority values must match defaults.json section config defaults\n- section_meta currently returns (2, false) for everything - this fix corrects layout behavior","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-06T20:20:34.234266Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:40:16.347924Z","closed_at":"2026-02-06T20:40:16.347873Z","close_reason":"SectionDescriptor struct with id/render/priority/is_spacer/is_flex/estimated_width/shell_out, registry() returns Vec, --list-sections shows tabular metadata","compaction_level":0,"original_size":0} {"id":"bd-7nx","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.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-02-06T18:10:57.309119Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:14:32.902849Z","closed_at":"2026-02-06T19:14:32.902096Z","close_reason":"Full 3-phase layout pipeline: ActiveSection, render_line with priority/flex/justify, assemble_left, section_meta","compaction_level":0,"original_size":0,"labels":["layout","phase-2"],"dependencies":[{"issue_id":"bd-7nx","depends_on_id":"bd-2u8","type":"blocks","created_at":"2026-02-06T18:11:35.699600Z","created_by":"tayloreernisse"}]} {"id":"bd-9hl","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>. WidthTier enum.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:11:06.042830Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:16:28.937658Z","closed_at":"2026-02-06T19:16:28.937612Z","close_reason":"Full 9-level width detection chain with 1s memoization: cli > env > config > ioctl > process tree > stty > COLUMNS > tput > 120","compaction_level":0,"original_size":0,"labels":["core","phase-3"]} {"id":"bd-ape","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:11:19.363621Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:21:21.610831Z","closed_at":"2026-02-06T19:21:21.610781Z","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.","compaction_level":0,"original_size":0,"labels":["phase-1","verification"],"dependencies":[{"issue_id":"bd-ape","depends_on_id":"bd-3hc","type":"blocks","created_at":"2026-02-06T18:11:33.279153Z","created_by":"tayloreernisse"}]} -{"id":"bd-khk","title":"Wire --no-cache, --no-shell, --clear-cache CLI flags","description":"## Background\nPRD 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.\n\n## Approach\n1. **--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.\n2. **--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.\n3. **--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.\n\n## Acceptance Criteria\n- [ ] --no-cache causes all cache reads to miss and writes to be skipped\n- [ ] --no-shell causes all shell-outs to be skipped (stale cache served if available)\n- [ ] --clear-cache removes cache dir for current session and exits 0\n- [ ] --clear-cache with nonexistent cache dir exits 0 without error\n- [ ] cargo test passes; cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/bin/claude-statusline.rs (wire flags into Cache and RenderContext)\n- src/cache.rs (add disabled mode)\n- src/section/mod.rs (add no_shell to RenderContext)\n- src/section/vcs.rs, beads.rs, load.rs, hostname.rs, time.rs, custom.rs (check no_shell)\n\n## Edge Cases\n- --clear-cache with no stdin: needs project_dir; consider reading stdin first or scanning /tmp/claude-sl-*\n- --no-shell + --no-cache: only pure sections render, no dynamic data","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-06T20:18:30.201511Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:18:30.201511Z","compaction_level":0,"original_size":0} +{"id":"bd-khk","title":"Wire --no-cache, --no-shell, --clear-cache CLI flags","description":"## Background\nPRD 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.\n\n## Approach\n1. **--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.\n2. **--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.\n3. **--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.\n\n## Acceptance Criteria\n- [ ] --no-cache causes all cache reads to miss and writes to be skipped\n- [ ] --no-shell causes all shell-outs to be skipped (stale cache served if available)\n- [ ] --clear-cache removes cache dir for current session and exits 0\n- [ ] --clear-cache with nonexistent cache dir exits 0 without error\n- [ ] cargo test passes; cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/bin/claude-statusline.rs (wire flags into Cache and RenderContext)\n- src/cache.rs (add disabled mode)\n- src/section/mod.rs (add no_shell to RenderContext)\n- src/section/vcs.rs, beads.rs, load.rs, hostname.rs, time.rs, custom.rs (check no_shell)\n\n## Edge Cases\n- --clear-cache with no stdin: needs project_dir; consider reading stdin first or scanning /tmp/claude-sl-*\n- --no-shell + --no-cache: only pure sections render, no dynamic data","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T20:18:30.201511Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:40:17.812804Z","closed_at":"2026-02-06T20:40:17.812758Z","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)","compaction_level":0,"original_size":0} {"id":"bd-s6n","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.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:11:09.744524Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:16:28.912385Z","closed_at":"2026-02-06T19:16:28.912319Z","close_reason":"Full cache: secure dir, ownership check, per-key TTL, flock, atomic writes","compaction_level":0,"original_size":0,"labels":["core","phase-3"]} {"id":"bd-wdi","title":"src/theme.rs: COLORFGBG detection","description":"Theme enum (Dark/Light). Detection: config override > COLORFGBG env var > default dark.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:25.956848Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.883445Z","closed_at":"2026-02-06T19:08:28.883397Z","close_reason":"Fully implemented during scaffold: Theme enum, COLORFGBG detection","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-wdi","depends_on_id":"bd-1m2","type":"blocks","created_at":"2026-02-06T18:11:30.490706Z","created_by":"tayloreernisse"}]} -{"id":"bd-ywx","title":"Implement shell allow/deny lists and shell_env merge","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Define ShellConfig struct in src/shell.rs**\n```rust\npub struct ShellConfig {\n pub enabled: bool, // global.shell_enabled\n pub allowlist: Vec, // global.shell_allowlist\n pub denylist: Vec, // global.shell_denylist\n pub env: HashMap, // global.shell_env\n pub max_output_bytes: usize, // global.shell_max_output_bytes (default 8192)\n pub timeout: Duration, // global.shell_timeout_ms\n}\n```\n\n**Step 2: Add gated execution function**\n```rust\npub fn exec_gated(\n program: &str,\n args: &[&str],\n dir: Option<&str>,\n shell_config: &ShellConfig,\n) -> Option {\n // 1. Check enabled\n if \\!shell_config.enabled { return None; }\n // 2. Denylist first (wins over allowlist)\n if shell_config.denylist.iter().any(|d| d == program) { return None; }\n // 3. Allowlist (empty = all allowed)\n if \\!shell_config.allowlist.is_empty()\n && \\!shell_config.allowlist.iter().any(|a| a == program) { return None; }\n // 4. Execute with merged env\n let result = exec_with_timeout_env(program, args, dir, shell_config.timeout, &shell_config.env)?;\n // 5. Truncate output\n if shell_config.max_output_bytes > 0 && result.len() > shell_config.max_output_bytes {\n Some(result[..shell_config.max_output_bytes].to_string())\n } else { Some(result) }\n}\n```\n\n**Step 3: Modify exec_with_timeout to accept extra env**\nAdd shell_env to Command::envs() call, layered ON TOP of GIT_ENV (shell_env overrides GIT_ENV).\n\n**Step 4: Add ShellConfig to RenderContext and migrate all sections**\n\n## Acceptance Criteria\n- [ ] New ShellConfig struct holds all shell execution parameters\n- [ ] exec_gated() checks enabled -> denylist -> allowlist -> execute\n- [ ] Denylist takes precedence over allowlist when both contain same command\n- [ ] shell_env vars merged into command environment (layers on GIT_ENV)\n- [ ] Output truncated to max_output_bytes (0 = unlimited)\n- [ ] Empty allowlist means all commands allowed\n- [ ] Skipped commands return None (caller falls back to stale cache)\n- [ ] All shell-out sections migrated to exec_gated\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: tests in src/shell.rs - test_denylist_blocks, test_allowlist_gates, test_env_merge, test_output_truncation, test_denylist_wins_over_allowlist\nGREEN: Implement ShellConfig + exec_gated\nVERIFY: cargo test -- shell\n\n## Files\n- src/shell.rs (add ShellConfig struct, exec_gated function, modify exec_with_timeout for env)\n- src/section/mod.rs (add shell_config to RenderContext)\n- src/bin/claude-statusline.rs (construct ShellConfig from config, pass to ctx)\n- src/section/vcs.rs, beads.rs, load.rs, hostname.rs, time.rs, custom.rs (migrate to exec_gated)\n\n## Edge Cases\n- Allowlist and denylist both contain \"git\": denylist wins\n- max_output_bytes = 0: no truncation\n- shell_env overrides GIT_ENV for same key: intentional (user override)\n- Truncation in middle of UTF-8: truncate at byte boundary, then trim to valid UTF-8","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-06T20:19:22.931468Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:23:17.072084Z","compaction_level":0,"original_size":0} +{"id":"bd-ywx","title":"Implement shell allow/deny lists and shell_env merge","description":"## Background\nPRD 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.\n\n## Approach\n\n**Step 1: Define ShellConfig struct in src/shell.rs**\n```rust\npub struct ShellConfig {\n pub enabled: bool, // global.shell_enabled\n pub allowlist: Vec, // global.shell_allowlist\n pub denylist: Vec, // global.shell_denylist\n pub env: HashMap, // global.shell_env\n pub max_output_bytes: usize, // global.shell_max_output_bytes (default 8192)\n pub timeout: Duration, // global.shell_timeout_ms\n}\n```\n\n**Step 2: Add gated execution function**\n```rust\npub fn exec_gated(\n program: &str,\n args: &[&str],\n dir: Option<&str>,\n shell_config: &ShellConfig,\n) -> Option {\n // 1. Check enabled\n if \\!shell_config.enabled { return None; }\n // 2. Denylist first (wins over allowlist)\n if shell_config.denylist.iter().any(|d| d == program) { return None; }\n // 3. Allowlist (empty = all allowed)\n if \\!shell_config.allowlist.is_empty()\n && \\!shell_config.allowlist.iter().any(|a| a == program) { return None; }\n // 4. Execute with merged env\n let result = exec_with_timeout_env(program, args, dir, shell_config.timeout, &shell_config.env)?;\n // 5. Truncate output\n if shell_config.max_output_bytes > 0 && result.len() > shell_config.max_output_bytes {\n Some(result[..shell_config.max_output_bytes].to_string())\n } else { Some(result) }\n}\n```\n\n**Step 3: Modify exec_with_timeout to accept extra env**\nAdd shell_env to Command::envs() call, layered ON TOP of GIT_ENV (shell_env overrides GIT_ENV).\n\n**Step 4: Add ShellConfig to RenderContext and migrate all sections**\n\n## Acceptance Criteria\n- [ ] New ShellConfig struct holds all shell execution parameters\n- [ ] exec_gated() checks enabled -> denylist -> allowlist -> execute\n- [ ] Denylist takes precedence over allowlist when both contain same command\n- [ ] shell_env vars merged into command environment (layers on GIT_ENV)\n- [ ] Output truncated to max_output_bytes (0 = unlimited)\n- [ ] Empty allowlist means all commands allowed\n- [ ] Skipped commands return None (caller falls back to stale cache)\n- [ ] All shell-out sections migrated to exec_gated\n- [ ] cargo test; cargo clippy --all-targets -- -D warnings\n\n## TDD Loop\nRED: tests in src/shell.rs - test_denylist_blocks, test_allowlist_gates, test_env_merge, test_output_truncation, test_denylist_wins_over_allowlist\nGREEN: Implement ShellConfig + exec_gated\nVERIFY: cargo test -- shell\n\n## Files\n- src/shell.rs (add ShellConfig struct, exec_gated function, modify exec_with_timeout for env)\n- src/section/mod.rs (add shell_config to RenderContext)\n- src/bin/claude-statusline.rs (construct ShellConfig from config, pass to ctx)\n- src/section/vcs.rs, beads.rs, load.rs, hostname.rs, time.rs, custom.rs (migrate to exec_gated)\n\n## Edge Cases\n- Allowlist and denylist both contain \"git\": denylist wins\n- max_output_bytes = 0: no truncation\n- shell_env overrides GIT_ENV for same key: intentional (user override)\n- Truncation in middle of UTF-8: truncate at byte boundary, then trim to valid UTF-8","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-06T20:19:22.931468Z","created_by":"tayloreernisse","updated_at":"2026-02-06T20:44:37.421763Z","closed_at":"2026-02-06T20:44:37.421716Z","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","compaction_level":0,"original_size":0} diff --git a/defaults.json b/defaults.json index 2034597..02c7742 100644 --- a/defaults.json +++ b/defaults.json @@ -4,7 +4,7 @@ "separator": " | ", "justify": "space-between", "vcs": "auto", - "cache_dir": "/tmp/claude-sl-{session_id}", + "cache_dir": "/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}", "responsive": true, "breakpoints": { "narrow": 60, diff --git a/src/bin/claude-statusline.rs b/src/bin/claude-statusline.rs index f0ff3c8..1cf2658 100644 --- a/src/bin/claude-statusline.rs +++ b/src/bin/claude-statusline.rs @@ -1,6 +1,11 @@ use claude_statusline::section::RenderContext; -use claude_statusline::{cache, color, config, input, layout, metrics, section, theme, width}; +use claude_statusline::shell::{self, ShellConfig}; +use claude_statusline::{ + cache, color, config, format as sl_format, input, layout, metrics, section, theme, width, +}; +use std::collections::HashMap; use std::io::Read; +use std::time::Duration; fn main() { let args: Vec = std::env::args().collect(); @@ -18,12 +23,27 @@ fn main() { return; } if args.iter().any(|a| a == "--list-sections") { - for (id, _) in section::registry() { - println!("{id}"); + println!("{:<22} pri flex shell est_w", "ID"); + println!("{}", "-".repeat(58)); + for desc in section::registry() { + println!( + "{:<22} {:<4} {:<5} {:<6} {}", + desc.id, + desc.priority, + if desc.is_flex { "yes" } else { "-" }, + if desc.shell_out { "yes" } else { "-" }, + desc.estimated_width, + ); } return; } + 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 cli_color = args .iter() .find_map(|a| a.strip_prefix("--color=")) @@ -57,7 +77,7 @@ fn main() { }); // Load config - let (config, warnings) = match config::load_config(config_path) { + let (config, warnings, config_hash) = match config::load_config(config_path) { Ok(v) => v, Err(e) => { eprintln!("claude-statusline: {e}"); @@ -102,8 +122,8 @@ fn main() { // 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 (term_width, width_source) = + width::detect_width_with_source(cli_width, config.global.width, config.global.width_margin); let tier = width::width_tier( term_width, config.global.breakpoints.narrow, @@ -117,31 +137,63 @@ fn main() { .unwrap_or("."); let session = cache::session_id(project_dir); - let cache = cache::Cache::new(&config.global.cache_dir, &session); + + // --clear-cache: remove cache directory and exit + if clear_cache { + let dir_str = config + .global + .cache_dir + .replace("{session_id}", &session) + .replace("{cache_version}", &config.global.cache_version.to_string()) + .replace("{config_hash}", &config_hash); + let dir = std::path::Path::new(&dir_str); + if dir.exists() { + if let Err(e) = std::fs::remove_dir_all(dir) { + eprintln!("claude-statusline: clear-cache: {e}"); + std::process::exit(1); + } + } + std::process::exit(0); + } + + let cache = if no_cache { + cache::Cache::disabled() + } else { + cache::Cache::new( + &config.global.cache_dir, + &session, + config.global.cache_version, + &config_hash, + config.global.cache_ttl_jitter_pct, + ) + }; + + let shell_config = ShellConfig { + enabled: config.global.shell_enabled && !no_shell, + allowlist: config.global.shell_allowlist.clone(), + denylist: config.global.shell_denylist.clone(), + timeout: Duration::from_millis(config.global.shell_timeout_ms), + max_output_bytes: config.global.shell_max_output_bytes, + env: config.global.shell_env.clone(), + failure_threshold: config.global.shell_failure_threshold, + cooldown_ms: config.global.shell_cooldown_ms, + }; 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 computed_metrics = metrics::ComputedMetrics::from_input(&input_data); + // Prefetch shell-outs in parallel (only for cache-miss commands) + let shell_results = if !no_shell && shell_config.enabled { + prefetch_shell_outs(&shell_config, &cache, vcs_type, project_dir, &config) + } else { + std::collections::HashMap::new() + }; + let ctx = RenderContext { input: &input_data, config: &config, @@ -153,11 +205,161 @@ fn main() { cache: &cache, glyphs_enabled: config.glyphs.enabled, color_enabled, + no_shell, + shell_config: &shell_config, metrics: computed_metrics, + budget_start: if config.global.render_budget_ms > 0 { + Some(std::time::Instant::now()) + } else { + None + }, + budget_ms: config.global.render_budget_ms, + shell_results, }; + // Handle --dump-state (after building ctx so we can collect diagnostics) + if let Some(format) = dump_state { + dump_state_output( + format, + &config, + term_width, + width_source, + tier, + detected_theme, + vcs_type, + project_dir, + &session, + &ctx, + ); + return; + } + let output = layout::render_all(&ctx); print!("{output}"); + + // Cache GC: run after output, never blocks status line + if !no_cache { + cache::gc( + config.global.cache_gc_days, + config.global.cache_gc_interval_hours, + ); + } +} + +/// Prefetch shell-out results in parallel using std::thread::scope. +/// Only spawns threads for commands whose cache has expired. +/// Returns results keyed by section name. +fn prefetch_shell_outs( + shell_config: &ShellConfig, + cache: &cache::Cache, + vcs_type: section::VcsType, + project_dir: &str, + config: &config::Config, +) -> HashMap> { + let mut results = HashMap::new(); + + // Check which shell-outs need refreshing + let vcs_ttl = Duration::from_secs(config.sections.vcs.ttl.branch); + let needs_vcs = vcs_type != section::VcsType::None + && config.sections.vcs.base.enabled + && cache.get("vcs_branch", vcs_ttl).is_none(); + + let load_ttl = Duration::from_secs(config.sections.load.ttl); + let needs_load = config.sections.load.base.enabled && cache.get("load_avg", load_ttl).is_none(); + + let beads_ttl = Duration::from_secs(config.sections.beads.ttl); + let needs_beads = config.sections.beads.base.enabled + && std::path::Path::new(project_dir).join(".beads").is_dir() + && cache.get("beads_summary", beads_ttl).is_none(); + + // If nothing needs refreshing, skip thread::scope entirely + if !needs_vcs && !needs_load && !needs_beads { + return results; + } + + std::thread::scope(|s| { + let vcs_handle = if needs_vcs { + let args: Vec = match vcs_type { + section::VcsType::Git => vec![ + "git".into(), + "-C".into(), + project_dir.into(), + "status".into(), + "--porcelain=v2".into(), + "--branch".into(), + ], + section::VcsType::Jj => vec![ + "jj".into(), + "log".into(), + "-r".into(), + "@".into(), + "--no-graph".into(), + "-T".into(), + "if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))".into(), + "--color=never".into(), + ], + section::VcsType::None => vec![], + }; + if args.is_empty() { + None + } else { + let dir = if vcs_type == section::VcsType::Jj { + Some(project_dir.to_string()) + } else { + None + }; + Some(s.spawn(move || { + let prog = &args[0]; + let str_args: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect(); + shell::exec_gated(shell_config, prog, &str_args, dir.as_deref()) + })) + } + } else { + None + }; + + let load_handle = if needs_load { + Some(s.spawn(|| { + #[cfg(target_os = "macos")] + { + shell::exec_gated(shell_config, "sysctl", &["-n", "vm.loadavg"], None) + } + #[cfg(target_os = "linux")] + { + std::fs::read_to_string("/proc/loadavg") + .ok() + .and_then(|c| c.split_whitespace().next().map(|s| s.to_string())) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + None + } + })) + } else { + None + }; + + let beads_handle = if needs_beads { + let dir = project_dir.to_string(); + Some(s.spawn(move || { + shell::exec_gated(shell_config, "br", &["ready", "--json"], Some(&dir)) + })) + } else { + None + }; + + if let Some(h) = vcs_handle { + results.insert("vcs".into(), h.join().ok().flatten()); + } + if let Some(h) = load_handle { + results.insert("load".into(), h.join().ok().flatten()); + } + if let Some(h) = beads_handle { + results.insert("beads".into(), h.join().ok().flatten()); + } + }); + + results } fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType { @@ -196,24 +398,74 @@ fn dump_state_output( format: &str, config: &config::Config, term_width: u16, + width_source: &str, tier: width::WidthTier, theme: theme::Theme, vcs: section::VcsType, project_dir: &str, session_id: &str, + ctx: &RenderContext, ) { + // Render all sections with per-section timing + let layout_lines = layout::resolve_layout(ctx.config, ctx.term_width); + let registry = section::registry(); + let mut section_timings: Vec = Vec::new(); + + for line_ids in &layout_lines { + for id in line_ids { + if section::is_spacer(id) { + continue; + } + let start = std::time::Instant::now(); + let output = section::render_section(id, ctx); + let elapsed_us = start.elapsed().as_micros() as u64; + + let priority = registry + .iter() + .find(|d| d.id == id) + .map_or(2, |d| d.priority); + + section_timings.push(serde_json::json!({ + "id": id, + "render_us": elapsed_us, + "rendered": output.is_some(), + "priority": priority, + "raw_width": output.as_ref().map(|o| sl_format::display_width(&o.raw)).unwrap_or(0), + })); + } + } + + // Collect cache diagnostics + let cache_diags: Vec = ctx + .cache + .diagnostics() + .into_iter() + .map(|d| { + serde_json::json!({ + "key": d.key, + "hit": d.hit, + "age_ms": d.age_ms, + }) + }) + .collect(); + let json = serde_json::json!({ "terminal": { "effective_width": term_width, "width_margin": config.global.width_margin, "width_tier": format!("{tier:?}"), + "source": width_source, }, "theme": theme.as_str(), "vcs": format!("{vcs:?}"), "layout": { "justify": format!("{:?}", config.global.justify), "separator": &config.global.separator, + "drop_strategy": &config.global.drop_strategy, + "render_budget_ms": config.global.render_budget_ms, }, + "sections": section_timings, + "cache": cache_diags, "paths": { "project_dir": project_dir, "cache_dir": config.global.cache_dir.replace("{session_id}", session_id), diff --git a/src/cache.rs b/src/cache.rs index 3433a5e..fa9325b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,21 +1,56 @@ +use std::cell::RefCell; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; +/// Diagnostic entry for a single cache lookup. +#[derive(Debug, Clone)] +pub struct CacheDiag { + pub key: String, + pub hit: bool, + pub age_ms: Option, +} + pub struct Cache { dir: Option, + jitter_pct: u8, + diagnostics: RefCell>, } impl Cache { + /// Create a disabled cache where all operations are no-ops. + /// Used for --no-cache mode. + pub fn disabled() -> Self { + Self { + dir: None, + jitter_pct: 0, + diagnostics: RefCell::new(Vec::new()), + } + } + /// 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); + /// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template. + pub fn new( + template: &str, + session_id: &str, + cache_version: u32, + config_hash: &str, + jitter_pct: u8, + ) -> Self { + let dir_str = template + .replace("{session_id}", session_id) + .replace("{cache_version}", &cache_version.to_string()) + .replace("{config_hash}", config_hash); let dir = PathBuf::from(&dir_str); if !dir.exists() { if fs::create_dir_all(&dir).is_err() { - return Self { dir: None }; + return Self { + dir: None, + jitter_pct, + diagnostics: RefCell::new(Vec::new()), + }; } #[cfg(unix)] { @@ -26,29 +61,93 @@ impl Cache { // Security: verify ownership, not a symlink, not world-writable if !verify_cache_dir(&dir) { - return Self { dir: None }; + return Self { + dir: None, + jitter_pct, + diagnostics: RefCell::new(Vec::new()), + }; } - Self { dir: Some(dir) } + Self { + dir: Some(dir), + jitter_pct, + diagnostics: RefCell::new(Vec::new()), + } } pub fn dir(&self) -> Option<&Path> { self.dir.as_deref() } - /// Get cached value if fresher than TTL. + /// Get cached value if fresher than TTL (with per-key jitter applied). pub fn get(&self, key: &str, ttl: Duration) -> Option { - let path = self.key_path(key)?; - let meta = fs::metadata(&path).ok()?; + let path = match self.key_path(key) { + Some(p) => p, + None => { + self.record_diag(key, false, None); + return None; + } + }; + let meta = match fs::metadata(&path).ok() { + Some(m) => m, + None => { + self.record_diag(key, false, None); + return None; + } + }; let modified = meta.modified().ok()?; let age = SystemTime::now().duration_since(modified).ok()?; - if age < ttl { - fs::read_to_string(&path).ok() + let age_ms = age.as_millis() as u64; + let effective_ttl = self.jittered_ttl(key, ttl); + if age < effective_ttl { + let value = fs::read_to_string(&path).ok(); + self.record_diag(key, value.is_some(), Some(age_ms)); + value } else { + self.record_diag(key, false, Some(age_ms)); None } } + fn record_diag(&self, key: &str, hit: bool, age_ms: Option) { + if let Ok(mut diags) = self.diagnostics.try_borrow_mut() { + diags.push(CacheDiag { + key: key.to_string(), + hit, + age_ms, + }); + } + } + + /// Return collected cache diagnostics (for --dump-state). + pub fn diagnostics(&self) -> Vec { + self.diagnostics.borrow().clone() + } + + /// Apply deterministic per-key jitter to TTL. + /// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time). + fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration { + if self.jitter_pct == 0 { + return base_ttl; + } + + // FNV-1a hash of key for deterministic per-key jitter + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in key.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0100_0000_01b3); + } + + // Map hash to range [-jitter_pct, +jitter_pct] + let jitter_range = f64::from(self.jitter_pct) / 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)) + } + /// Get stale cached value (ignores TTL). Used as fallback on command failure. pub fn get_stale(&self, key: &str) -> Option { let path = self.key_path(key)?; @@ -152,3 +251,94 @@ pub fn session_id(project_dir: &str) -> String { let hash = Md5::digest(project_dir.as_bytes()); format!("{:x}", hash)[..12].to_string() } + +/// Garbage-collect old cache directories. +/// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days` +/// that match /tmp/claude-sl-* and are owned by the current user. +/// Never blocks: uses non-blocking flock on a sentinel file. +pub fn gc(gc_days: u16, gc_interval_hours: u16) { + let lock_path = Path::new("/tmp/claude-sl-gc.lock"); + + // Check interval: if lock file exists and is younger than gc_interval, skip + if let Ok(meta) = fs::metadata(lock_path) { + if let Ok(modified) = meta.modified() { + if let Ok(age) = SystemTime::now().duration_since(modified) { + if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) { + return; + } + } + } + } + + // Try non-blocking lock + let lock_file = match fs::File::create(lock_path) { + Ok(f) => f, + Err(_) => return, + }; + if !try_flock(&lock_file) { + return; // another process is GC-ing + } + + // Touch the lock file (create already set mtime to now) + let max_age = Duration::from_secs(u64::from(gc_days) * 86400); + + let entries = match fs::read_dir("/tmp") { + Ok(e) => e, + Err(_) => { + unlock(&lock_file); + return; + } + }; + + let uid = current_uid(); + + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.starts_with("claude-sl-") { + continue; + } + + let path = entry.path(); + + // Safety: skip symlinks + let meta = match fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_dir() || meta.file_type().is_symlink() { + continue; + } + + // Only delete dirs owned by current user + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if meta.uid() != uid { + continue; + } + } + + // Check age + if let Ok(modified) = meta.modified() { + if let Ok(age) = SystemTime::now().duration_since(modified) { + if age > max_age { + let _ = fs::remove_dir_all(&path); + } + } + } + } + + unlock(&lock_file); +} + +fn current_uid() -> u32 { + #[cfg(unix)] + { + unsafe { libc::getuid() } + } + #[cfg(not(unix))] + { + 0 + } +} diff --git a/src/color.rs b/src/color.rs index e0ad97a..1693186 100644 --- a/src/color.rs +++ b/src/color.rs @@ -49,6 +49,7 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String } /// Determine whether color output should be used. +/// Precedence: NO_COLOR > --color= CLI flag > CLAUDE_STATUSLINE_COLOR env > config pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool { if std::env::var("NO_COLOR").is_ok() { return false; @@ -62,6 +63,14 @@ pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::C }; } + if let Ok(env_color) = std::env::var("CLAUDE_STATUSLINE_COLOR") { + return match env_color.as_str() { + "always" => true, + "never" => false, + _ => atty_stdout(), + }; + } + match config_color { crate::config::ColorMode::Always => true, crate::config::ColorMode::Never => false, diff --git a/src/config.rs b/src/config.rs index 02f57ee..f2ba0d1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -128,6 +128,7 @@ pub enum ColorMode { pub struct Breakpoints { pub narrow: u16, pub medium: u16, + pub hysteresis: u16, } impl Default for Breakpoints { @@ -135,6 +136,7 @@ impl Default for Breakpoints { Self { narrow: 60, medium: 100, + hysteresis: 2, } } } @@ -660,7 +662,11 @@ pub fn deep_merge(base: &mut Value, patch: &Value) { // ── Config loading ────────────────────────────────────────────────────── /// Load config: embedded defaults deep-merged with user overrides. -pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec), crate::Error> { +/// Returns (Config, warnings, config_hash) where config_hash is 8-char hex MD5 +/// of the merged JSON (for cache namespace invalidation on config change). +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 @@ -687,12 +693,24 @@ pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec), )); } + // Compute config hash from merged JSON before deserialize consumes it + let config_hash = compute_config_hash(&base); + let mut warnings = Vec::new(); let config: Config = serde_ignored::deserialize(base, |path| { warnings.push(format!("unknown config key: {path}")); })?; - Ok((config, warnings)) + Ok((config, warnings, config_hash)) +} + +/// MD5 of the merged JSON value, truncated to 8 hex chars. +/// Deterministic: serde_json produces stable output for the same Value. +fn compute_config_hash(merged: &Value) -> String { + use md5::{Digest, Md5}; + let json_bytes = serde_json::to_string(merged).unwrap_or_default(); + let hash = Md5::digest(json_bytes.as_bytes()); + format!("{:x}", hash)[..8].to_string() } fn xdg_config_path() -> Option { diff --git a/src/layout/mod.rs b/src/layout/mod.rs index b40d2a0..88c66d6 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -36,12 +36,64 @@ pub fn resolve_layout(config: &Config, term_width: u16) -> Vec> { } fn responsive_preset(width: u16, bp: &crate::config::Breakpoints) -> &'static str { - if width < bp.narrow { + use std::sync::Mutex; + + static LAST_PRESET: Mutex> = Mutex::new(None); + + let simple = if width < bp.narrow { "dense" } else if width < bp.medium { "standard" } else { "verbose" + }; + + let hysteresis = bp.hysteresis; + if hysteresis == 0 { + return simple; + } + + let guard = LAST_PRESET.lock().unwrap_or_else(|e| e.into_inner()); + let prev = *guard; + drop(guard); + + let result = match prev { + Some(prev) => { + // Check if width is within hysteresis zone of a breakpoint + let in_narrow_zone = + width >= bp.narrow.saturating_sub(hysteresis) && width < bp.narrow + hysteresis; + let in_medium_zone = + width >= bp.medium.saturating_sub(hysteresis) && width < bp.medium + hysteresis; + + if (in_narrow_zone || in_medium_zone) && is_valid_preset(prev, width, bp, hysteresis) { + prev + } else { + simple + } + } + None => simple, + }; + + let mut guard = LAST_PRESET.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(result); + + result +} + +/// Check if a previous preset is still valid given the current width. +fn is_valid_preset( + preset: &str, + width: u16, + bp: &crate::config::Breakpoints, + hysteresis: u16, +) -> bool { + match preset { + "dense" => width < bp.narrow + hysteresis, + "standard" => { + width >= bp.narrow.saturating_sub(hysteresis) && width < bp.medium + hysteresis + } + "verbose" => width >= bp.medium.saturating_sub(hysteresis), + _ => false, } } @@ -65,6 +117,14 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) -> let mut active: Vec = Vec::new(); for id in section_ids { + // Budget check: skip non-priority-1 sections when over budget + if ctx.budget_exceeded() { + let (prio, _) = section_meta(id, ctx.config); + if prio > 1 { + continue; + } + } + if let Some(mut output) = section::render_section(id, ctx) { if output.raw.is_empty() && !section::is_spacer(id) { continue; @@ -97,7 +157,12 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) -> } // Phase 2: Priority drop if overflowing - let mut active = priority::priority_drop(active, ctx.term_width, separator); + let mut active = priority::drop_sections( + active, + ctx.term_width, + separator, + &ctx.config.global.drop_strategy, + ); // Phase 3: Flex expand or justify let line = if ctx.config.global.justify != JustifyMode::Left @@ -111,6 +176,9 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) -> ctx.config.global.justify, ctx.color_enabled, ) + } else if ctx.budget_exceeded() { + // Over budget: skip flex expansion, emit as-is + assemble_left(&active, separator, ctx.color_enabled) } else { flex::flex_expand(&mut active, ctx, separator); assemble_left(&active, separator, ctx.color_enabled) diff --git a/src/layout/priority.rs b/src/layout/priority.rs index bbd11b2..31d4d69 100644 --- a/src/layout/priority.rs +++ b/src/layout/priority.rs @@ -1,9 +1,22 @@ use crate::format; use crate::layout::ActiveSection; -/// Drop priority 3 sections (all at once), then priority 2, until line fits. +/// Dispatch: select drop strategy by name. +pub fn drop_sections( + active: Vec, + term_width: u16, + separator: &str, + strategy: &str, +) -> Vec { + match strategy { + "gradual" => gradual_drop(active, term_width, separator), + _ => tiered_drop(active, term_width, separator), + } +} + +/// Tiered: drop all priority 3 sections at once, then all priority 2. /// Priority 1 sections never drop. -pub fn priority_drop( +fn tiered_drop( mut active: Vec, term_width: u16, separator: &str, @@ -23,9 +36,42 @@ pub fn priority_drop( active } +/// Gradual: drop sections one-by-one. +/// Order: highest priority number first, then widest, then rightmost. +/// Priority 1 sections never drop. +fn gradual_drop( + mut active: Vec, + term_width: u16, + separator: &str, +) -> Vec { + while line_width(&active, separator) > term_width as usize { + // Find the best candidate to drop + let candidate = active + .iter() + .enumerate() + .filter(|(_, s)| s.priority > 1) // never drop priority 1 + .max_by_key(|(idx, s)| { + ( + s.priority, + format::display_width(&s.output.raw), + *idx, // rightmost wins ties + ) + }) + .map(|(idx, _)| idx); + + match candidate { + Some(idx) => { + active.remove(idx); + } + None => break, // only priority 1 left + } + } + active +} + /// Calculate total display width including separators. /// Spacers suppress adjacent separators on both sides. -fn line_width(active: &[ActiveSection], separator: &str) -> usize { +pub fn line_width(active: &[ActiveSection], separator: &str) -> usize { let sep_w = format::display_width(separator); let mut total = 0; diff --git a/src/section/beads.rs b/src/section/beads.rs index a7fdfcb..fdf7649 100644 --- a/src/section/beads.rs +++ b/src/section/beads.rs @@ -13,18 +13,24 @@ pub fn render(ctx: &RenderContext) -> Option { return None; } + // --no-shell: serve stale cache only + if ctx.no_shell { + return render_from_cache(ctx, ctx.cache.get_stale("beads_summary")?); + } + let ttl = Duration::from_secs(ctx.config.sections.beads.ttl); - let timeout = Duration::from_millis(200); let cached = ctx.cache.get("beads_summary", ttl); let summary = cached.or_else(|| { - // Run br ready to get count of ready items - let out = shell::exec_with_timeout( - "br", - &["ready", "--json"], - Some(ctx.project_dir.to_str()?), - timeout, - )?; + // Use prefetched result if available, otherwise exec + let out = ctx.shell_results.get("beads").cloned().unwrap_or_else(|| { + shell::exec_gated( + ctx.shell_config, + "br", + &["ready", "--json"], + Some(ctx.project_dir.to_str()?), + ) + })?; // Count JSON array items (simple: count opening braces at indent level 1) let count = out.matches("\"id\"").count(); let summary = format!("{count}"); @@ -46,3 +52,17 @@ pub fn render(ctx: &RenderContext) -> Option { Some(SectionOutput { raw, ansi }) } + +fn render_from_cache(ctx: &RenderContext, summary: String) -> Option { + let count: usize = summary.trim().parse().unwrap_or(0); + if count == 0 { + return None; + } + let raw = format!("{count} ready"); + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/custom.rs b/src/section/custom.rs index 0d540e7..7d6eab0 100644 --- a/src/section/custom.rs +++ b/src/section/custom.rs @@ -1,4 +1,5 @@ use crate::color; +use crate::config::CustomCommand; use crate::section::{RenderContext, SectionOutput}; use crate::shell; use std::time::Duration; @@ -7,10 +8,16 @@ use std::time::Duration; pub fn render(id: &str, ctx: &RenderContext) -> Option { let cmd_cfg = ctx.config.custom.iter().find(|c| c.id == id)?; - let ttl = Duration::from_secs(cmd_cfg.ttl); - let timeout = Duration::from_millis(ctx.config.global.shell_timeout_ms); let cache_key = format!("custom_{id}"); + // --no-shell: serve stale cache only + if ctx.no_shell { + let output_str = ctx.cache.get_stale(&cache_key)?; + return render_output(ctx, cmd_cfg, &output_str); + } + + let ttl = Duration::from_secs(cmd_cfg.ttl); + let cached = ctx.cache.get(&cache_key, ttl); let output_str = cached.or_else(|| { let result = if let Some(ref exec) = cmd_cfg.exec { @@ -18,9 +25,9 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option { return None; } let args: Vec<&str> = exec[1..].iter().map(|s| s.as_str()).collect(); - shell::exec_with_timeout(&exec[0], &args, None, timeout) + shell::exec_gated(ctx.shell_config, &exec[0], &args, None) } else if let Some(ref command) = cmd_cfg.command { - shell::exec_with_timeout("sh", &["-c", command], None, timeout) + shell::exec_gated(ctx.shell_config, "sh", &["-c", command], None) } else { None }; @@ -34,16 +41,29 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option { return None; } + render_output(ctx, cmd_cfg, &output_str) +} + +/// Shared rendering: label + color logic used by both live and stale-cache paths. +fn render_output( + ctx: &RenderContext, + cmd_cfg: &CustomCommand, + output_str: &str, +) -> Option { + if output_str.is_empty() { + return None; + } + let label = cmd_cfg.label.as_deref().unwrap_or(""); let raw = if label.is_empty() { - output_str.clone() + output_str.to_string() } else { format!("{label}: {output_str}") }; let ansi = if ctx.color_enabled { if let Some(ref color_cfg) = cmd_cfg.color { - if let Some(matched_color) = color_cfg.match_map.get(&output_str) { + if let Some(matched_color) = color_cfg.match_map.get(output_str) { let c = color::resolve_color(matched_color, ctx.theme, &ctx.config.colors); format!("{c}{raw}{}", color::RESET) } else if let Some(ref default_c) = cmd_cfg.default_color { diff --git a/src/section/load.rs b/src/section/load.rs index cb6c970..d902663 100644 --- a/src/section/load.rs +++ b/src/section/load.rs @@ -7,6 +7,18 @@ pub fn render(ctx: &RenderContext) -> Option { return None; } + // --no-shell: serve stale cache only + if ctx.no_shell { + let load_str = ctx.cache.get_stale("load_avg")?; + let raw = format!("load {load_str}"); + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + return Some(SectionOutput { raw, ansi }); + } + let ttl = Duration::from_secs(ctx.config.sections.load.ttl); let cached = ctx.cache.get("load_avg", ttl); @@ -21,12 +33,10 @@ pub fn render(ctx: &RenderContext) -> Option { } #[cfg(target_os = "macos")] { - let out = crate::shell::exec_with_timeout( - "sysctl", - &["-n", "vm.loadavg"], - None, - Duration::from_millis(100), - )?; + // Use prefetched result if available, otherwise exec + let out = ctx.shell_results.get("load").cloned().unwrap_or_else(|| { + crate::shell::exec_gated(ctx.shell_config, "sysctl", &["-n", "vm.loadavg"], None) + })?; // sysctl output: "{ 1.23 4.56 7.89 }" let load1 = out .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.') diff --git a/src/section/mod.rs b/src/section/mod.rs index 6cd92fd..d1208a1 100644 --- a/src/section/mod.rs +++ b/src/section/mod.rs @@ -2,6 +2,7 @@ use crate::cache::Cache; use crate::config::Config; use crate::input::InputData; use crate::metrics::ComputedMetrics; +use crate::shell::ShellConfig; use crate::theme::Theme; use crate::width::WidthTier; @@ -62,36 +63,259 @@ pub struct RenderContext<'a> { pub cache: &'a Cache, pub glyphs_enabled: bool, pub color_enabled: bool, + pub no_shell: bool, + pub shell_config: &'a ShellConfig, pub metrics: ComputedMetrics, + pub budget_start: Option, + pub budget_ms: u64, + pub shell_results: std::collections::HashMap>, } -/// Build the registry of all built-in sections. -pub fn registry() -> Vec<(&'static str, RenderFn)> { +impl RenderContext<'_> { + /// Check if the render budget has been exceeded. + /// Returns false if budget_ms == 0 (unlimited) or budget_start is None. + pub fn budget_exceeded(&self) -> bool { + if self.budget_ms == 0 { + return false; + } + if let Some(start) = self.budget_start { + start.elapsed().as_millis() as u64 >= self.budget_ms + } else { + false + } + } +} + +/// Metadata for layout planning, CLI introspection, and render budgeting. +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 with metadata. +pub fn registry() -> Vec { vec![ - ("model", model::render), - ("provider", provider::render), - ("project", project::render), - ("vcs", vcs::render), - ("beads", beads::render), - ("context_bar", context_bar::render), - ("context_usage", context_usage::render), - ("context_remaining", context_remaining::render), - ("tokens_raw", tokens_raw::render), - ("cache_efficiency", cache_efficiency::render), - ("cost", cost::render), - ("cost_velocity", cost_velocity::render), - ("token_velocity", token_velocity::render), - ("cost_trend", cost_trend::render), - ("context_trend", context_trend::render), - ("lines_changed", lines_changed::render), - ("duration", duration::render), - ("tools", tools::render), - ("turns", turns::render), - ("load", load::render), - ("version", version::render), - ("time", time::render), - ("output_style", output_style::render), - ("hostname", hostname::render), + SectionDescriptor { + id: "model", + render: model::render, + priority: 1, + 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: 3, + 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: 3, + is_spacer: false, + is_flex: false, + estimated_width: 10, + shell_out: false, + }, + SectionDescriptor { + id: "token_velocity", + render: token_velocity::render, + priority: 3, + 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: false, + }, + 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: false, + }, ] } @@ -104,9 +328,9 @@ pub fn render_section(id: &str, ctx: &RenderContext) -> Option { }); } - for (name, render_fn) in registry() { - if name == id { - return render_fn(ctx); + for desc in registry() { + if desc.id == id { + return (desc.render)(ctx); } } diff --git a/src/section/vcs.rs b/src/section/vcs.rs index f3bab04..d1bc528 100644 --- a/src/section/vcs.rs +++ b/src/section/vcs.rs @@ -3,7 +3,6 @@ use crate::glyph; use crate::section::{RenderContext, SectionOutput, VcsType}; use crate::shell::{self, GitStatusV2}; use crate::width::WidthTier; -use std::time::Duration; pub fn render(ctx: &RenderContext) -> Option { if !ctx.config.sections.vcs.base.enabled { @@ -13,6 +12,11 @@ pub fn render(ctx: &RenderContext) -> Option { return None; } + // --no-shell: serve stale cache only, skip all git/jj commands + if ctx.no_shell { + return render_stale_cache(ctx); + } + let dir = ctx.project_dir.to_str()?; let ttl = &ctx.config.sections.vcs.ttl; let glyphs = &ctx.config.glyphs; @@ -24,28 +28,45 @@ pub fn render(ctx: &RenderContext) -> Option { } } +/// Serve stale cached VCS data without running any commands. +fn render_stale_cache(ctx: &RenderContext) -> Option { + let branch = ctx.cache.get_stale("vcs_branch")?; + let branch_glyph = glyph::glyph("branch", &ctx.config.glyphs); + let raw = format!("{branch_glyph}{branch}"); + let ansi = if ctx.color_enabled { + format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET) + } else { + raw.clone() + }; + Some(SectionOutput { raw, ansi }) +} + fn render_git( ctx: &RenderContext, dir: &str, ttl: &crate::config::VcsTtl, glyphs: &crate::config::GlyphConfig, ) -> Option { + use std::time::Duration; + 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); 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() { - let output = shell::exec_with_timeout( - "git", - &["-C", dir, "status", "--porcelain=v2", "--branch"], - None, - timeout, - ); + // Use prefetched result if available, otherwise exec + let output = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| { + shell::exec_gated( + ctx.shell_config, + "git", + &["-C", dir, "status", "--porcelain=v2", "--branch"], + None, + ) + }); match output { Some(ref out) => { let s = shell::parse_git_status_v2(out); @@ -138,24 +159,33 @@ fn render_jj( ttl: &crate::config::VcsTtl, glyphs: &crate::config::GlyphConfig, ) -> Option { + use std::time::Duration; + 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", - ], - Some(dir), - timeout, - )?; + // Use prefetched result if available + let out = ctx + .shell_results + .get("vcs") + .cloned() + .flatten() + .or_else(|| { + shell::exec_gated( + ctx.shell_config, + "jj", + &[ + "log", + "-r", + "@", + "--no-graph", + "-T", + "if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))", + "--color=never", + ], + Some(dir), + ) + })?; ctx.cache.set("vcs_branch", &out); Some(out) })?; diff --git a/src/shell.rs b/src/shell.rs index 81eb76f..87632e0 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -8,6 +9,166 @@ const GIT_ENV: &[(&str, &str)] = &[ ("LC_ALL", "C"), ]; +/// Shell execution configuration for gated access. +pub struct ShellConfig { + pub enabled: bool, + pub allowlist: Vec, + pub denylist: Vec, + pub timeout: Duration, + pub max_output_bytes: usize, + pub env: HashMap, + pub failure_threshold: u8, + pub cooldown_ms: u64, +} + +// ── Circuit breaker ───────────────────────────────────────────────────── + +use std::sync::Mutex; + +struct CircuitState { + failures: u8, + cooldown_until: Option, +} + +static BREAKER: Mutex>> = Mutex::new(None); + +fn circuit_check(program: &str, threshold: u8) -> bool { + if threshold == 0 { + return true; // disabled + } + let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner()); + let map = guard.get_or_insert_with(HashMap::new); + + if let Some(state) = map.get(program) { + if let Some(until) = state.cooldown_until { + if Instant::now() < until { + return false; // in cooldown + } + // Cooldown expired: allow retry + map.remove(program); + } + } + true +} + +fn circuit_record_success(program: &str) { + let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(map) = guard.as_mut() { + map.remove(program); + } +} + +fn circuit_record_failure(program: &str, threshold: u8, cooldown_ms: u64) { + if threshold == 0 { + return; + } + let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner()); + let map = guard.get_or_insert_with(HashMap::new); + let state = map.entry(program.to_string()).or_insert(CircuitState { + failures: 0, + cooldown_until: None, + }); + state.failures = state.failures.saturating_add(1); + if state.failures >= threshold { + state.cooldown_until = Some(Instant::now() + Duration::from_millis(cooldown_ms)); + } +} + +/// Execute a command through the gated shell pipeline. +/// Checks: enabled -> denylist -> allowlist -> execute with env merge -> truncate. +/// Returns None if gated out (caller falls back to stale cache). +pub fn exec_gated( + config: &ShellConfig, + program: &str, + args: &[&str], + dir: Option<&str>, +) -> Option { + // 1. Global kill switch + if !config.enabled { + return None; + } + // 2. Denylist (wins over allowlist) + if config.denylist.iter().any(|d| d == program) { + return None; + } + // 3. Allowlist (empty = all allowed) + if !config.allowlist.is_empty() && !config.allowlist.iter().any(|a| a == program) { + return None; + } + // 4. Circuit breaker check + if !circuit_check(program, config.failure_threshold) { + return None; + } + // 5. Execute with merged env + let result = exec_with_timeout_env(program, args, dir, config.timeout, &config.env); + match result { + Some(ref output) => { + circuit_record_success(program); + // 6. Truncate output + if config.max_output_bytes > 0 && output.len() > config.max_output_bytes { + let truncated = &output.as_bytes()[..config.max_output_bytes]; + Some(String::from_utf8_lossy(truncated).into_owned()) + } else { + Some(output.clone()) + } + } + None => { + circuit_record_failure(program, config.failure_threshold, config.cooldown_ms); + None + } + } +} + +/// Execute with timeout and optional extra env vars. +fn exec_with_timeout_env( + program: &str, + args: &[&str], + dir: Option<&str>, + timeout: Duration, + extra_env: &HashMap, +) -> Option { + let mut cmd = Command::new(program); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::null()); + + if let Some(d) = dir { + cmd.current_dir(d); + } + + if program == "git" { + for (k, v) in GIT_ENV { + cmd.env(k, v); + } + } + + // shell_env overrides GIT_ENV for same key (intentional: user override) + for (k, v) in extra_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; + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => return None, + } + } +} + /// Execute a command with a polling timeout. Returns None on timeout or error. pub fn exec_with_timeout( program: &str, diff --git a/src/width.rs b/src/width.rs index f192e23..3773fb1 100644 --- a/src/width.rs +++ b/src/width.rs @@ -24,16 +24,25 @@ pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier { /// Detect terminal width. Memoized for 1 second across renders. /// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120 pub fn detect_width(cli_width: Option, config_width: Option, config_margin: u16) -> u16 { + detect_width_with_source(cli_width, config_width, config_margin).0 +} + +/// Like detect_width but also returns the detection source name (for --dump-state). +pub fn detect_width_with_source( + cli_width: Option, + config_width: Option, + config_margin: u16, +) -> (u16, &'static str) { // Check memo first if let Ok(guard) = CACHED_WIDTH.lock() { if let Some((w, ts)) = *guard { if ts.elapsed() < WIDTH_TTL { - return w; + return (w, "cached"); } } } - let raw = detect_raw(cli_width, config_width); + let (raw, source) = detect_raw_with_source(cli_width, config_width); let effective = raw.saturating_sub(config_margin).max(40); // Store in memo @@ -41,14 +50,17 @@ pub fn detect_width(cli_width: Option, config_width: Option, config_ma *guard = Some((effective, Instant::now())); } - effective + (effective, source) } -fn detect_raw(cli_width: Option, config_width: Option) -> u16 { +fn detect_raw_with_source( + cli_width: Option, + config_width: Option, +) -> (u16, &'static str) { // 1. --width CLI flag if let Some(w) = cli_width { if w > 0 { - return w; + return (w, "cli_flag"); } } @@ -56,7 +68,7 @@ fn detect_raw(cli_width: Option, config_width: Option) -> u16 { if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") { if let Ok(w) = val.parse::() { if w > 0 { - return w; + return (w, "env_var"); } } } @@ -64,28 +76,28 @@ fn detect_raw(cli_width: Option, config_width: Option) -> u16 { // 3. Config override if let Some(w) = config_width { if w > 0 { - return w; + return (w, "config"); } } // 4. ioctl(TIOCGWINSZ) on stdout if let Some(w) = ioctl_width(libc::STDOUT_FILENO) { if w > 0 { - return w; + return (w, "ioctl"); } } // 5. Process tree walk: find ancestor with real TTY if let Some(w) = process_tree_width() { if w > 0 { - return w; + return (w, "process_tree"); } } // 6. stty size < /dev/tty if let Some(w) = stty_dev_tty() { if w > 0 { - return w; + return (w, "stty"); } } @@ -93,7 +105,7 @@ fn detect_raw(cli_width: Option, config_width: Option) -> u16 { if let Ok(val) = std::env::var("COLUMNS") { if let Ok(w) = val.parse::() { if w > 0 { - return w; + return (w, "columns_env"); } } } @@ -101,12 +113,12 @@ fn detect_raw(cli_width: Option, config_width: Option) -> u16 { // 8. tput cols if let Some(w) = tput_cols() { if w > 0 { - return w; + return (w, "tput"); } } // 9. Fallback - 120 + (120, "fallback") } fn ioctl_width(fd: i32) -> Option {