commit b55d1aefd19a03263124326db930a2a04c6c1b5f Author: Taylor Eernisse Date: Fri Feb 6 14:21:57 2026 -0500 feat: complete Rust port of claude-statusline Port the entire 2236-line bash statusline script to Rust. Implements all 25 sections, 3-phase layout engine (render, priority drop, flex/justify), file-based caching with flock, 9-level terminal width detection, trend sparklines, and deep-merge JSON config. Release binary: 864K with LTO. Render time: <1ms warm. Co-Authored-By: Claude Opus 4.6 diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..f32e807 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,11 @@ +# Database +*.db +*.db-shm +*.db-wal + +# Lock files +*.lock + +# Temporary +last-touched +*.tmp diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..4e54a22 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,4 @@ +# Beads Project Configuration +# issue_prefix: bd +# default_priority: 2 +# default_type: task diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 0000000..178114c --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,23 @@ +{"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-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"}]} +{"id":"bd-1rt","title":"src/trend.rs: Append-only trend files + sparkline","description":"Append with write throttle (5s). Skip if unchanged. Max N points, trim from left. Sparkline: 8 Unicode block chars, normalized min/max. Flat-series guard (mid-height).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-06T18:11:14.837631Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:16:28.958965Z","closed_at":"2026-02-06T19:16:28.958915Z","close_reason":"Full trend: append with throttle + sparkline with flat-series guard","compaction_level":0,"original_size":0,"labels":["core","phase-3"]} +{"id":"bd-1ze","title":"src/section/mod.rs: SectionOutput, RenderContext, registry, dispatch","description":"SectionOutput{raw,ansi}. RenderFn type alias. RenderContext struct with all fields. Registry of (id, render_fn). render_section dispatch. is_spacer check.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-02-06T18:10:36.753358Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.949074Z","closed_at":"2026-02-06T19:08:28.949030Z","close_reason":"Fully implemented during scaffold: registry, dispatch, RenderContext, SectionOutput, VcsType","compaction_level":0,"original_size":0,"labels":["phase-1","sections"],"dependencies":[{"issue_id":"bd-1ze","depends_on_id":"bd-2bo","type":"blocks","created_at":"2026-02-06T18:11:33.123002Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ze","depends_on_id":"bd-2ck","type":"blocks","created_at":"2026-02-06T18:11:33.143326Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ze","depends_on_id":"bd-3w4","type":"blocks","created_at":"2026-02-06T18:11:33.102654Z","created_by":"tayloreernisse"}]} +{"id":"bd-2bo","title":"src/input.rs: InputData struct (serde from stdin JSON)","description":"InputData, ModelInfo, CostInfo, ContextWindow, CurrentUsage, Workspace, OutputStyle. All fields Option with serde(default). Matches stdin JSON shape from Claude Code.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-06T18:10:18.971570Z","created_by":"tayloreernisse","updated_at":"2026-02-06T19:08:28.815936Z","closed_at":"2026-02-06T19:08:28.815889Z","close_reason":"Fully implemented during scaffold: all InputData structs with serde","compaction_level":0,"original_size":0,"labels":["core","phase-1"],"dependencies":[{"issue_id":"bd-2bo","depends_on_id":"bd-1m2","type":"blocks","created_at":"2026-02-06T18:11:30.430855Z","created_by":"tayloreernisse"}]} +{"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-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-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-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-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-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-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-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-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"}]} diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..c787975 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90998a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# bv (beads viewer) local config and caches +.bv/ +target/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a239495 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,722 @@ +# AGENTS.md + +## RULE 0 - THE FUNDAMENTAL OVERRIDE PEROGATIVE + +If I tell you to do something, even if it goes against what follows below, YOU MUST LISTEN TO ME. I AM IN CHARGE, NOT YOU. + +--- + +## RULE NUMBER 1: NO FILE DELETION + +**YOU ARE NEVER ALLOWED TO DELETE A FILE WITHOUT EXPRESS PERMISSION.** Even a new file that you yourself created, such as a test code file. You have a horrible track record of deleting critically important files or otherwise throwing away tons of expensive work. As a result, you have permanently lost any and all rights to determine that a file or folder should be deleted. + +**YOU MUST ALWAYS ASK AND RECEIVE CLEAR, WRITTEN PERMISSION BEFORE EVER DELETING A FILE OR FOLDER OF ANY KIND.** + +--- + +## Irreversible Git & Filesystem Actions — DO NOT EVER BREAK GLASS + +> **Note:** Treat destructive commands as break-glass. If there's any doubt, stop and ask. + +1. **Absolutely forbidden commands:** `git reset --hard`, `git clean -fd`, `rm -rf`, or any command that can delete or overwrite code/data must never be run unless the user explicitly provides the exact command and states, in the same message, that they understand and want the irreversible consequences. +2. **No guessing:** If there is any uncertainty about what a command might delete or overwrite, stop immediately and ask the user for specific approval. "I think it's safe" is never acceptable. +3. **Safer alternatives first:** When cleanup or rollbacks are needed, request permission to use non-destructive options (`git status`, `git diff`, `git stash`, copying to backups) before ever considering a destructive command. +4. **Mandatory explicit plan:** Even after explicit user authorization, restate the command verbatim, list exactly what will be affected, and wait for a confirmation that your understanding is correct. Only then may you execute it—if anything remains ambiguous, refuse and escalate. +5. **Document the confirmation:** When running any approved destructive command, record (in the session notes / final response) the exact user text that authorized it, the command actually run, and the execution time. If that record is absent, the operation did not happen. + +--- + +## Toolchain: Rust & Cargo + +We only use **Cargo** in this project, NEVER any other package manager. + +- **Edition/toolchain:** Follow `rust-toolchain.toml` (if present). Do not assume stable vs nightly. +- **Dependencies:** Explicit versions for stability; keep the set minimal. +- **Configuration:** Cargo.toml only +- **Unsafe code:** Forbidden (`#![forbid(unsafe_code)]`) + +### Release Profile + +Use the release profile defined in `Cargo.toml`. If you need to change it, justify the +performance/size tradeoff and how it impacts determinism and cancellation behavior. + +--- + +## Code Editing Discipline + +### No Script-Based Changes + +**NEVER** run a script that processes/changes code files in this repo. Brittle regex-based transformations create far more problems than they solve. + +- **Always make code changes manually**, even when there are many instances +- For many simple changes: use parallel subagents +- For subtle/complex changes: do them methodically yourself + +### No File Proliferation + +If you want to change something or add a feature, **revise existing code files in place**. + +**NEVER** create variations like: +- `mainV2.rs` +- `main_improved.rs` +- `main_enhanced.rs` + +New files are reserved for **genuinely new functionality** that makes zero sense to include in any existing file. The bar for creating new files is **incredibly high**. + +--- + +## Backwards Compatibility + +We do not care about backwards compatibility—we're in early development with no users. We want to do things the **RIGHT** way with **NO TECH DEBT**. + +- Never create "compatibility shims" +- Never create wrapper functions for deprecated APIs +- Just fix the code directly + +--- + +## Compiler Checks (CRITICAL) + +**After any substantive code changes, you MUST verify no errors were introduced:** + +```bash +# Check for compiler errors and warnings +cargo check --all-targets + +# Check for clippy lints (pedantic + nursery are enabled) +cargo clippy --all-targets -- -D warnings + +# Verify formatting +cargo fmt --check +``` + +If you see errors, **carefully understand and resolve each issue**. Read sufficient context to fix them the RIGHT way. + +--- + +## Testing + +### Unit & Property Tests + +```bash +# Run all tests +cargo test + +# Run with output +cargo test -- --nocapture +``` + +When adding or changing primitives, add tests that assert the core invariants: + +- no task leaks +- no obligation leaks +- losers are drained after races +- region close implies quiescence + +Prefer deterministic lab-runtime tests for concurrency-sensitive behavior. + +--- + +## MCP Agent Mail — Multi-Agent Coordination + +A mail-like layer that lets coding agents coordinate asynchronously via MCP tools and resources. Provides identities, inbox/outbox, searchable threads, and advisory file reservations with human-auditable artifacts in Git. + +### Why It's Useful + +- **Prevents conflicts:** Explicit file reservations (leases) for files/globs +- **Token-efficient:** Messages stored in per-project archive, not in context +- **Quick reads:** `resource://inbox/...`, `resource://thread/...` + +### Same Repository Workflow + +1. **Register identity:** + ``` + ensure_project(project_key=) + register_agent(project_key, program, model) + ``` + +2. **Reserve files before editing:** + ``` + file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true) + ``` + +3. **Communicate with threads:** + ``` + send_message(..., thread_id="FEAT-123") + fetch_inbox(project_key, agent_name) + acknowledge_message(project_key, agent_name, message_id) + ``` + +4. **Quick reads:** + ``` + resource://inbox/{Agent}?project=&limit=20 + resource://thread/{id}?project=&include_bodies=true + ``` + +### Macros vs Granular Tools + +- **Prefer macros for speed:** `macro_start_session`, `macro_prepare_thread`, `macro_file_reservation_cycle`, `macro_contact_handshake` +- **Use granular tools for control:** `register_agent`, `file_reservation_paths`, `send_message`, `fetch_inbox`, `acknowledge_message` + +### Common Pitfalls + +- `"from_agent not registered"`: Always `register_agent` in the correct `project_key` first +- `"FILE_RESERVATION_CONFLICT"`: Adjust patterns, wait for expiry, or use non-exclusive reservation +- **Auth errors:** If JWT+JWKS enabled, include bearer token with matching `kid` + +--- + +## Beads (br) — Dependency-Aware Issue Tracking + +Beads provides a lightweight, dependency-aware issue database and CLI (`br` / beads_rust) for selecting "ready work," setting priorities, and tracking status. It complements MCP Agent Mail's messaging and file reservations. + +**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`. + +### Conventions + +- **Single source of truth:** Beads for task status/priority/dependencies; Agent Mail for conversation and audit +- **Shared identifiers:** Use Beads issue ID (e.g., `br-123`) as Mail `thread_id` and prefix subjects with `[br-123]` +- **Reservations:** When starting a task, call `file_reservation_paths()` with the issue ID in `reason` + +### Typical Agent Flow + +1. **Pick ready work (Beads):** + ```bash + br ready --json # Choose highest priority, no blockers + ``` + +2. **Reserve edit surface (Mail):** + ``` + file_reservation_paths(project_key, agent_name, ["src/**"], ttl_seconds=3600, exclusive=true, reason="br-123") + ``` + +3. **Announce start (Mail):** + ``` + send_message(..., thread_id="br-123", subject="[br-123] Start: ", ack_required=true) + ``` + +4. **Work and update:** Reply in-thread with progress + +5. **Complete and release:** + ```bash + br close br-123 --reason "Completed" + ``` + ``` + release_file_reservations(project_key, agent_name, paths=["src/**"]) + ``` + Final Mail reply: `[br-123] Completed` with summary + +### Mapping Cheat Sheet + +| Concept | Value | +|---------|-------| +| Mail `thread_id` | `br-###` | +| Mail subject | `[br-###] ...` | +| File reservation `reason` | `br-###` | +| Commit messages | Include `br-###` for traceability | + +--- + +## bv — Graph-Aware Triage Engine + +bv is a graph-aware triage engine for Beads projects (`.beads/beads.jsonl`). It computes PageRank, betweenness, critical path, cycles, HITS, eigenvector, and k-core metrics deterministically. + +**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For agent-to-agent coordination (messaging, work claiming, file reservations), use MCP Agent Mail. + +**CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.** + +### The Workflow: Start With Triage + +**`bv --robot-triage` is your single entry point.** It returns: +- `quick_ref`: at-a-glance counts + top 3 picks +- `recommendations`: ranked actionable items with scores, reasons, unblock info +- `quick_wins`: low-effort high-impact items +- `blockers_to_clear`: items that unblock the most downstream work +- `project_health`: status/type/priority distributions, graph metrics +- `commands`: copy-paste shell commands for next steps + +```bash +bv --robot-triage # THE MEGA-COMMAND: start here +bv --robot-next # Minimal: just the single top pick + claim command +``` + +### Command Reference + +**Planning:** +| Command | Returns | +|---------|---------| +| `--robot-plan` | Parallel execution tracks with `unblocks` lists | +| `--robot-priority` | Priority misalignment detection with confidence | + +**Graph Analysis:** +| Command | Returns | +|---------|---------| +| `--robot-insights` | Full metrics: PageRank, betweenness, HITS, eigenvector, critical path, cycles, k-core, articulation points, slack | +| `--robot-label-health` | Per-label health: `health_level`, `velocity_score`, `staleness`, `blocked_count` | +| `--robot-label-flow` | Cross-label dependency: `flow_matrix`, `dependencies`, `bottleneck_labels` | +| `--robot-label-attention [--attention-limit=N]` | Attention-ranked labels | + +**History & Change Tracking:** +| Command | Returns | +|---------|---------| +| `--robot-history` | Bead-to-commit correlations | +| `--robot-diff --diff-since <ref>` | Changes since ref: new/closed/modified issues, cycles | + +**Other:** +| Command | Returns | +|---------|---------| +| `--robot-burndown <sprint>` | Sprint burndown, scope changes, at-risk items | +| `--robot-forecast <id\|all>` | ETA predictions with dependency-aware scheduling | +| `--robot-alerts` | Stale issues, blocking cascades, priority mismatches | +| `--robot-suggest` | Hygiene: duplicates, missing deps, label suggestions | +| `--robot-graph [--graph-format=json\|dot\|mermaid]` | Dependency graph export | +| `--export-graph <file.html>` | Interactive HTML visualization | + +### Scoping & Filtering + +```bash +bv --robot-plan --label backend # Scope to label's subgraph +bv --robot-insights --as-of HEAD~30 # Historical point-in-time +bv --recipe actionable --robot-plan # Pre-filter: ready to work +bv --recipe high-impact --robot-triage # Pre-filter: top PageRank +bv --robot-triage --robot-triage-by-track # Group by parallel work streams +bv --robot-triage --robot-triage-by-label # Group by domain +``` + +### Understanding Robot Output + +**All robot JSON includes:** +- `data_hash` — Fingerprint of source beads.jsonl +- `status` — Per-metric state: `computed|approx|timeout|skipped` + elapsed ms +- `as_of` / `as_of_commit` — Present when using `--as-of` + +**Two-phase analysis:** +- **Phase 1 (instant):** degree, topo sort, density +- **Phase 2 (async, 500ms timeout):** PageRank, betweenness, HITS, eigenvector, cycles + +### jq Quick Reference + +```bash +bv --robot-triage | jq '.quick_ref' # At-a-glance summary +bv --robot-triage | jq '.recommendations[0]' # Top recommendation +bv --robot-plan | jq '.plan.summary.highest_impact' # Best unblock target +bv --robot-insights | jq '.status' # Check metric readiness +bv --robot-insights | jq '.Cycles' # Circular deps (must fix!) +``` + +--- + +## UBS — Ultimate Bug Scanner + +**Golden Rule:** `ubs <changed-files>` before every commit. Exit 0 = safe. Exit >0 = fix & re-run. + +### Commands + +```bash +ubs file.rs file2.rs # Specific files (< 1s) — USE THIS +ubs $(git diff --name-only --cached) # Staged files — before commit +ubs --only=rust,toml src/ # Language filter (3-5x faster) +ubs --ci --fail-on-warning . # CI mode — before PR +ubs . # Whole project (ignores target/, Cargo.lock) +``` + +### Output Format + +``` +⚠️ Category (N errors) + file.rs:42:5 – Issue description + 💡 Suggested fix +Exit code: 1 +``` + +Parse: `file:line:col` → location | 💡 → how to fix | Exit 0/1 → pass/fail + +### Fix Workflow + +1. Read finding → category + fix suggestion +2. Navigate `file:line:col` → view context +3. Verify real issue (not false positive) +4. Fix root cause (not symptom) +5. Re-run `ubs <file>` → exit 0 +6. Commit + +### Bug Severity + +- **Critical (always fix):** Memory safety, use-after-free, data races, SQL injection +- **Important (production):** Unwrap panics, resource leaks, overflow checks +- **Contextual (judgment):** TODO/FIXME, println! debugging + +--- + +## ast-grep vs ripgrep + +**Use `ast-grep` when structure matters.** It parses code and matches AST nodes, ignoring comments/strings, and can **safely rewrite** code. + +- Refactors/codemods: rename APIs, change import forms +- Policy checks: enforce patterns across a repo +- Editor/automation: LSP mode, `--json` output + +**Use `ripgrep` when text is enough.** Fastest way to grep literals/regex. + +- Recon: find strings, TODOs, log lines, config values +- Pre-filter: narrow candidate files before ast-grep + +### Rule of Thumb + +- Need correctness or **applying changes** → `ast-grep` +- Need raw speed or **hunting text** → `rg` +- Often combine: `rg` to shortlist files, then `ast-grep` to match/modify + +### Rust Examples + +```bash +# Find structured code (ignores comments) +ast-grep run -l Rust -p 'fn $NAME($$$ARGS) -> $RET { $$$BODY }' + +# Find all unwrap() calls +ast-grep run -l Rust -p '$EXPR.unwrap()' + +# Quick textual hunt +rg -n 'println!' -t rust + +# Combine speed + precision +rg -l -t rust 'unwrap\(' | xargs ast-grep run -l Rust -p '$X.unwrap()' --json +``` + +--- + +## Morph Warp Grep — AI-Powered Code Search + +**Use `mcp__morph-mcp__warp_grep` for exploratory "how does X work?" questions.** An AI agent expands your query, greps the codebase, reads relevant files, and returns precise line ranges with full context. + +**Use `ripgrep` for targeted searches.** When you know exactly what you're looking for. + +**Use `ast-grep` for structural patterns.** When you need AST precision for matching/rewriting. + +### When to Use What + +| Scenario | Tool | Why | +|----------|------|-----| +| "How is pattern matching implemented?" | `warp_grep` | Exploratory; don't know where to start | +| "Where is the quick reject filter?" | `warp_grep` | Need to understand architecture | +| "Find all uses of `Regex::new`" | `ripgrep` | Targeted literal search | +| "Find files with `println!`" | `ripgrep` | Simple pattern | +| "Replace all `unwrap()` with `expect()`" | `ast-grep` | Structural refactor | + +### warp_grep Usage + +``` +mcp__morph-mcp__warp_grep( + repoPath: "/path/to/dcg", + query: "How does the safe pattern whitelist work?" +) +``` + +Returns structured results with file paths, line ranges, and extracted code snippets. + +### Anti-Patterns + +- **Don't** use `warp_grep` to find a specific function name → use `ripgrep` +- **Don't** use `ripgrep` to understand "how does X work" → wastes time with manual reads +- **Don't** use `ripgrep` for codemods → risks collateral edits + +<!-- bv-agent-instructions-v1 --> + +--- + +## Beads Workflow Integration + +This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in git. + +**Note:** `br` is non-invasive—it never executes git commands directly. You must run git commands manually after `br sync --flush-only`. + +### Essential Commands + +```bash +# View issues (launches TUI - avoid in automated sessions) +bv + +# CLI commands for agents (use these instead) +br ready # Show issues ready to work (no blockers) +br list --status=open # All open issues +br show <id> # Full issue details with dependencies +br create --title="..." --type=task --priority=2 +br update <id> --status=in_progress +br close <id> --reason="Completed" +br close <id1> <id2> # Close multiple issues at once +br sync --flush-only # Export to JSONL (then manually: git add .beads/ && git commit) +``` + +### Workflow Pattern + +1. **Start**: Run `br ready` to find actionable work +2. **Claim**: Use `br update <id> --status=in_progress` +3. **Work**: Implement the task +4. **Complete**: Use `br close <id>` +5. **Sync**: Run `br sync --flush-only`, then `git add .beads/ && git commit -m "Update beads"` + +### Key Concepts + +- **Dependencies**: Issues can block other issues. `br ready` shows only unblocked work. +- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words) +- **Types**: task, bug, feature, epic, question, docs +- **Blocking**: `br dep add <issue> <depends-on>` to add dependencies + +### Session Protocol + +**Before ending any session, run this checklist:** + +```bash +git status # Check what changed +git add <files> # Stage code changes +br sync --flush-only # Export beads to JSONL +git add .beads/ # Stage beads changes +git commit -m "..." # Commit code and beads +git push # Push to remote +``` + +### Best Practices + +- Check `br ready` at session start to find available work +- Update status as you work (in_progress → closed) +- Create new issues with `br create` when you discover tasks +- Use descriptive titles and set appropriate priority/type +- Always run `br sync --flush-only` then commit .beads/ before ending session + +<!-- end-bv-agent-instructions --> + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + br sync --flush-only + git add .beads/ + git commit -m "Update beads" + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + +--- + +## cass — Cross-Agent Session Search + +`cass` indexes prior agent conversations (Claude Code, Codex, Cursor, Gemini, ChatGPT, etc.) so we can reuse solved problems. + +**Rules:** Never run bare `cass` (TUI). Always use `--robot` or `--json`. + +### Examples + +```bash +cass health +cass search "async runtime" --robot --limit 5 +cass view /path/to/session.jsonl -n 42 --json +cass expand /path/to/session.jsonl -n 42 -C 3 --json +cass capabilities --json +cass robot-docs guide +``` + +### Tips + +- Use `--fields minimal` for lean output +- Filter by agent with `--agent` +- Use `--days N` to limit to recent history + +stdout is data-only, stderr is diagnostics; exit code 0 means success. + +Treat cass as a way to avoid re-solving problems other agents already handled. + +--- + +## Note for Codex/GPT-5.2 + +You constantly bother me and stop working with concerned questions that look similar to this: + +``` +Unexpected changes (need guidance) + +- Working tree still shows edits I did not make in Cargo.toml, Cargo.lock, src/runtime.rs, src/scope.rs. Please advise whether to keep/commit/revert these before any further work. I did not touch them. + +Next steps (pick one) + +1. Decide how to handle the unrelated modified files above so we can resume cleanly. +``` + +NEVER EVER DO THAT AGAIN. The answer is literally ALWAYS the same: those are changes created by the potentially dozen of other agents working on the project at the same time. This is not only a common occurence, it happens multiple times PER MINUTE. The way to deal with it is simple: you NEVER, under ANY CIRCUMSTANCE, stash, revert, overwrite, or otherwise disturb in ANY way the work of other agents. Just treat those changes identically to changes that you yourself made. Just fool yourself into thinking YOU made the changes and simply don't recall it for some reason. + +--- + +## Note on Built-in TODO Functionality + +Also, if I ask you to explicitly use your built-in TODO functionality, don't complain about this and say you need to use beads. You can use built-in TODOs if I tell you specifically to do so. Always comply with such orders. + +## TDD Requirements + +Test-first development is mandatory: +1. **RED** - Write failing test first +2. **GREEN** - Minimal implementation to pass +3. **REFACTOR** - Clean up while green + +## Key Patterns + +Find the simplest solution that meets all acceptance criteria. +Use third party libraries whenever there's a well-maintained, active, and widely adopted solution (for example, date-fns for TS date math) +Build extensible pieces of logic that can easily be integrated with other pieces. +DRY principles should be loosely held. +Architecture MUST be clear and well thought-out. Ask the user for clarification whenever ambiguity is discovered around architecture, or you think a better approach than planned exists. + +--- + +## Third-Party Library Usage + +If you aren't 100% sure how to use a third-party library, **SEARCH ONLINE** to find the latest documentation and mid-2025 best practices. + +--- + +## Gitlore Robot Mode + +The `lore` CLI has a robot mode optimized for AI agent consumption with structured JSON output, meaningful exit codes, and TTY auto-detection. + +### Activation + +```bash +# Explicit flag +lore --robot issues -n 10 + +# JSON shorthand (-J) +lore -J issues -n 10 + +# Auto-detection (when stdout is not a TTY) +lore issues | jq . + +# Environment variable +LORE_ROBOT=1 lore issues +``` + +### Robot Mode Commands + +```bash +# List issues/MRs with JSON output +lore --robot issues -n 10 +lore --robot mrs -s opened + +# Show detailed entity info +lore --robot issues 123 +lore --robot mrs 456 -p group/repo + +# Count entities +lore --robot count issues +lore --robot count discussions --for mr + +# Search indexed documents +lore --robot search "authentication bug" + +# Check sync status +lore --robot status + +# Run full sync pipeline +lore --robot sync + +# Run sync without resource events +lore --robot sync --no-events + +# Run ingestion only +lore --robot ingest issues + +# Check environment health +lore --robot doctor + +# Document and index statistics +lore --robot stats + +# Quick health pre-flight check (exit 0 = healthy, 1 = unhealthy) +lore --robot health + +# Generate searchable documents from ingested data +lore --robot generate-docs + +# Generate vector embeddings via Ollama +lore --robot embed + +# Agent self-discovery manifest (all commands, flags, exit codes) +lore robot-docs + +# Version information +lore --robot version +``` + +### Response Format + +All commands return consistent JSON: + +```json +{"ok":true,"data":{...},"meta":{...}} +``` + +Errors return structured JSON to stderr: + +```json +{"error":{"code":"CONFIG_NOT_FOUND","message":"...","suggestion":"Run 'lore init'"}} +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Internal error / health check failed / not implemented | +| 2 | Usage error (invalid flags or arguments) | +| 3 | Config invalid | +| 4 | Token not set | +| 5 | GitLab auth failed | +| 6 | Resource not found | +| 7 | Rate limited | +| 8 | Network error | +| 9 | Database locked | +| 10 | Database error | +| 11 | Migration failed | +| 12 | I/O error | +| 13 | Transform error | +| 14 | Ollama unavailable | +| 15 | Ollama model not found | +| 16 | Embedding failed | +| 17 | Not found (entity does not exist) | +| 18 | Ambiguous match (use `-p` to specify project) | +| 20 | Config not found | + +### Configuration Precedence + +1. CLI flags (highest priority) +2. Environment variables (`LORE_ROBOT`, `GITLAB_TOKEN`, `LORE_CONFIG_PATH`) +3. Config file (`~/.config/lore/config.json`) +4. Built-in defaults (lowest priority) + +### Best Practices + +- Use `lore --robot` or `lore -J` for all agent interactions +- Check exit codes for error handling +- Parse JSON errors from stderr +- Use `-n` / `--limit` to control response size +- Use `-q` / `--quiet` to suppress progress bars and non-essential output +- Use `--color never` in non-TTY automation for ANSI-free output +- Use `-v` / `-vv` / `-vvv` for increasing verbosity (debug/trace logging) +- Use `--log-format json` for machine-readable log output to stderr +- TTY detection handles piped commands automatically +- Use `lore --robot health` as a fast pre-flight check before queries +- The `-p` flag supports fuzzy project matching (suffix and substring) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fcf8ac2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,663 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "claude-statusline" +version = "0.1.0" +dependencies = [ + "criterion", + "libc", + "md-5", + "serde", + "serde_ignored", + "serde_json", + "serde_path_to_error", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2372d1c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "claude-statusline" +version = "0.1.0" +edition = "2021" +description = "Fast, configurable status line for Claude Code" +license = "MIT" +repository = "https://github.com/tayloreernisse/claude-statusline" + +[[bin]] +name = "claude-statusline" +path = "src/bin/claude-statusline.rs" + +[lib] +name = "claude_statusline" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +md-5 = "0.10" +unicode-width = "0.2" +unicode-segmentation = "1" +libc = "0.2" +serde_path_to_error = "0.1" +serde_ignored = "0.1" + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "render" +harness = false + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..615b880 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Taylor Eernisse + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c2b90b --- /dev/null +++ b/README.md @@ -0,0 +1,801 @@ +# claude-statusline + +A configurable, multi-line status line for Claude Code with priority-based flex layout, VCS auto-detection (git/jj), spacer-based positioning, custom command sections, and per-section formatting. + +``` +[Opus] | Anthropic | gitlore master* +1-0 +[============----] 58% | $0.12 | +156 -23 | 14m | 7 tools (Edit) +``` + +## Quick Start + +### Prerequisites + +- **bash 4+** (macOS ships bash 3 — `brew install bash`) +- **jq** (`brew install jq` / `apt install jq`) +- **Claude Code** with status line support + +### Install + +```bash +git clone https://github.com/tayloreernisse/claude-statusline ~/projects/claude-statusline +cd ~/projects/claude-statusline +./install.sh +``` + +The installer creates two symlinks: + +``` +~/.claude/statusline.sh -> ~/projects/claude-statusline/statusline.sh +~/.claude/statusline.json -> ~/projects/claude-statusline/statusline.json +``` + +### Configure Claude Code + +Add to `~/.claude/settings.json`: + +```json +{ + "statusLine": "~/.claude/statusline.sh" +} +``` + +Restart Claude Code to see the status line. + +## How It Works + +``` +Claude Code (every ~300ms) + | + | pipes JSON to stdin + v +statusline.sh + | + +-- reads ~/.claude/statusline.json (config) + +-- parses stdin JSON (model, cost, context, etc.) + +-- caches expensive ops (VCS, beads, system load) + +-- resolves layout (preset or custom array) + +-- renders sections with ANSI colors + +-- applies priority-based flex layout + | + | stdout (ANSI-colored lines) + v +Claude Code renders status bar +``` + +The stdin JSON from Claude Code contains: +- `model` — current model info (id, display name) +- `cost` — accumulated cost, duration, lines changed, tool uses +- `context_window` — token counts, used percentage, cache stats +- `workspace` — project directory +- `version` — Claude Code version +- `output_style` — current output style + +## Configuration Reference + +Config lives at `~/.claude/statusline.json` (or set `CLAUDE_STATUSLINE_CONFIG` env var). + +### Global Options + +```json +{ + "global": { + "separator": " | ", + "justify": "left", + "vcs": "auto", + "width_margin": 4, + "cache_dir": "/tmp/claude-sl-{session_id}" + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `separator` | `" \| "` | Text between sections. In `left` mode, used as-is. In `spread`/`space-between`, the non-space characters (e.g. `\|`) stay as visual anchors with extra padding around them. | +| `justify` | `"left"` | How sections distribute across terminal width. See [Justify Modes](#justify-modes). | +| `width` | (auto) | Explicit terminal width in columns. If omitted, auto-detection walks the process tree to find an ancestor with a real TTY, falling back to `stty` via `/dev/tty`, `COLUMNS`, `tput cols`, or 120. | +| `width_margin` | `4` | Columns to subtract from the detected width. Accounts for terminal multiplexer borders (Zellij/tmux) or Claude Code UI chrome that reduce the actual visible area. | +| `vcs` | `"auto"` | VCS detection: `auto`, `git`, `jj`, `none` | +| `cache_dir` | `/tmp/claude-sl-{session_id}` | Cache directory template. `{session_id}` is replaced at runtime. | + +### Layout System + +The `layout` field controls which sections appear and on which lines. + +**Using a preset:** +```json +{ "layout": "standard" } +``` + +**Using a custom layout:** +```json +{ + "layout": [ + ["model", "provider", "project", "spacer", "vcs"], + ["context_bar", "cost", "duration"] + ] +} +``` + +Each inner array is one line. Section IDs in the array are rendered left-to-right, separated by the global separator. + +### Spacer Sections + +Use `spacer` (or `_spacer1`, `_spacer2`, etc. for multiple spacers) as a virtual section that expands to fill remaining width. This pushes sections apart without using `justify: "spread"`. + +```json +{ + "layout": [ + ["model", "provider", "spacer", "vcs"] + ] +} +``` + +Output (80 columns): +``` +[Opus] | Anthropic master* +1-0 +``` + +Spacers have `flex: true` by default and suppress the separator on adjacent sides. Use multiple unique spacer IDs (`_spacer1`, `_spacer2`) if you need more than one on a line. + +### Built-in Presets + +**standard** (2 lines): +``` +Line 1: model | provider | project | spacer | vcs +Line 2: context_bar | cost | lines_changed | duration | tools +``` + +**dense** (1 line): +``` +Line 1: model | provider | project | vcs | context_bar | cost | duration +``` + +**verbose** (3 lines): +``` +Line 1: model | provider | project | vcs | beads +Line 2: context_bar | tokens_raw | cache_efficiency | cost | cost_velocity +Line 3: lines_changed | duration | tools | turns | load | version +``` + +You can define custom presets in the `presets` object and reference them by name. + +### Section Configuration + +Each section supports these common properties: + +```json +{ + "enabled": true, + "priority": 1, + "flex": false, + "min_width": 0, + "prefix": "", + "suffix": "", + "pad": 0, + "align": "left", + "color": "dim" +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `enabled` | boolean | Whether the section renders at all | +| `priority` | 1/2/3 | Display priority (see Flex Layout below) | +| `flex` | boolean | Whether section expands to fill remaining width | +| `min_width` | integer | Minimum character width | +| `prefix` | string | Text prepended to section output | +| `suffix` | string | Text appended to section output | +| `pad` | integer | Pad output to this minimum width | +| `align` | `left`/`right`/`center` | Alignment within padded width | +| `color` | color name | Override the section's color (see [Color Names](#color-names)) + +### Per-Section Formatting Examples + +**Add brackets around the project name:** +```json +{ + "sections": { + "project": { "prefix": "[", "suffix": "]" } + } +} +``` +Output: `[myproject]` + +**Right-align cost in a fixed-width column:** +```json +{ + "sections": { + "cost": { "pad": 8, "align": "right" } + } +} +``` +Output: ` $0.12` (padded to 8 chars) + +**Override a section's default color:** +```json +{ + "sections": { + "duration": { "color": "cyan" } + } +} +``` + +## Flex Layout Deep Dive + +### Priority Drop + +When a line is wider than the terminal: + +1. Drop `priority: 3` sections, right-to-left +2. Still too wide? Drop `priority: 2`, right-to-left +3. `priority: 1` sections never drop + +**Example at 120 columns** (everything fits): +``` +[Opus] | Anthropic | gitlore | master* | [======----] 58% | $0.12 | +156 -23 | 14m | 7 tools +``` + +**Same content at 80 columns** (priority 3 dropped, priority 2 trimmed): +``` +[Opus] | gitlore | master* | [======----] 58% | $0.12 +``` + +### Flex Expansion + +If a section has `flex: true` and there's remaining width after rendering all sections, the flex section expands to fill the gap. For `context_bar`, this means a wider progress bar. For spacers, they expand to push adjacent sections apart. For other sections, it pads with spaces. + +One flex section per line wins. Spacers take priority over non-spacer flex sections. + +**80 columns with flex context_bar:** +``` +[Opus] | gitlore | master* | [================----] 58% | $0.12 +``` + +**80 columns with spacer (pushes VCS to right):** +``` +[Opus] | gitlore master* +1-0 +``` + +### Justify Modes + +The `global.justify` setting controls how sections distribute across the full terminal width. This is independent of priority drop — sections are dropped first if needed, then the remaining sections are distributed. + +**`"left"` (default)** — Pack sections left with fixed-width separators. Use `flex: true` on a section to fill remaining space. +``` +[Opus] | Anthropic | gitlore | master* +``` + +**`"spread"`** — Distribute extra space evenly into all gaps. The separator's visible characters (e.g. `|`) stay as anchors, padded with spaces on both sides. Every line fills the full terminal width. +``` +[Opus] | Anthropic | gitlore | master* +``` + +**`"space-between"`** — Same distribution as `spread` for terminal output (first section starts at column 0, last section ends at the terminal edge). + +When justify is `"spread"` or `"space-between"`, the `flex` property on individual sections is ignored — the entire line spreads instead of one section expanding. + +The separator character still matters: `" | "` produces gaps like ` | `, while `" :: "` produces ` :: `, and `" "` (pure spaces) produces invisible gaps. + +## Section Reference + +### model +**Source:** stdin JSON `model.display_name` or parsed from `model.id` + +Shows the current model name in bold brackets. Falls back to parsing "Opus"/"Sonnet"/"Haiku" from the model ID. + +``` +[Opus] +``` + +### provider +**Source:** pattern match on `model.id` + +Detects the API provider from the model identifier. + +| Pattern | Provider | +|---------|----------| +| `us.anthropic.*`, `anthropic.*` | Bedrock | +| `*@YYYYMMDD` | Vertex | +| `claude-*` | Anthropic | + +Empty if provider can't be determined. + +### project +**Source:** `basename` of `workspace.project_dir` + +Shows the current project directory name. + +### vcs +**Source:** git/jj CLI (cached) + +Auto-detects VCS by checking for `.jj/` first, then `.git/`. Override with `vcs.prefer`. + +```json +{ + "vcs": { + "prefer": "auto", + "show_ahead_behind": true, + "show_dirty": true, + "ttl": { "branch": 3, "dirty": 5, "ahead_behind": 30 } + } +} +``` + +**git output:** `master* +1-0` (branch, dirty indicator, ahead/behind) + +**jj output:** `feature-branch*` (bookmark or short change ID, dirty indicator) + +Each piece has its own cache TTL — branch name barely changes, dirty status changes often, ahead/behind is expensive. + +### beads +**Source:** `br` CLI (cached) + +Shows current in-progress bead and ready count. Requires `br` to be installed. + +```json +{ + "beads": { + "show_wip": true, + "show_ready_count": true, + "ttl": 30 + } +} +``` + +``` +br-47 wip | 3 ready +``` + +### context_bar +**Source:** `context_window.used_percentage` + +Visual progress bar of context window usage with color thresholds. + +```json +{ + "context_bar": { + "flex": true, + "bar_width": 10, + "thresholds": { "warn": 50, "danger": 70, "critical": 85 } + } +} +``` + +| Range | Color | +|-------|-------| +| 0-49% | Green | +| 50-69% | Yellow | +| 70-84% | Red | +| 85%+ | Bold Red | + +### context_usage +**Source:** `context_window.total_input_tokens`, `total_output_tokens`, `max_tokens`, `used_percentage` + +Shows context usage as "used/total" (e.g., `125k/200k`). The "used" part is colored based on thresholds, while "total" is always dim. + +```json +{ + "context_usage": { + "enabled": true, + "thresholds": { "warn": 50, "danger": 70, "critical": 85 } + } +} +``` + +Output: `125k/200k` (green when below 50%, yellow 50-69%, red 70-84%, bold red 85%+) + +If `max_tokens` is not provided in the input, it's calculated from the percentage. + +### tokens_raw +**Source:** `context_window.total_input_tokens`, `total_output_tokens` + +Raw token counts in human-readable format. + +```json +{ "tokens_raw": { "format": "{input}in/{output}out" } } +``` + +``` +115kin/8.5kout +``` + +### cache_efficiency +**Source:** `cache_read_input_tokens`, `cache_creation_input_tokens` + +Percentage of cache reads vs total cache operations. + +``` +cache:33% +``` + +### cost +**Source:** `cost.total_cost_usd` + +Accumulated session cost with color thresholds. + +```json +{ + "cost": { + "thresholds": { "warn": 0.25, "danger": 0.50, "critical": 1.00 } + } +} +``` + +### cost_velocity +**Source:** cost / duration + +Cost per minute of session time. + +``` +$0.08/m +``` + +### token_velocity +**Source:** tokens / duration + +Total tokens (input + output) consumed per minute. Useful for understanding how fast you're burning through context. + +``` +14.5ktok/m +``` + +In narrow terminals, abbreviated to `14.5kt/m`. + +### lines_changed +**Source:** `cost.total_lines_added`, `total_lines_removed` + +``` ++156 -23 +``` + +Green for additions, red for removals. + +### duration +**Source:** `cost.total_duration_ms` + +Human-readable session duration: `14m`, `1h23m`, `45s`. + +### tools +**Source:** `cost.total_tool_uses`, `cost.last_tool_name` + +```json +{ + "tools": { + "show_last_name": true, + "ttl": 2 + } +} +``` + +``` +7 tools (Edit) +``` + +### turns +**Source:** `cost.total_turns` + +``` +12 turns +``` + +### load +**Source:** system load average (macOS: `sysctl`, Linux: `/proc/loadavg`) + +```json +{ "load": { "ttl": 10 } } +``` + +``` +load:2.1 +``` + +### version +**Source:** `version` from stdin JSON + +``` +v1.0.80 +``` + +### time +**Source:** `date` command + +```json +{ "time": { "format": "%H:%M" } } +``` + +### output_style +**Source:** `output_style.name` + +Shows the current Claude Code output style (e.g., "learning", "concise"). + +### hostname +**Source:** `hostname -s` + +Short hostname of the machine. + +## Custom Commands + +Add custom sections that execute shell commands and display the result. + +```json +{ + "custom": [ + { + "id": "ollama", + "label": "ollama", + "command": "pgrep -x ollama >/dev/null && echo 'up' || echo 'down'", + "ttl": 30, + "priority": 3, + "color": { + "match": { "up": "green", "down": "dim" } + } + } + ] +} +``` + +Then reference by `id` in any layout line: + +```json +{ "layout": [["model", "project", "ollama"]] } +``` + +### Custom Command Fields + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `id` | yes | — | Unique identifier, used in layout arrays | +| `label` | no | same as `id` | Display prefix before the value | +| `command` | yes | — | Shell command. stdout is captured. | +| `ttl` | no | 30 | Cache TTL in seconds | +| `priority` | no | 2 | Display priority (1/2/3) | +| `flex` | no | false | Expand to fill remaining width | +| `min_width` | no | 4 | Minimum character width | +| `color.match` | no | — | Map output values to color names | +| `default_color` | no | — | Fallback color when `color.match` doesn't match | +| `prefix` | no | — | Text prepended to output | +| `suffix` | no | — | Text appended to output | +| `pad` | no | — | Pad output to this minimum width | +| `align` | no | `left` | Alignment within padded width (`left`/`right`/`center`) | + +### Color Names + +Available colors: `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, `dim`, `bold`. + +These work in three places: +- `sections.<name>.color` — override a built-in section's color +- `custom[].color.match` — map output values to colors +- `custom[].default_color` — default color for custom commands (use instead of `color` to avoid conflict with `color.match`) + +### Recipe Ideas + +**Dev server status:** +```json +{ + "id": "devserver", + "command": "curl -so /dev/null http://localhost:3000 && echo 'up' || echo 'down'", + "ttl": 10, + "priority": 3, + "color": { "match": { "up": "green", "down": "red" } } +} +``` + +**Disk usage:** +```json +{ + "id": "disk", + "label": "disk", + "command": "df -h / | awk 'NR==2{print $5}'", + "ttl": 120, + "priority": 3 +} +``` + +**CI status (via gh):** +```json +{ + "id": "ci", + "label": "CI", + "command": "gh run list --limit 1 --json conclusion -q '.[0].conclusion' 2>/dev/null || echo '?'", + "ttl": 60, + "priority": 3, + "color": { "match": { "success": "green", "failure": "red", "?": "dim" } } +} +``` + +**Ollama status:** +```json +{ + "id": "ollama", + "label": "ollama", + "command": "pgrep -x ollama >/dev/null && echo 'up' || echo 'down'", + "ttl": 30, + "priority": 3, + "color": { "match": { "up": "green", "down": "dim" } } +} +``` + +**Docker status:** +```json +{ + "id": "docker", + "label": "docker", + "command": "docker info >/dev/null 2>&1 && echo 'up' || echo 'down'", + "ttl": 60, + "priority": 3, + "color": { "match": { "up": "green", "down": "dim" } } +} +``` + +## Caching + +Expensive operations (VCS commands, beads queries, custom commands, system load) are cached in files under the cache directory. + +### How It Works + +1. Each cached value has a key (e.g., `vcs_branch`) and a TTL in seconds +2. On first call, the command runs and its stdout is written to `$CACHE_DIR/$key` +3. On subsequent calls within the TTL, the cached file is read instead +4. After TTL expires, the command runs again + +### Cache Location + +Default: `/tmp/claude-sl-{session_id}/` + +The `{session_id}` template variable is replaced with the script's PID. Configure via `global.cache_dir`. + +### TTLs by Section + +| Section | Key | Default TTL | +|---------|-----|-------------| +| vcs (branch) | `vcs_branch` | 3s | +| vcs (dirty) | `vcs_dirty` | 5s | +| vcs (ahead/behind) | `vcs_ab` | 30s | +| beads (wip) | `beads_wip` | 30s | +| beads (ready) | `beads_ready` | 30s | +| load | `load` | 10s | +| custom commands | `custom_{id}` | per-command `ttl` | + +## Troubleshooting + +### Script doesn't run + +- Check the shebang: `head -1 ~/.claude/statusline.sh` should be `#!/usr/bin/env bash` +- Verify it's executable: `ls -la ~/.claude/statusline.sh` (should show `-rwxr-xr-x`) +- Fix: `chmod +x ~/.claude/statusline.sh` +- Verify jq is installed: `which jq` +- Test manually: `echo '{}' | ~/.claude/statusline.sh` + +### No output / blank status line + +- Test with mock data: `echo '{"model":{"id":"claude-opus-4-5-20251101","display_name":"Opus"},"cost":{"total_cost_usd":0.05}}' | ~/.claude/statusline.sh` +- Check config is valid JSON: `jq . ~/.claude/statusline.json` +- Check symlinks: `ls -la ~/.claude/statusline.sh ~/.claude/statusline.json` + +### Stale data + +- VCS branch stuck? TTL is 3s by default. Increase: `sections.vcs.ttl.branch` +- Clear cache: `rm -rf /tmp/claude-sl-*/` + +### Performance + +The script runs every ~300ms. Most sections are free (parsed from stdin JSON). Expensive sections: + +| Section | Cost | Mitigation | +|---------|------|-----------| +| vcs | git/jj subprocess | Cached with per-field TTLs | +| beads | br subprocess | 30s TTL | +| load | sysctl/proc read | 10s TTL | +| custom | arbitrary command | User-configured TTL | + +If status line feels sluggish, increase TTLs or disable expensive sections. + +## Provider Detection + +The `provider` section detects the API provider from the model ID: + +| Model ID Pattern | Detected Provider | +|-----------------|-------------------| +| `us.anthropic.claude-*` | Bedrock | +| `anthropic.claude-*` | Bedrock | +| `claude-*-*@20251101` | Vertex | +| `claude-opus-4-5-20251101` | Anthropic (direct) | + +Detection is pure pattern matching on `model.id` — no credentials or API calls involved. + +## Config Validation + +A JSON Schema is provided at `schema.json` for editor autocomplete and validation. + +**VSCode:** Add to your `statusline.json`: +```json +{ + "$schema": "./schema.json", + "version": 1 +} +``` + +**CLI validation (with ajv):** +```bash +npx ajv validate -s schema.json -d statusline.json +``` + +## Terminal Width Detection + +Claude Code runs the status line script without a TTY, which makes detecting the terminal width non-trivial. The detection priority is: + +1. **`global.width`** in config — explicit override +2. **`CLAUDE_STATUSLINE_WIDTH`** env var +3. **Process tree walk** — finds an ancestor process with a real TTY and runs `stty size` on it +4. **`stty size < /dev/tty`** — works on some systems +5. **`COLUMNS`** env var +6. **`tput cols`** +7. **Fallback: 120** + +After detection, `global.width_margin` (default: 4) is subtracted to account for terminal multiplexer borders or UI chrome. + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `CLAUDE_STATUSLINE_CONFIG` | Override config file path | +| `CLAUDE_STATUSLINE_WIDTH` | Override terminal width (second priority after `global.width`) | +| `COLUMNS` | Standard terminal width (lower priority) | + +## CLI Flags + +The script supports several flags for development and debugging: + +```bash +# Test your config with mock data (no Claude Code needed) +./statusline.sh --test + +# Get path to JSON schema for IDE autocomplete +./statusline.sh --config-schema + +# Debug internal state (width detection, theme, VCS, etc.) +./statusline.sh --dump-state + +# Show help +./statusline.sh --help +``` + +### --test Mode + +Renders the status line with realistic mock data. Useful for: +- Validating config changes without restarting Claude Code +- Rapid iteration on layout and colors +- Debugging section visibility + +### --dump-state Mode + +Outputs internal computed state as JSON: +```json +{ + "terminal": {"detected_width": 178, "effective_width": 174, "width_tier": "wide"}, + "responsive": {"enabled": true, "layout": "verbose"}, + "theme": "dark", + "vcs": "git", + "paths": {"config": "~/.claude/statusline.json", "cache_dir": "/tmp/claude-sl-abc123"} +} +``` + +## Examples + +The `examples/` directory contains ready-to-use configurations: + +| File | Description | +|------|-------------| +| `dense.json` | Single-line layout with compact context bar | +| `verbose.json` | Three-line layout with all metrics | +| `custom-commands.json` | Custom Ollama and Docker status indicators | + +Copy an example to your config: +```bash +cp examples/verbose.json ~/.claude/statusline.json +``` + +## License + +MIT diff --git a/benches/render.rs b/benches/render.rs new file mode 100644 index 0000000..228a34c --- /dev/null +++ b/benches/render.rs @@ -0,0 +1,8 @@ +use criterion::{criterion_group, criterion_main, Criterion}; + +fn bench_render(_c: &mut Criterion) { + // Benchmarks will be added when sections are implemented +} + +criterion_group!(benches, bench_render); +criterion_main!(benches); diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0c16752 --- /dev/null +++ b/build.rs @@ -0,0 +1,4 @@ +fn main() { + println!("cargo:rerun-if-changed=defaults.json"); + println!("cargo:rerun-if-changed=schema.json"); +} diff --git a/defaults.json b/defaults.json new file mode 100644 index 0000000..2034597 --- /dev/null +++ b/defaults.json @@ -0,0 +1,271 @@ +{ + "version": 1, + "global": { + "separator": " | ", + "justify": "space-between", + "vcs": "auto", + "cache_dir": "/tmp/claude-sl-{session_id}", + "responsive": true, + "breakpoints": { + "narrow": 60, + "medium": 100 + }, + "theme": "auto" + }, + "colors": { + "dark": { + "success": "green", + "warning": "yellow", + "danger": "red", + "critical": "red bold", + "muted": "dim", + "accent": "cyan", + "highlight": "bold", + "info": "blue" + }, + "light": { + "success": "green bold", + "warning": "yellow bold", + "danger": "red bold", + "critical": "red bold", + "muted": "dim", + "accent": "blue", + "highlight": "bold", + "info": "cyan" + } + }, + "glyphs": { + "enabled": false, + "set": { + "separator": "", + "separator_alt": "", + "branch": "", + "dirty": "*", + "clean": "✓", + "ahead": "↑", + "behind": "↓", + "folder": "", + "clock": "", + "dollar": "" + }, + "fallback": { + "separator": "|", + "separator_alt": "|", + "branch": "", + "dirty": "*", + "clean": "", + "ahead": "+", + "behind": "-", + "folder": "", + "clock": "", + "dollar": "$" + } + }, + "presets": { + "standard": [ + [ + "model", + "provider", + "project", + "spacer", + "vcs" + ], + [ + "context_bar", + "cost", + "lines_changed", + "duration", + "tools" + ] + ], + "dense": [ + [ + "model", + "provider", + "project", + "vcs", + "context_bar", + "cost", + "duration" + ] + ], + "verbose": [ + [ + "model", + "provider", + "project", + "vcs", + "beads" + ], + [ + "context_bar", + "tokens_raw", + "cache_efficiency", + "cost", + "cost_velocity" + ], + [ + "lines_changed", + "duration", + "tools", + "turns", + "load", + "version" + ] + ] + }, + "layout": "verbose", + "sections": { + "model": { + "enabled": true, + "priority": 1 + }, + "provider": { + "enabled": true, + "priority": 2 + }, + "project": { + "enabled": true, + "priority": 1, + "truncate": { + "enabled": true, + "max": 30, + "style": "middle" + } + }, + "vcs": { + "enabled": true, + "priority": 1, + "min_width": 8, + "prefer": "auto", + "show_ahead_behind": true, + "show_dirty": true, + "truncate": { + "enabled": true, + "max": 25, + "style": "right" + }, + "ttl": { + "branch": 3, + "dirty": 5, + "ahead_behind": 30 + } + }, + "beads": { + "enabled": true, + "priority": 3, + "show_wip": true, + "show_wip_count": true, + "show_ready_count": true, + "show_open_count": true, + "show_closed_count": true, + "ttl": 30 + }, + "context_bar": { + "enabled": true, + "priority": 1, + "flex": true, + "min_width": 15, + "bar_width": 10, + "thresholds": { + "warn": 50, + "danger": 70, + "critical": 85 + } + }, + "context_usage": { + "enabled": false, + "priority": 2, + "capacity": 200000, + "thresholds": { + "warn": 50, + "danger": 70, + "critical": 85 + } + }, + "tokens_raw": { + "enabled": true, + "priority": 3, + "format": "{input} in/{output} out" + }, + "cache_efficiency": { + "enabled": true, + "priority": 3 + }, + "cost": { + "enabled": true, + "priority": 1, + "thresholds": { + "warn": 5.00, + "danger": 8.00, + "critical": 10.00 + } + }, + "cost_velocity": { + "enabled": true, + "priority": 3 + }, + "token_velocity": { + "enabled": false, + "priority": 3 + }, + "cost_trend": { + "enabled": false, + "priority": 3, + "width": 8 + }, + "context_trend": { + "enabled": false, + "priority": 3, + "width": 8, + "thresholds": { + "warn": 50, + "danger": 70, + "critical": 85 + } + }, + "lines_changed": { + "enabled": true, + "priority": 2 + }, + "duration": { + "enabled": true, + "priority": 2 + }, + "tools": { + "enabled": true, + "priority": 2, + "min_width": 6, + "show_last_name": true, + "ttl": 2 + }, + "turns": { + "enabled": true, + "priority": 3, + "ttl": 2 + }, + "load": { + "enabled": true, + "priority": 3, + "ttl": 10 + }, + "version": { + "enabled": false, + "priority": 3 + }, + "time": { + "enabled": false, + "priority": 3, + "format": "%H:%M" + }, + "output_style": { + "enabled": false, + "priority": 3 + }, + "hostname": { + "enabled": true, + "priority": 3 + } + }, + "custom": [] +} diff --git a/examples/custom-commands.json b/examples/custom-commands.json new file mode 100644 index 0000000..2ff5209 --- /dev/null +++ b/examples/custom-commands.json @@ -0,0 +1,31 @@ +{ + "version": 1, + + "layout": [ + ["model", "provider", "project", "vcs"], + ["context_bar", "cost", "duration", "ollama", "docker"] + ], + + "custom": [ + { + "id": "ollama", + "label": "ollama", + "command": "pgrep -x ollama >/dev/null && echo 'up' || echo 'down'", + "ttl": 30, + "priority": 3, + "color": { + "match": { "up": "green", "down": "dim" } + } + }, + { + "id": "docker", + "label": "docker", + "command": "docker info >/dev/null 2>&1 && echo 'up' || echo 'down'", + "ttl": 60, + "priority": 3, + "color": { + "match": { "up": "green", "down": "dim" } + } + } + ] +} diff --git a/examples/dense.json b/examples/dense.json new file mode 100644 index 0000000..fdb0d3e --- /dev/null +++ b/examples/dense.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "layout": "dense", + "sections": { + "context_bar": { + "enabled": true, "priority": 1, "flex": true, "min_width": 10, + "bar_width": 8 + } + } +} diff --git a/examples/user-config.json b/examples/user-config.json new file mode 100644 index 0000000..9980e82 --- /dev/null +++ b/examples/user-config.json @@ -0,0 +1,23 @@ +{ + "comment": "User config — only include overrides. Merged with defaults.json at runtime.", + "layout": "verbose", + "global": { + "justify": "left", + "width_margin": 4 + }, + "sections": { + "version": { + "enabled": true + }, + "hostname": { + "enabled": false + }, + "cost": { + "thresholds": { + "warn": 2.00, + "danger": 5.00, + "critical": 10.00 + } + } + } +} diff --git a/examples/verbose.json b/examples/verbose.json new file mode 100644 index 0000000..b92cf0a --- /dev/null +++ b/examples/verbose.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "layout": "verbose", + "sections": { + "tokens_raw": { "enabled": true, "priority": 3 }, + "cache_efficiency": { "enabled": true, "priority": 3 }, + "cost_velocity": { "enabled": true, "priority": 3 }, + "turns": { "enabled": true, "priority": 3, "ttl": 2 }, + "load": { "enabled": true, "priority": 3, "ttl": 10 }, + "version": { "enabled": true, "priority": 3 } + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ca932e4 --- /dev/null +++ b/install.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# install.sh — Set up claude-statusline symlinks +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAUDE_DIR="$HOME/.claude" + +echo "claude-statusline installer" +echo "===========================" +echo "" + +# Check dependencies +if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required but not installed." + echo " macOS: brew install jq" + echo " Ubuntu: sudo apt install jq" + echo " Fedora: sudo dnf install jq" + exit 1 +fi +echo "[ok] jq found" + +# Check bash version +if (( BASH_VERSINFO[0] < 4 )); then + echo "WARNING: bash 4+ recommended (you have ${BASH_VERSION})" + echo " macOS ships bash 3. Install bash 4+:" + echo " brew install bash" +fi + +# Ensure ~/.claude exists +mkdir -p "$CLAUDE_DIR" + +# Create symlinks +create_link() { + local src="$1" dst="$2" name="$3" + if [[ -L "$dst" ]]; then + local existing + existing="$(readlink "$dst")" + if [[ "$existing" == "$src" ]]; then + echo "[ok] $name already linked" + return + fi + echo "[update] $name: updating symlink" + ln -sf "$src" "$dst" + elif [[ -f "$dst" ]]; then + echo "[skip] $name: $dst exists as a regular file" + echo " To use the symlink, rename or remove the existing file first." + return + else + ln -s "$src" "$dst" + echo "[ok] $name linked" + fi +} + +create_link "$SCRIPT_DIR/statusline.sh" "$CLAUDE_DIR/statusline.sh" "statusline.sh" + +# Optionally link user config if they want to start from an example +if [[ ! -f "$CLAUDE_DIR/statusline.json" ]]; then + echo "[info] No statusline.json found. You can copy an example from:" + echo " $SCRIPT_DIR/examples/" +fi + +echo "" +echo "Symlinks created. Now add the status line to your Claude Code settings." +echo "" +echo "Add this to ~/.claude/settings.json:" +echo "" +echo ' "statusLine": "'$CLAUDE_DIR'/statusline.sh"' +echo "" +echo "If ~/.claude/settings.json doesn't exist yet, create it:" +echo "" +echo ' {' +echo ' "statusLine": "'$CLAUDE_DIR'/statusline.sh"' +echo ' }' +echo "" +echo "Then restart Claude Code to see the status line." diff --git a/rust_prd.md b/rust_prd.md new file mode 100644 index 0000000..0c8401d --- /dev/null +++ b/rust_prd.md @@ -0,0 +1,3091 @@ +# Rust Port: claude-statusline + +## Why + +The bash version (~2236 lines) spawns ~233 subshells per render. Each fork+exec costs ~1-3ms on macOS. Measured: **670ms average per render** against a 300ms render cycle. The perf floor is structural. A Rust port targets **<1ms warm, <5ms cold**. + +## Contract + +- **Input**: JSON on stdin from Claude Code (model, cost, tokens, context, workspace, version, output_style) +- **Output**: Multi-line ANSI-colored text on stdout +- **Config**: JSON deep-merged with embedded defaults. Search order: `--config <path>`, `CLAUDE_STATUSLINE_CONFIG`, `$XDG_CONFIG_HOME/claude/statusline.json`, `~/.config/claude/statusline.json`, `~/.claude/statusline.json` +- **CLI**: `--help`, `--test`, `--dump-state[=text|json]`, `--validate-config`, `--config-schema`, `--list-sections`, `--print-defaults`, `--config <path>`, `--no-cache`, `--no-shell`, `--clear-cache`, `--width <cols>`, `--color=auto|always|never` +- **Env**: `NO_COLOR`, `CLAUDE_STATUSLINE_COLOR`, `CLAUDE_STATUSLINE_WIDTH`, `CLAUDE_STATUSLINE_NO_CACHE`, `CLAUDE_STATUSLINE_NO_SHELL` +- **Cache**: File-based in `/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/` with per-key TTLs (compatible with bash version) +- **VCS**: Shell out to git/jj (not libgit2) + +--- + +## Crate Structure: Single package with lib + bin + +No workspace. Keep one package, but split core logic into `src/lib.rs` +and a thin `src/bin/claude-statusline.rs` wrapper. This keeps the deliverable +a single binary while making tests/benchmarks reuse library code. + +``` +Cargo.toml +build.rs # embed defaults.json + schema.json +src/ + lib.rs # Core API: parse, render, layout + bin/ + claude-statusline.rs # CLI parsing, stdin, orchestration + error.rs # Error enum with From impls + config.rs # Config loading, deep merge, typed structs + input.rs # InputData struct (serde from stdin JSON) + theme.rs # Theme detection (COLORFGBG, config override) + color.rs # ANSI codes, palette resolution, color_by_name() + glyph.rs # Glyph system (Nerd Font + ASCII fallback) + width.rs # Terminal width detection (ioctl + process tree walk chain), memoized + cache.rs # File-based caching, secure dir, TTL via mtime + trend.rs # Trend tracking + sparkline (8 Unicode blocks) + format.rs # human_tokens, human_duration, truncation (grapheme-safe), apply_formatting + shell.rs # exec_with_timeout, GIT_ENV, parse_git_status_v2, circuit breaker + metrics.rs # Derived metrics from input (cost velocity, token velocity, usage %, totals) + section/ + mod.rs # SectionOutput{raw,ansi}, dispatch(RenderContext), registry + metadata + model.rs # [Opus 4.6] - bold + provider.rs # Bedrock/Vertex/Anthropic - dim + project.rs # dirname - cyan + vcs.rs # branch+dirty+ahead/behind - combined git status, jj shell-out + beads.rs # br status - shell-out + context_bar.rs # [====------] 58% - threshold colors, flex rebuild + context_usage.rs # 115k/200k - threshold colors + context_remaining.rs # 85k left - threshold colors + tokens_raw.rs # progressive disclosure by width tier + cache_efficiency.rs # cache:83% - dim/green/boldgreen + cost.rs # $0.42 - threshold colors, width-tier decimals + cost_velocity.rs # $0.03/m - dim + token_velocity.rs # 14.5ktok/m - dim + cost_trend.rs # sparkline - dim + context_trend.rs # sparkline - threshold colors + lines_changed.rs # +156 -23 - green/red + duration.rs # 14m - dim + tools.rs # 7 tools (Edit) - dim, progressive + turns.rs # 12 turns - dim + load.rs # load:2.1 - dim, shell-out + version.rs # v1.0.80 - dim + time.rs # 14:30 - dim + output_style.rs # learning - magenta + hostname.rs # myhost - dim + custom.rs # user commands: `exec` argv or `bash -c`, optional JSON output + layout/ + mod.rs # resolve_layout, render_line, assembly + priority.rs # drop tier 3 (all at once), then tier 2 + flex.rs # spacer > non-spacer; context_bar rebuild + justify.rs # spread/space-between gap distribution +defaults.json # embedded via include_str!() +schema.json # shipped alongside or embedded +``` + +--- + +## Cargo.toml + +```toml +[package] +name = "claude-statusline" +version = "0.1.0" +edition = "2021" +description = "Fast, configurable status line for Claude Code" +license = "MIT" +repository = "https://github.com/tayloreernisse/claude-statusline" + +[[bin]] +name = "claude-statusline" +path = "src/bin/claude-statusline.rs" + +[lib] +name = "claude_statusline" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +md-5 = "0.10" # cache dir compat with bash (12-char hex of project path) +unicode-width = "0.2" # display width for CJK, emoji, Nerd Font glyphs +unicode-segmentation = "1" # grapheme-cluster-aware truncation +libc = "0.2" # ioctl, flock, and low-level TTY checks +serde_path_to_error = "0.1" # precise error paths for --validate-config +serde_ignored = "0.1" # warn on unknown config keys during normal runs + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "render" +harness = false + +[profile.release] +lto = true +codegen-units = 1 +strip = true +``` + +No clap (4 flags = manual parsing). No regex (simple string ops). No chrono (libc strftime or manual). No colored/owo-colors (10 ANSI codes as const strings). + +--- + +## build.rs — Embed defaults and schema + +```rust +fn main() { + println!("cargo:rerun-if-changed=defaults.json"); + println!("cargo:rerun-if-changed=schema.json"); +} +``` + +The actual embedding uses `include_str!()` in `config.rs`. `build.rs` only ensures rebuilds trigger on file changes. `--config-schema` and `--print-defaults` stream embedded JSON to stdout. + +--- + +## src/error.rs + +```rust +use std::fmt; +use std::io; + +#[derive(Debug)] +pub enum Error { + Io(io::Error), + Json(serde_json::Error), + ConfigNotFound(String), + EmptyStdin, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "io: {e}"), + Self::Json(e) => write!(f, "json: {e}"), + Self::ConfigNotFound(p) => write!(f, "config not found: {p}"), + Self::EmptyStdin => write!(f, "empty stdin"), + } + } +} + +impl std::error::Error for Error {} + +impl From<io::Error> for Error { + fn from(e: io::Error) -> Self { Self::Io(e) } +} + +impl From<serde_json::Error> for Error { + fn from(e: serde_json::Error) -> Self { Self::Json(e) } +} +``` + +--- + +## src/input.rs — Stdin JSON deserialization + +All fields are `Option` with `#[serde(default)]` — Claude Code may omit any field. + +```rust +use serde::Deserialize; + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct InputData { + pub model: Option<ModelInfo>, + pub cost: Option<CostInfo>, + pub context_window: Option<ContextWindow>, + pub workspace: Option<Workspace>, + pub version: Option<String>, + pub output_style: Option<OutputStyle>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ModelInfo { + pub id: Option<String>, + pub display_name: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct CostInfo { + pub total_cost_usd: Option<f64>, + pub total_duration_ms: Option<u64>, + pub total_lines_added: Option<u64>, + pub total_lines_removed: Option<u64>, + pub total_tool_uses: Option<u64>, + pub last_tool_name: Option<String>, + pub total_turns: Option<u64>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ContextWindow { + pub used_percentage: Option<f64>, + pub total_input_tokens: Option<u64>, + pub total_output_tokens: Option<u64>, + pub context_window_size: Option<u64>, + pub current_usage: Option<CurrentUsage>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct CurrentUsage { + pub cache_read_input_tokens: Option<u64>, + pub cache_creation_input_tokens: Option<u64>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Workspace { + pub project_dir: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct OutputStyle { + pub name: Option<String>, +} +``` + +### Complete stdin JSON shape + +Claude Code pipes this every ~300ms: + +```json +{ + "model": { + "id": "claude-opus-4-6-20260101", + "display_name": "Opus 4.6" + }, + "cost": { + "total_cost_usd": 0.42, + "total_duration_ms": 840000, + "total_lines_added": 156, + "total_lines_removed": 23, + "total_tool_uses": 7, + "last_tool_name": "Edit", + "total_turns": 12 + }, + "context_window": { + "used_percentage": 58.5, + "total_input_tokens": 115000, + "total_output_tokens": 8500, + "context_window_size": 200000, + "current_usage": { + "cache_read_input_tokens": 75000, + "cache_creation_input_tokens": 15000 + } + }, + "workspace": { + "project_dir": "/Users/taylor/projects/foo" + }, + "version": "1.0.80", + "output_style": { + "name": "learning" + } +} +``` + +--- + +## src/config.rs — Fully typed config with deep merge + +The `#[serde(flatten)]` pattern lets every section inherit `SectionBase` fields (enabled, priority, flex, min_width, prefix, suffix, pad, align, color) without repeating them. + +```rust +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; + +const DEFAULTS_JSON: &str = include_str!("../defaults.json"); + +// ── Top-level Config ──────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct Config { + pub version: u32, + pub global: GlobalConfig, + pub colors: ThemeColors, + pub glyphs: GlyphConfig, + pub presets: HashMap<String, Vec<Vec<String>>>, + pub layout: LayoutValue, + pub sections: Sections, + pub custom: Vec<CustomCommand>, +} + +impl Default for Config { + fn default() -> Self { + serde_json::from_str(DEFAULTS_JSON).expect("embedded defaults must parse") + } +} + +// ── Migration ─────────────────────────────────────────────────────────── +// Migrate older config versions to current (in-memory only). +// `--validate-config` reports the original version and applied migrations. + +// ── Layout: preset name or explicit array ─────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum LayoutValue { + Preset(String), + Custom(Vec<Vec<String>>), +} + +impl Default for LayoutValue { + fn default() -> Self { Self::Preset("standard".into()) } +} + +// ── Global settings ───────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct GlobalConfig { + pub separator: String, + pub justify: JustifyMode, + pub vcs: String, + pub width: Option<u16>, + pub width_margin: u16, + pub cache_dir: String, + pub cache_gc_days: u16, + pub cache_gc_interval_hours: u16, + pub cache_ttl_jitter_pct: u8, + pub responsive: bool, + pub breakpoints: Breakpoints, + pub render_budget_ms: u64, + pub theme: String, + pub color: ColorMode, + pub warn_unknown_keys: bool, + pub shell_enabled: bool, + pub shell_allowlist: Vec<String>, + pub shell_denylist: Vec<String>, + pub shell_timeout_ms: u64, + pub shell_max_output_bytes: usize, + pub shell_failure_threshold: u8, + pub shell_cooldown_ms: u64, + pub shell_env: HashMap<String, String>, + pub cache_version: u32, + pub drop_strategy: String, + pub breakpoint_hysteresis: u16, +} + +impl Default for GlobalConfig { + fn default() -> Self { + Self { + separator: " | ".into(), + justify: JustifyMode::Left, + vcs: "auto".into(), + width: None, + width_margin: 4, + cache_dir: "/tmp/claude-sl-{session_id}".into(), + cache_gc_days: 7, + cache_gc_interval_hours: 24, + cache_ttl_jitter_pct: 10, + responsive: true, + breakpoints: Breakpoints::default(), + render_budget_ms: 8, + theme: "auto".into(), + color: ColorMode::Auto, + warn_unknown_keys: true, + shell_enabled: true, + shell_allowlist: Vec::new(), + shell_denylist: Vec::new(), + shell_timeout_ms: 200, + shell_max_output_bytes: 8192, + shell_failure_threshold: 3, + shell_cooldown_ms: 30_000, + shell_env: HashMap::new(), + cache_version: 1, + drop_strategy: "tiered".into(), + breakpoint_hysteresis: 2, + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum JustifyMode { + #[default] + Left, + Spread, + SpaceBetween, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ColorMode { + #[default] + Auto, + Always, + Never, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct Breakpoints { + pub narrow: u16, + pub medium: u16, +} + +impl Default for Breakpoints { + fn default() -> Self { Self { narrow: 60, medium: 100 } } +} + +// ── Color palettes ────────────────────────────────────────────────────── + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ThemeColors { + pub dark: HashMap<String, String>, + pub light: HashMap<String, String>, +} + +// ── Glyph config ──────────────────────────────────────────────────────── + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct GlyphConfig { + pub enabled: bool, + pub set: HashMap<String, String>, + pub fallback: HashMap<String, String>, +} + +// ── Shared section base (flattened into each section) ─────────────────── + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SectionBase { + pub enabled: bool, + pub priority: u8, + pub flex: bool, + pub min_width: Option<u16>, + pub prefix: Option<String>, + pub suffix: Option<String>, + pub pad: Option<u16>, + pub align: Option<String>, + pub color: Option<String>, +} + +impl Default for SectionBase { + fn default() -> Self { + Self { + enabled: true, + priority: 2, + flex: false, + min_width: None, + prefix: None, + suffix: None, + pad: None, + align: None, + color: None, + } + } +} + +// ── Per-section typed configs ─────────────────────────────────────────── + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Sections { + pub model: SectionBase, + pub provider: SectionBase, + pub project: ProjectSection, + pub vcs: VcsSection, + pub beads: BeadsSection, + pub context_bar: ContextBarSection, + pub context_usage: ContextUsageSection, + pub context_remaining: ContextRemainingSection, + pub tokens_raw: TokensRawSection, + pub cache_efficiency: SectionBase, + pub cost: CostSection, + pub cost_velocity: SectionBase, + pub token_velocity: SectionBase, + pub cost_trend: TrendSection, + pub context_trend: ContextTrendSection, + pub lines_changed: SectionBase, + pub duration: SectionBase, + pub tools: ToolsSection, + pub turns: CachedSection, + pub load: CachedSection, + pub version: SectionBase, + pub time: TimeSection, + pub output_style: SectionBase, + pub hostname: SectionBase, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ProjectSection { + #[serde(flatten)] + pub base: SectionBase, + pub truncate: TruncateConfig, +} + +impl Default for ProjectSection { + fn default() -> Self { + Self { + base: SectionBase { priority: 1, ..Default::default() }, + truncate: TruncateConfig { enabled: true, max: 30, style: "middle".into() }, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct TruncateConfig { + pub enabled: bool, + pub max: usize, + pub style: String, +} + +impl Default for TruncateConfig { + fn default() -> Self { Self { enabled: false, max: 0, style: "right".into() } } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct VcsSection { + #[serde(flatten)] + pub base: SectionBase, + pub prefer: String, + pub show_ahead_behind: bool, + pub show_dirty: bool, + pub untracked: String, + pub submodules: bool, + pub fast_mode: bool, + pub truncate: TruncateConfig, + pub ttl: VcsTtl, +} + +impl Default for VcsSection { + fn default() -> Self { + Self { + base: SectionBase { priority: 1, min_width: Some(8), ..Default::default() }, + prefer: "auto".into(), + show_ahead_behind: true, + show_dirty: true, + untracked: "normal".into(), + submodules: false, + fast_mode: false, + truncate: TruncateConfig { enabled: true, max: 25, style: "right".into() }, + ttl: VcsTtl::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct VcsTtl { + pub branch: u64, + pub dirty: u64, + pub ahead_behind: u64, +} + +impl Default for VcsTtl { + fn default() -> Self { Self { branch: 3, dirty: 5, ahead_behind: 30 } } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct BeadsSection { + #[serde(flatten)] + pub base: SectionBase, + pub show_wip: bool, + pub show_wip_count: bool, + pub show_ready_count: bool, + pub show_open_count: bool, + pub show_closed_count: bool, + pub ttl: u64, +} + +impl Default for BeadsSection { + fn default() -> Self { + Self { + base: SectionBase { priority: 3, ..Default::default() }, + show_wip: true, show_wip_count: true, + show_ready_count: true, show_open_count: true, show_closed_count: true, + ttl: 30, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextBarSection { + #[serde(flatten)] + pub base: SectionBase, + pub bar_width: u16, + pub thresholds: Thresholds, +} + +impl Default for ContextBarSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 1, flex: true, min_width: Some(15), ..Default::default() + }, + bar_width: 10, + thresholds: Thresholds { warn: 50.0, danger: 70.0, critical: 85.0 }, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Thresholds { + pub warn: f64, + pub danger: f64, + pub critical: f64, +} + +impl Default for Thresholds { + fn default() -> Self { Self { warn: 50.0, danger: 70.0, critical: 85.0 } } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextUsageSection { + #[serde(flatten)] + pub base: SectionBase, + pub capacity: u64, + pub thresholds: Thresholds, +} + +impl Default for ContextUsageSection { + fn default() -> Self { + Self { + base: SectionBase { enabled: false, priority: 2, ..Default::default() }, + capacity: 200_000, + thresholds: Thresholds::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextRemainingSection { + #[serde(flatten)] + pub base: SectionBase, + pub format: String, + pub thresholds: Thresholds, +} + +impl Default for ContextRemainingSection { + fn default() -> Self { + Self { + base: SectionBase { enabled: false, priority: 2, ..Default::default() }, + format: "{remaining} left".into(), + thresholds: Thresholds::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct TokensRawSection { + #[serde(flatten)] + pub base: SectionBase, + pub format: String, +} + +impl Default for TokensRawSection { + fn default() -> Self { + Self { + base: SectionBase { priority: 3, ..Default::default() }, + format: "{input} in/{output} out".into(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct CostSection { + #[serde(flatten)] + pub base: SectionBase, + pub thresholds: Thresholds, +} + +impl Default for CostSection { + fn default() -> Self { + Self { + base: SectionBase { priority: 1, ..Default::default() }, + thresholds: Thresholds { warn: 5.0, danger: 8.0, critical: 10.0 }, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct TrendSection { + #[serde(flatten)] + pub base: SectionBase, + pub width: u8, +} + +impl Default for TrendSection { + fn default() -> Self { + Self { base: SectionBase { priority: 3, ..Default::default() }, width: 8 } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextTrendSection { + #[serde(flatten)] + pub base: SectionBase, + pub width: u8, + pub thresholds: Thresholds, +} + +impl Default for ContextTrendSection { + fn default() -> Self { + Self { + base: SectionBase { priority: 3, ..Default::default() }, + width: 8, + thresholds: Thresholds::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ToolsSection { + #[serde(flatten)] + pub base: SectionBase, + pub show_last_name: bool, + pub ttl: u64, +} + +impl Default for ToolsSection { + fn default() -> Self { + Self { + base: SectionBase { priority: 2, min_width: Some(6), ..Default::default() }, + show_last_name: true, + ttl: 2, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct CachedSection { + #[serde(flatten)] + pub base: SectionBase, + pub ttl: u64, +} + +impl Default for CachedSection { + fn default() -> Self { + Self { base: SectionBase { priority: 3, ..Default::default() }, ttl: 10 } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct TimeSection { + #[serde(flatten)] + pub base: SectionBase, + pub format: String, +} + +impl Default for TimeSection { + fn default() -> Self { + Self { + base: SectionBase { enabled: false, priority: 3, ..Default::default() }, + format: "%H:%M".into(), + } + } +} + +// ── Custom command sections ───────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +pub struct CustomCommand { + pub id: String, + #[serde(default)] + pub command: Option<String>, + #[serde(default)] + pub exec: Option<Vec<String>>, + #[serde(default)] + pub format: Option<String>, + #[serde(default)] + pub label: Option<String>, + #[serde(default = "default_custom_ttl")] + pub ttl: u64, + #[serde(default = "default_priority")] + pub priority: u8, + #[serde(default)] + pub flex: bool, + #[serde(default)] + pub min_width: Option<u16>, + #[serde(default)] + pub color: Option<CustomColor>, + #[serde(default)] + pub default_color: Option<String>, + #[serde(default)] + pub prefix: Option<String>, + #[serde(default)] + pub suffix: Option<String>, + #[serde(default)] + pub pad: Option<u16>, + #[serde(default)] + pub align: Option<String>, +} + +fn default_custom_ttl() -> u64 { 30 } +fn default_priority() -> u8 { 2 } + +#[derive(Debug, Clone, Deserialize)] +pub struct CustomColor { + #[serde(rename = "match", default)] + pub match_map: HashMap<String, String>, +} + +// ── Deep merge ────────────────────────────────────────────────────────── + +/// Recursive JSON merge: user values win, arrays replaced entirely. +pub fn deep_merge(base: &mut Value, patch: &Value) { + match (base, patch) { + (Value::Object(base_map), Value::Object(patch_map)) => { + for (k, v) in patch_map { + let entry = base_map.entry(k.clone()).or_insert(Value::Null); + deep_merge(entry, v); + } + } + (base, patch) => { + *base = patch.clone(); + } + } +} + +// ── Config loading ────────────────────────────────────────────────────── + +/// Load config: embedded defaults deep-merged with user overrides. +/// Returns (Config, Vec<String>) where the Vec contains unknown-key warnings. +pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec<String>), crate::Error> { + let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?; + + let user_path = explicit_path + .map(|p| std::path::PathBuf::from(p)) + .or_else(|| std::env::var("CLAUDE_STATUSLINE_CONFIG").ok().map(Into::into)) + .or_else(xdg_config_path) + .or_else(dot_config_path) + .unwrap_or_else(|| { + let mut p = dirs_home().unwrap_or_default(); + p.push(".claude/statusline.json"); + p + }); + + if user_path.exists() { + let user_json: Value = serde_json::from_str(&std::fs::read_to_string(&user_path)?)?; + deep_merge(&mut base, &user_json); + } else if explicit_path.is_some() { + return Err(crate::Error::ConfigNotFound(user_path.display().to_string())); + } + + // Deserialize with unknown-key capture + let mut warnings = Vec::new(); + let config: Config = serde_ignored::deserialize( + base, + |path| warnings.push(format!("unknown config key: {path}")), + )?; + + Ok((config, warnings)) +} + +fn xdg_config_path() -> Option<std::path::PathBuf> { + let val = std::env::var("XDG_CONFIG_HOME").ok()?; + let mut p = std::path::PathBuf::from(val); + p.push("claude/statusline.json"); + if p.exists() { Some(p) } else { None } +} + +fn dot_config_path() -> Option<std::path::PathBuf> { + let mut p = dirs_home()?; + p.push(".config/claude/statusline.json"); + if p.exists() { Some(p) } else { None } +} + +fn dirs_home() -> Option<std::path::PathBuf> { + std::env::var("HOME").ok().map(Into::into) +} +``` + +### Config system rules + +1. Embedded `defaults.json` (compiled in via `include_str!()`) +2. User config from `--config <path>` / `$CLAUDE_STATUSLINE_CONFIG` / `$XDG_CONFIG_HOME/claude/statusline.json` / `~/.config/claude/statusline.json` / `~/.claude/statusline.json` +3. Deep merge: recursive `serde_json::Value` merge (user wins, arrays replaced entirely) +4. Migrate older config versions to current schema (in-memory only) +5. Deserialize merged JSON into typed `Config` struct +6. Capture unknown keys via `serde_ignored` and emit warnings by default. `global.warn_unknown_keys` toggles warnings; `--validate-config` uses `serde_path_to_error` for precise field paths and reports original version + applied migrations. +7. Color mode: `global.color` (auto|always|never). `NO_COLOR` forces never. `auto` disables color when stdout is not a TTY or `TERM=dumb`. + +--- + +## src/theme.rs — COLORFGBG detection + +```rust +use crate::config::Config; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Theme { + Dark, + Light, +} + +impl Theme { + pub fn as_str(&self) -> &'static str { + match self { + Self::Dark => "dark", + Self::Light => "light", + } + } +} + +/// Detection priority: +/// 1. Config override (global.theme = "dark" or "light") +/// 2. COLORFGBG env var: parse bg from "fg;bg", bg 9-15 = light, 0-8 = dark +/// 3. Default: dark +pub fn detect_theme(config: &Config) -> Theme { + match config.global.theme.as_str() { + "dark" => return Theme::Dark, + "light" => return Theme::Light, + _ => {} + } + + if let Ok(val) = std::env::var("COLORFGBG") { + if let Some(bg_str) = val.rsplit(';').next() { + if let Ok(bg) = bg_str.parse::<u8>() { + return if bg > 8 && bg < 16 { Theme::Light } else { Theme::Dark }; + } + } + } + + Theme::Dark +} +``` + +--- + +## src/color.rs — ANSI constants + resolve_color + +10 ANSI escape codes as `const` strings. No external crate. + +```rust +use crate::config::ThemeColors; +use crate::theme::Theme; + +pub const RESET: &str = "\x1b[0m"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const RED: &str = "\x1b[31m"; +pub const GREEN: &str = "\x1b[32m"; +pub const YELLOW: &str = "\x1b[33m"; +pub const BLUE: &str = "\x1b[34m"; +pub const MAGENTA: &str = "\x1b[35m"; +pub const CYAN: &str = "\x1b[36m"; +pub const WHITE: &str = "\x1b[37m"; + +/// Resolve a color name to ANSI escape sequence(s). +/// +/// Supports: +/// - Palette references: `"p:success"` -> look up in theme palette, resolve recursively +/// - Compound styles: `"red bold"` -> concatenated ANSI codes +/// - Single names: `"green"` -> direct ANSI code +/// - Unknown: returns RESET +pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String { + // Handle palette reference + if let Some(key) = name.strip_prefix("p:") { + let map = match theme { + Theme::Dark => &palette.dark, + Theme::Light => &palette.light, + }; + if let Some(resolved) = map.get(key) { + return resolve_color(resolved, theme, palette); + } + return RESET.to_string(); + } + + // Handle compound styles ("red bold") + let mut result = String::new(); + for part in name.split_whitespace() { + result.push_str(match part { + "red" => RED, + "green" => GREEN, + "yellow" => YELLOW, + "blue" => BLUE, + "magenta" => MAGENTA, + "cyan" => CYAN, + "white" => WHITE, + "dim" => DIM, + "bold" => BOLD, + _ => "", + }); + } + + if result.is_empty() { RESET.to_string() } else { result } +} + +/// When color is disabled, returns empty strings instead of ANSI codes. +/// Determined by: `NO_COLOR` env, `--color=never`, or stdout is not a TTY. +pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool { + // NO_COLOR takes precedence (https://no-color.org/) + if std::env::var("NO_COLOR").is_ok() { + return false; + } + + // CLI --color flag overrides config + if let Some(flag) = cli_color { + return match flag { + "always" => true, + "never" => false, + _ => atty_stdout(), + }; + } + + match config_color { + crate::config::ColorMode::Always => true, + crate::config::ColorMode::Never => false, + crate::config::ColorMode::Auto => { + atty_stdout() && std::env::var("TERM").map_or(true, |t| t != "dumb") + } + } +} + +fn atty_stdout() -> bool { + unsafe { libc::isatty(libc::STDOUT_FILENO) != 0 } +} +``` + +--- + +## src/glyph.rs — Nerd Font + ASCII fallback + +```rust +use crate::config::GlyphConfig; + +/// Look up a named glyph. Returns Nerd Font icon when enabled, ASCII fallback otherwise. +pub fn glyph<'a>(name: &str, config: &'a GlyphConfig) -> &'a str { + if config.enabled { + if let Some(val) = config.set.get(name) { + if !val.is_empty() { + return val; + } + } + } + config.fallback.get(name).map(|s| s.as_str()).unwrap_or("") +} +``` + +--- + +## src/width.rs — Full detection chain with memoization + +```rust +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +static CACHED_WIDTH: Mutex<Option<(u16, Instant)>> = Mutex::new(None); +const WIDTH_TTL: Duration = Duration::from_secs(1); + +/// Detect terminal width. Memoized for 1 second across renders. +/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120 +pub fn detect_width(cli_width: Option<u16>, config_width: Option<u16>, config_margin: u16) -> u16 { + // Check memo first + if let Ok(guard) = CACHED_WIDTH.lock() { + if let Some((w, ts)) = *guard { + if ts.elapsed() < WIDTH_TTL { + return w; + } + } + } + + let raw = detect_raw(cli_width, config_width); + let effective = raw.saturating_sub(config_margin); + let effective = effective.max(40); // minimum sane width + + // Store in memo + if let Ok(mut guard) = CACHED_WIDTH.lock() { + *guard = Some((effective, Instant::now())); + } + + effective +} + +fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 { + // 1. --width CLI flag + if let Some(w) = cli_width { + if w > 0 { return w; } + } + + // 2. CLAUDE_STATUSLINE_WIDTH env var + if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") { + if let Ok(w) = val.parse::<u16>() { + if w > 0 { return w; } + } + } + + // 3. Config override + if let Some(w) = config_width { + if w > 0 { return w; } + } + + // 4. ioctl(TIOCGWINSZ) on stdout + if let Some(w) = ioctl_width(libc::STDOUT_FILENO) { + if w > 0 { return w; } + } + + // 5. Process tree walk: find ancestor with real TTY + if let Some(w) = process_tree_width() { + if w > 0 { return w; } + } + + // 6. stty size < /dev/tty + if let Some(w) = stty_dev_tty() { + if w > 0 { return w; } + } + + // 7. COLUMNS env var + if let Ok(val) = std::env::var("COLUMNS") { + if let Ok(w) = val.parse::<u16>() { + if w > 0 { return w; } + } + } + + // 8. tput cols + if let Some(w) = tput_cols() { + if w > 0 { return w; } + } + + // 9. Fallback + 120 +} + +fn ioctl_width(fd: i32) -> Option<u16> { + #[repr(C)] + struct Winsize { + ws_row: u16, + ws_col: u16, + ws_xpixel: u16, + ws_ypixel: u16, + } + + let mut ws = Winsize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0 }; + + // TIOCGWINSZ value differs by platform + #[cfg(target_os = "macos")] + const TIOCGWINSZ: libc::c_ulong = 0x40087468; + #[cfg(target_os = "linux")] + const TIOCGWINSZ: libc::c_ulong = 0x5413; + + let ret = unsafe { libc::ioctl(fd, TIOCGWINSZ, &mut ws) }; + if ret == 0 && ws.ws_col > 0 { Some(ws.ws_col) } else { None } +} + +/// Walk process tree from current PID, find ancestor with a real TTY, +/// then query its width via stty. +fn process_tree_width() -> Option<u16> { + let mut pid = std::process::id(); + + while pid > 1 { + // Read TTY from /dev/fd or ps + let output = std::process::Command::new("ps") + .args(["-o", "tty=", "-p", &pid.to_string()]) + .output() + .ok()?; + + let tty = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !tty.is_empty() && tty != "??" && tty != "-" { + let dev_path = format!("/dev/{tty}"); + if let Ok(out) = std::process::Command::new("stty") + .arg("size") + .stdin(std::fs::File::open(&dev_path).ok()?) + .output() + { + let s = String::from_utf8_lossy(&out.stdout); + if let Some(cols_str) = s.split_whitespace().nth(1) { + if let Ok(w) = cols_str.parse::<u16>() { + return Some(w); + } + } + } + } + + // Walk to parent + let ppid_out = std::process::Command::new("ps") + .args(["-o", "ppid=", "-p", &pid.to_string()]) + .output() + .ok()?; + let ppid_str = String::from_utf8_lossy(&ppid_out.stdout).trim().to_string(); + pid = ppid_str.parse().ok()?; + } + + None +} + +fn stty_dev_tty() -> Option<u16> { + let tty = std::fs::File::open("/dev/tty").ok()?; + let out = std::process::Command::new("stty") + .arg("size") + .stdin(tty) + .output() + .ok()?; + let s = String::from_utf8_lossy(&out.stdout); + s.split_whitespace().nth(1)?.parse().ok() +} + +fn tput_cols() -> Option<u16> { + let out = std::process::Command::new("tput").arg("cols").output().ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse().ok() +} + +// ── Width tiers for progressive disclosure ────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WidthTier { + Narrow, + Medium, + Wide, +} + +pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier { + if width < narrow_bp { + WidthTier::Narrow + } else if width < medium_bp { + WidthTier::Medium + } else { + WidthTier::Wide + } +} +``` + +--- + +## src/cache.rs — Secure dir, per-key TTL, flock, atomic writes + +```rust +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +pub struct Cache { + dir: Option<PathBuf>, +} + +impl Cache { + /// Create cache with secure directory. Returns disabled cache on failure. + pub fn new(template: &str, session_id: &str) -> Self { + let dir_str = template.replace("{session_id}", session_id); + let dir = PathBuf::from(&dir_str); + + if !dir.exists() { + if fs::create_dir_all(&dir).is_err() { + return Self { dir: None }; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); + } + } + + // Security: verify ownership, not a symlink, not world-writable + if !verify_cache_dir(&dir) { + return Self { dir: None }; + } + + Self { dir: Some(dir) } + } + + pub fn dir(&self) -> Option<&Path> { + self.dir.as_deref() + } + + /// Get cached value if fresher than TTL seconds. + pub fn get(&self, key: &str, ttl: Duration) -> Option<String> { + let path = self.key_path(key)?; + let meta = fs::metadata(&path).ok()?; + let age = meta.modified().ok()?.elapsed().ok()?; + if age < ttl { + fs::read_to_string(&path).ok() + } else { + None + } + } + + /// Get stale cached value (ignores TTL). Used as fallback on command failure. + pub fn get_stale(&self, key: &str) -> Option<String> { + let path = self.key_path(key)?; + fs::read_to_string(&path).ok() + } + + /// Atomic write: write to .tmp then rename (prevents partial reads). + /// Uses flock with LOCK_NB — skips cache on contention rather than blocking. + pub fn set(&self, key: &str, value: &str) -> Option<()> { + let path = self.key_path(key)?; + let tmp = path.with_extension("tmp"); + + // Try non-blocking flock + let lock_path = path.with_extension("lock"); + let lock_file = fs::File::create(&lock_path).ok()?; + if !try_flock(&lock_file) { + return None; // contention — skip cache write + } + + let mut f = fs::File::create(&tmp).ok()?; + f.write_all(value.as_bytes()).ok()?; + fs::rename(&tmp, &path).ok()?; + + unlock(&lock_file); + Some(()) + } + + fn key_path(&self, key: &str) -> Option<PathBuf> { + let dir = self.dir.as_ref()?; + let safe_key: String = key.chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }) + .collect(); + Some(dir.join(safe_key)) + } +} + +fn verify_cache_dir(dir: &Path) -> bool { + // Must exist, be a directory, not a symlink + let meta = match fs::symlink_metadata(dir) { + Ok(m) => m, + Err(_) => return false, + }; + if !meta.is_dir() || meta.file_type().is_symlink() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Must be owned by current user + if meta.uid() != unsafe { libc::getuid() } { + return false; + } + // Must not be world-writable + if meta.mode() & 0o002 != 0 { + return false; + } + } + + true +} + +fn try_flock(file: &fs::File) -> bool { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + ret == 0 + } + #[cfg(not(unix))] + { true } +} + +fn unlock(file: &fs::File) { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN); } + } +} + +/// Session ID: first 12 chars of MD5 hex of project_dir. +/// Same algorithm as bash for cache compatibility during migration. +pub fn session_id(project_dir: &str) -> String { + use md5::{Md5, Digest}; + let hash = Md5::digest(project_dir.as_bytes()); + format!("{:x}", hash)[..12].to_string() +} +``` + +--- + +## src/trend.rs — Append with throttle, sparkline with flat-series guard + +```rust +use crate::cache::Cache; +use std::time::Duration; + +const SPARKLINE_CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +/// Append a value to a trend file. Throttled to at most once per `interval`. +/// Returns the full comma-separated series (for immediate sparkline rendering). +pub fn append(cache: &Cache, key: &str, value: i64, max_points: usize, interval: Duration) -> Option<String> { + let trend_key = format!("trend_{key}"); + + // Check throttle: skip if last write was within interval + if let Some(existing) = cache.get(&trend_key, interval) { + // Not yet time to append — return existing series + return Some(existing); + } + + // Read current series (ignoring TTL) + let mut series: Vec<i64> = cache + .get_stale(&trend_key) + .unwrap_or_default() + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + // Skip if value unchanged from last point + if series.last() == Some(&value) { + // Still update the file mtime so throttle window resets + let csv = series.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(","); + cache.set(&trend_key, &csv); + return Some(csv); + } + + series.push(value); + + // Trim from left to max_points + if series.len() > max_points { + series = series[series.len() - max_points..].to_vec(); + } + + let csv = series.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(","); + cache.set(&trend_key, &csv); + Some(csv) +} + +/// Render a sparkline from comma-separated values. +/// 8 Unicode block chars, normalized to min/max range. +/// Flat-series guard: when min == max, render mid-height blocks (▄). +pub fn sparkline(csv: &str, width: usize) -> String { + let vals: Vec<i64> = csv.split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + if vals.is_empty() { + return String::new(); + } + + let min = *vals.iter().min().unwrap(); + let max = *vals.iter().max().unwrap(); + + let count = vals.len().min(width); + + if max == min { + // Flat series: mid-height block for all points + return std::iter::repeat('▄').take(count).collect(); + } + + let range = (max - min) as f64; + vals.iter() + .take(width) + .map(|&v| { + let idx = (((v - min) as f64 / range) * 7.0) as usize; + SPARKLINE_CHARS[idx.min(7)] + }) + .collect() +} +``` + +--- + +## src/format.rs — Formatting utilities + +```rust +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::color; +use crate::config::SectionBase; +use crate::theme::Theme; + +/// Display width using unicode-width (handles CJK, emoji, Nerd Font glyphs). +pub fn display_width(s: &str) -> usize { + UnicodeWidthStr::width(s) +} + +/// Format token count for human readability: 1500 -> "1.5k", 2000000 -> "2.0M" +pub fn human_tokens(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}k", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + +/// Format duration: 840000ms -> "14m", 7200000ms -> "2h0m", 45000ms -> "45s" +pub fn human_duration(ms: u64) -> String { + let secs = ms / 1000; + if secs >= 3600 { + format!("{}h{}m", secs / 3600, (secs % 3600) / 60) + } else if secs >= 60 { + format!("{}m", secs / 60) + } else { + format!("{secs}s") + } +} + +/// Grapheme-cluster-safe truncation. Never splits a grapheme. +pub fn truncate(s: &str, max: usize, style: &str) -> String { + let w = display_width(s); + if w <= max { + return s.to_string(); + } + + let graphemes: Vec<&str> = s.graphemes(true).collect(); + match style { + "middle" => truncate_middle(&graphemes, max), + "left" => truncate_left(&graphemes, max), + _ => truncate_right(&graphemes, max), + } +} + +fn truncate_right(graphemes: &[&str], max: usize) -> String { + let mut result = String::new(); + let mut w = 0; + for &g in graphemes { + let gw = UnicodeWidthStr::width(g); + if w + gw + 1 > max { break; } // +1 for ellipsis + result.push_str(g); + w += gw; + } + result.push('…'); + result +} + +fn truncate_middle(graphemes: &[&str], max: usize) -> String { + let half = (max - 1) / 2; // -1 for ellipsis + let mut left = String::new(); + let mut left_w = 0; + for &g in graphemes { + let gw = UnicodeWidthStr::width(g); + if left_w + gw > half { break; } + left.push_str(g); + left_w += gw; + } + + let right_budget = max - 1 - left_w; + let mut right_graphemes = Vec::new(); + let mut right_w = 0; + for &g in graphemes.iter().rev() { + let gw = UnicodeWidthStr::width(g); + if right_w + gw > right_budget { break; } + right_graphemes.push(g); + right_w += gw; + } + right_graphemes.reverse(); + + format!("{left}…{}", right_graphemes.join("")) +} + +fn truncate_left(graphemes: &[&str], max: usize) -> String { + let budget = max - 1; // -1 for ellipsis + let mut parts = Vec::new(); + let mut w = 0; + for &g in graphemes.iter().rev() { + let gw = UnicodeWidthStr::width(g); + if w + gw > budget { break; } + parts.push(g); + w += gw; + } + parts.reverse(); + format!("…{}", parts.join("")) +} + +/// Apply per-section formatting: prefix, suffix, color override, pad+align. +/// +/// Color override re-wraps `raw` text (discards section's internal ANSI), +/// matching bash behavior where `apply_formatting()` rebuilds the ANSI string. +pub fn apply_formatting( + raw: &mut String, + ansi: &mut String, + base: &SectionBase, + theme: Theme, + palette: &crate::config::ThemeColors, +) { + // 1. Prefix / suffix + if let Some(ref pfx) = base.prefix { + *raw = format!("{pfx}{raw}"); + *ansi = format!("{pfx}{ansi}"); + } + if let Some(ref sfx) = base.suffix { + raw.push_str(sfx); + ansi.push_str(sfx); + } + + // 2. Color override — re-wrap raw text + if let Some(ref color_name) = base.color { + let c = color::resolve_color(color_name, theme, palette); + *ansi = format!("{c}{raw}{}", color::RESET); + } + + // 3. Pad + align + if let Some(pad) = base.pad { + let pad = pad as usize; + let raw_w = display_width(raw); + if pad > raw_w { + let needed = pad - raw_w; + let spaces: String = " ".repeat(needed); + let align = base.align.as_deref().unwrap_or("left"); + match align { + "right" => { + *raw = format!("{spaces}{raw}"); + *ansi = format!("{spaces}{ansi}"); + } + "center" => { + let left = needed / 2; + let right = needed - left; + *raw = format!("{}{raw}{}", " ".repeat(left), " ".repeat(right)); + *ansi = format!("{}{ansi}{}", " ".repeat(left), " ".repeat(right)); + } + _ => { + raw.push_str(&spaces); + ansi.push_str(&spaces); + } + } + } + } +} +``` + +--- + +## src/shell.rs — exec_with_timeout, GIT_ENV, parse_git_status_v2 + +```rust +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; +use std::thread; + +/// Stable env for all git commands: no lock contention, no prompts, no locale variance. +const GIT_ENV: &[(&str, &str)] = &[ + ("GIT_OPTIONAL_LOCKS", "0"), + ("GIT_TERMINAL_PROMPT", "0"), + ("LC_ALL", "C"), +]; + +/// Execute a command with a polling timeout. No extra crate needed. +/// Returns None on timeout or error. +pub fn exec_with_timeout( + program: &str, + args: &[&str], + dir: Option<&str>, + timeout: Duration, +) -> Option<String> { + let mut cmd = Command::new(program); + cmd.args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + if let Some(d) = dir { + cmd.current_dir(d); + } + + // Apply GIT_ENV for git commands + if program == "git" { + for (k, v) in GIT_ENV { + cmd.env(k, v); + } + } + + let mut child = cmd.spawn().ok()?; + let start = Instant::now(); + + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let output = child.wait_with_output().ok()?; + return Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + Ok(None) => { + if start.elapsed() >= timeout { + let _ = child.kill(); + return None; + } + thread::sleep(Duration::from_millis(5)); + } + Err(_) => return None, + } + } +} + +/// Parsed result from `git status --porcelain=v2 --branch`. +/// Single call returns branch, dirty, and ahead/behind. +#[derive(Debug, Default)] +pub struct GitStatusV2 { + pub branch: Option<String>, + pub ahead: u32, + pub behind: u32, + pub is_dirty: bool, +} + +/// Parse combined `git status --porcelain=v2 --branch` output. +/// Three cache TTLs are preserved by caching sub-results independently, +/// but all three are populated from a single execution when any one expires. +pub fn parse_git_status_v2(output: &str) -> GitStatusV2 { + let mut result = GitStatusV2::default(); + + for line in output.lines() { + if let Some(rest) = line.strip_prefix("# branch.head ") { + result.branch = Some(rest.to_string()); + } else if let Some(rest) = line.strip_prefix("# branch.ab ") { + // Format: "+3 -1" + for part in rest.split_whitespace() { + if let Some(n) = part.strip_prefix('+') { + result.ahead = n.parse().unwrap_or(0); + } else if let Some(n) = part.strip_prefix('-') { + result.behind = n.parse().unwrap_or(0); + } + } + } else if line.starts_with('1') || line.starts_with('2') || line.starts_with('?') || line.starts_with('u') { + // Porcelain v2: 1=changed, 2=renamed, ?=untracked, u=unmerged + result.is_dirty = true; + } + } + + result +} +``` + +--- + +## src/section/mod.rs — Section dispatch and registry + +Function pointers (`fn(&RenderContext) -> Option<SectionOutput>`) — no traits. + +```rust +use crate::config::Config; +use crate::cache::Cache; +use crate::input::InputData; +use crate::theme::Theme; +use crate::width::WidthTier; + +use std::path::Path; + +/// What every section renderer returns. +#[derive(Debug, Clone)] +pub struct SectionOutput { + pub raw: String, // plain text (for width calculation) + pub ansi: String, // ANSI-colored text (for display) +} + +/// Type alias for section render functions. +pub type RenderFn = fn(&RenderContext) -> Option<SectionOutput>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VcsType { + Git, + Jj, + None, +} + +/// Context passed to every section renderer. +pub struct RenderContext<'a> { + pub input: &'a InputData, + pub config: &'a Config, + pub theme: Theme, + pub width_tier: WidthTier, + pub term_width: u16, + pub vcs_type: VcsType, + pub project_dir: &'a Path, + pub cache: &'a Cache, + pub glyphs_enabled: bool, + pub color_enabled: bool, + pub metrics: ComputedMetrics, +} + +/// Metadata for layout planning (before rendering). +/// Single source of truth for section IDs, validation, and CLI introspection. +pub struct SectionDescriptor { + pub id: &'static str, + pub render: RenderFn, + pub priority: u8, + pub is_spacer: bool, + pub is_flex: bool, + pub estimated_width: u16, + pub shell_out: bool, +} + +/// Build the registry of all built-in sections. +/// Maps section ID to its render function. +pub fn registry() -> Vec<(&'static str, RenderFn)> { + vec![ + ("model", super::section::model::render), + ("provider", super::section::provider::render), + ("project", super::section::project::render), + ("vcs", super::section::vcs::render), + ("beads", super::section::beads::render), + ("context_bar", super::section::context_bar::render), + ("context_usage", super::section::context_usage::render), + ("context_remaining", super::section::context_remaining::render), + ("tokens_raw", super::section::tokens_raw::render), + ("cache_efficiency", super::section::cache_efficiency::render), + ("cost", super::section::cost::render), + ("cost_velocity", super::section::cost_velocity::render), + ("token_velocity", super::section::token_velocity::render), + ("cost_trend", super::section::cost_trend::render), + ("context_trend", super::section::context_trend::render), + ("lines_changed", super::section::lines_changed::render), + ("duration", super::section::duration::render), + ("tools", super::section::tools::render), + ("turns", super::section::turns::render), + ("load", super::section::load::render), + ("version", super::section::version::render), + ("time", super::section::time::render), + ("output_style", super::section::output_style::render), + ("hostname", super::section::hostname::render), + ] +} + +/// Dispatch: look up section by ID and render it. +/// Returns None if section is disabled, missing data, or unknown. +pub fn render_section(id: &str, ctx: &RenderContext) -> Option<SectionOutput> { + if is_spacer(id) { + return Some(SectionOutput { raw: " ".into(), ansi: " ".into() }); + } + + // Try built-in + for (name, render_fn) in registry() { + if name == id { + return render_fn(ctx); + } + } + + // Try custom command + super::section::custom::render(id, ctx) +} + +pub fn is_spacer(id: &str) -> bool { + id == "spacer" || id.starts_with("_spacer") +} +``` + +--- + +## Representative Sections (4 of 24) + +### section/model.rs — Pure data, no shell-out + +```rust +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.model.enabled { return None; } + + let model = ctx.input.model.as_ref()?; + let id = model.id.as_deref().unwrap_or("?"); + let id_lower = id.to_ascii_lowercase(); + + let base_name = if id_lower.contains("opus") { "Opus" } + else if id_lower.contains("sonnet") { "Sonnet" } + else if id_lower.contains("haiku") { "Haiku" } + else { return Some(simple_output(model.display_name.as_deref().unwrap_or(id), ctx)) }; + + // Extract version: "opus-4-6" or "4-6-opus" -> "4.6" + let version = extract_version(&id_lower, base_name.to_ascii_lowercase().as_str()); + + let name = match version { + Some(v) => format!("{base_name} {v}"), + None => base_name.to_string(), + }; + + let raw = format!("[{name}]"); + let ansi = if ctx.color_enabled { + format!("{}[{name}]{}", color::BOLD, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} + +fn extract_version(id: &str, family: &str) -> Option<String> { + // Pattern: "opus-4-6" or "4-6-opus" + let parts: Vec<&str> = id.split('-').collect(); + for window in parts.windows(3) { + if window[0] == family { + if let (Ok(a), Ok(b)) = (window[1].parse::<u8>(), window[2].parse::<u8>()) { + return Some(format!("{a}.{b}")); + } + } + if window[2] == family { + if let (Ok(a), Ok(b)) = (window[0].parse::<u8>(), window[1].parse::<u8>()) { + return Some(format!("{a}.{b}")); + } + } + } + None +} + +fn simple_output(name: &str, ctx: &RenderContext) -> SectionOutput { + let raw = format!("[{name}]"); + let ansi = if ctx.color_enabled { + format!("{}[{name}]{}", color::BOLD, color::RESET) + } else { + raw.clone() + }; + SectionOutput { raw, ansi } +} +``` + +### section/cost.rs — Threshold colors, width-tier decimals + +```rust +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use crate::width::WidthTier; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.cost.base.enabled { return None; } + + let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?; + + let decimals = match ctx.width_tier { + WidthTier::Narrow => 0, + WidthTier::Medium => 2, + WidthTier::Wide => 4, + }; + + let cost_str = format!("{cost_val:.decimals$}"); + let raw = format!("${cost_str}"); + + let thresh = &ctx.config.sections.cost.thresholds; + let color_code = if cost_val >= thresh.critical { + format!("{}{}", color::RED, color::BOLD) + } else if cost_val >= thresh.danger { + color::RED.to_string() + } else if cost_val >= thresh.warn { + color::YELLOW.to_string() + } else { + color::GREEN.to_string() + }; + + let ansi = if ctx.color_enabled { + format!("{color_code}${cost_str}{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} +``` + +### section/context_bar.rs — Flex-expandable, threshold colors + +```rust +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +/// Render context bar at a given bar_width. Called both at initial render +/// and during flex expansion (with wider bar_width). +pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOutput> { + let pct = ctx.input.context_window.as_ref()?.used_percentage?; + let pct_int = pct.round() as u16; + + let filled = (pct_int as u32 * bar_width as u32 / 100) as usize; + let empty = bar_width as usize - filled; + + let bar: String = "=".repeat(filled) + &"-".repeat(empty); + let raw = format!("[{bar}] {pct_int}%"); + + let thresh = &ctx.config.sections.context_bar.thresholds; + let color_code = threshold_color(pct, thresh); + + let ansi = if ctx.color_enabled { + format!("{color_code}[{bar}] {pct_int}%{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.context_bar.base.enabled { return None; } + render_at_width(ctx, ctx.config.sections.context_bar.bar_width) +} + +fn threshold_color(pct: f64, thresh: &crate::config::Thresholds) -> String { + if pct >= thresh.critical { + format!("{}{}", color::RED, color::BOLD) + } else if pct >= thresh.danger { + color::RED.to_string() + } else if pct >= thresh.warn { + color::YELLOW.to_string() + } else { + color::GREEN.to_string() + } +} +``` + +### section/vcs.rs — Shell-out with combined git status + +```rust +use crate::color; +use crate::glyph; +use crate::section::{RenderContext, SectionOutput, VcsType}; +use crate::shell::{self, GitStatusV2}; +use crate::width::WidthTier; +use std::time::Duration; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.vcs.base.enabled { return None; } + if ctx.vcs_type == VcsType::None { return None; } + + let dir = ctx.project_dir.to_str()?; + let ttl = &ctx.config.sections.vcs.ttl; + let glyphs = &ctx.config.glyphs; + + match ctx.vcs_type { + VcsType::Git => render_git(ctx, dir, ttl, glyphs), + VcsType::Jj => render_jj(ctx, dir, ttl, glyphs), + VcsType::None => None, + } +} + +fn render_git( + ctx: &RenderContext, + dir: &str, + ttl: &crate::config::VcsTtl, + glyphs: &crate::config::GlyphConfig, +) -> Option<SectionOutput> { + // Try combined git status (populates all three sub-caches at once) + let branch_ttl = Duration::from_secs(ttl.branch); + let dirty_ttl = Duration::from_secs(ttl.dirty); + let ab_ttl = Duration::from_secs(ttl.ahead_behind); + let timeout = Duration::from_millis(200); + + // Check if any sub-cache is expired + let branch_cached = ctx.cache.get("vcs_branch", branch_ttl); + let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl); + let ab_cached = ctx.cache.get("vcs_ab", ab_ttl); + + let status = if branch_cached.is_none() || dirty_cached.is_none() || ab_cached.is_none() { + // Run combined command, populate all sub-caches + let output = shell::exec_with_timeout( + "git", &["-C", dir, "status", "--porcelain=v2", "--branch"], + None, timeout, + ); + match output { + Some(ref out) => { + let s = shell::parse_git_status_v2(out); + if let Some(ref b) = s.branch { + ctx.cache.set("vcs_branch", b); + } + ctx.cache.set("vcs_dirty", if s.is_dirty { "1" } else { "" }); + ctx.cache.set("vcs_ab", &format!("{} {}", s.ahead, s.behind)); + s + } + None => { + // Fall back to stale cache + GitStatusV2 { + branch: branch_cached.or_else(|| ctx.cache.get_stale("vcs_branch")), + is_dirty: dirty_cached.or_else(|| ctx.cache.get_stale("vcs_dirty")) + .map_or(false, |v| !v.is_empty()), + ahead: 0, + behind: 0, + } + } + } + } else { + GitStatusV2 { + branch: branch_cached, + is_dirty: dirty_cached.map_or(false, |v| !v.is_empty()), + ahead: ab_cached.as_ref() + .and_then(|s| s.split_whitespace().next()?.parse().ok()).unwrap_or(0), + behind: ab_cached.as_ref() + .and_then(|s| s.split_whitespace().nth(1)?.parse().ok()).unwrap_or(0), + } + }; + + let branch = status.branch.as_deref().unwrap_or("?"); + let branch_glyph = glyph::glyph("branch", glyphs); + let dirty_glyph = if status.is_dirty && ctx.config.sections.vcs.show_dirty { + glyph::glyph("dirty", glyphs) + } else { "" }; + + let mut raw = format!("{branch_glyph}{branch}{dirty_glyph}"); + let mut ansi = if ctx.color_enabled { + let mut s = format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET); + if !dirty_glyph.is_empty() { + s.push_str(&format!("{}{dirty_glyph}{}", color::YELLOW, color::RESET)); + } + s + } else { + raw.clone() + }; + + // Ahead/behind: medium+ width only + if ctx.config.sections.vcs.show_ahead_behind + && ctx.width_tier != WidthTier::Narrow + && (status.ahead > 0 || status.behind > 0) + { + let ahead_g = glyph::glyph("ahead", glyphs); + let behind_g = glyph::glyph("behind", glyphs); + let mut ab = String::new(); + if status.ahead > 0 { ab.push_str(&format!("{ahead_g}{}", status.ahead)); } + if status.behind > 0 { ab.push_str(&format!("{behind_g}{}", status.behind)); } + raw.push_str(&format!(" {ab}")); + if ctx.color_enabled { + ansi.push_str(&format!(" {}{ab}{}", color::DIM, color::RESET)); + } else { + ansi = raw.clone(); + } + } + + Some(SectionOutput { raw, ansi }) +} + +fn render_jj( + ctx: &RenderContext, + _dir: &str, + ttl: &crate::config::VcsTtl, + glyphs: &crate::config::GlyphConfig, +) -> Option<SectionOutput> { + let branch_ttl = Duration::from_secs(ttl.branch); + let timeout = Duration::from_millis(200); + + let branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| { + let out = shell::exec_with_timeout( + "jj", + &["log", "-r", "@", "--no-graph", + "-T", "if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))", + "--color=never"], + None, timeout, + )?; + ctx.cache.set("vcs_branch", &out); + Some(out) + })?; + + let branch_glyph = glyph::glyph("branch", glyphs); + let raw = format!("{branch_glyph}{branch}"); + let ansi = if ctx.color_enabled { + format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET) + } else { + raw.clone() + }; + + // jj has no upstream concept — ahead/behind hardcoded to 0/0 + Some(SectionOutput { raw, ansi }) +} +``` + +--- + +## src/layout/mod.rs — Full render pipeline + +Three phases: Plan → Render survivors → Reflow. + +```rust +use crate::config::{Config, JustifyMode, LayoutValue}; +use crate::format; +use crate::section::{self, RenderContext, SectionOutput}; + +/// A section that survived priority drops and has rendered output. +pub struct ActiveSection { + pub id: String, + pub output: SectionOutput, + pub priority: u8, + pub is_spacer: bool, + pub is_flex: bool, +} + +/// Resolve layout: preset lookup with optional responsive override. +pub fn resolve_layout(config: &Config, term_width: u16) -> Vec<Vec<String>> { + match &config.layout { + LayoutValue::Preset(name) => { + let effective = if config.global.responsive { + responsive_preset(term_width, &config.global.breakpoints) + } else { + name.as_str() + }; + config.presets.get(effective) + .or_else(|| config.presets.get(name.as_str())) + .cloned() + .unwrap_or_else(|| vec![vec!["model".into(), "project".into()]]) + } + LayoutValue::Custom(lines) => lines.clone(), + } +} + +fn responsive_preset(width: u16, bp: &crate::config::Breakpoints) -> &'static str { + if width < bp.narrow { "dense" } + else if width < bp.medium { "standard" } + else { "verbose" } +} + +/// Render a single layout line. +/// Returns the final ANSI string, or None if all sections were empty. +pub fn render_line( + section_ids: &[String], + ctx: &RenderContext, + separator: &str, +) -> Option<String> { + // Phase 1: Render all sections, collect active ones + let mut active: Vec<ActiveSection> = Vec::new(); + + for id in section_ids { + let output = section::render_section(id, ctx)?; + if output.raw.is_empty() && !section::is_spacer(id) { + continue; + } + + let (priority, is_flex) = section_meta(id, ctx.config); + active.push(ActiveSection { + id: id.clone(), + output, + priority, + is_spacer: section::is_spacer(id), + is_flex, + }); + } + + if active.is_empty() || active.iter().all(|s| s.is_spacer) { + return None; + } + + // Phase 2: Priority drop if overflowing + let mut active = super::layout::priority::priority_drop( + active, ctx.term_width, separator, + ); + + // Phase 3: Flex expand or justify + let line = if ctx.config.global.justify != JustifyMode::Left + && !active.iter().any(|s| s.is_spacer) + && active.len() > 1 + { + super::layout::justify::justify(&active, ctx.term_width, separator, ctx.config.global.justify) + } else { + super::layout::flex::flex_expand(&mut active, ctx, separator)?; + assemble_left(&active, separator, ctx.color_enabled) + }; + + Some(line) +} + +/// Left-aligned assembly with separator dimming and spacer suppression. +fn assemble_left(active: &[ActiveSection], separator: &str, color_enabled: bool) -> String { + let mut output = String::new(); + let mut prev_is_spacer = false; + + for (i, sec) in active.iter().enumerate() { + if i > 0 && !prev_is_spacer && !sec.is_spacer { + if color_enabled { + output.push_str(&format!("{}{separator}{}", crate::color::DIM, crate::color::RESET)); + } else { + output.push_str(separator); + } + } + output.push_str(&sec.output.ansi); + prev_is_spacer = sec.is_spacer; + } + + output +} + +fn section_meta(id: &str, config: &Config) -> (u8, bool) { + if section::is_spacer(id) { + return (1, true); + } + // Look up from config (simplified — real impl uses per-section base) + (2, false) // placeholder; real impl reads config.sections.{id}.base +} + +/// Full render: resolve layout, render each line, join with newlines. +pub fn render_all(ctx: &RenderContext) -> String { + let layout = resolve_layout(ctx.config, ctx.term_width); + let separator = &ctx.config.global.separator; + + let lines: Vec<String> = layout.iter() + .filter_map(|line_ids| render_line(line_ids, ctx, separator)) + .collect(); + + lines.join("\n") +} +``` + +--- + +## src/layout/priority.rs — Priority drop + +```rust +use crate::layout::ActiveSection; +use crate::format; + +/// Drop priority 3 sections (all at once), then priority 2, until line fits. +/// Priority 1 sections never drop. +pub fn priority_drop( + mut active: Vec<ActiveSection>, + term_width: u16, + separator: &str, +) -> Vec<ActiveSection> { + if line_width(&active, separator) <= term_width as usize { + return active; + } + + // Drop all priority 3 + active.retain(|s| s.priority < 3); + if line_width(&active, separator) <= term_width as usize { + return active; + } + + // Drop all priority 2 + active.retain(|s| s.priority < 2); + active +} + +/// Calculate total display width including separators. +/// Spacers suppress adjacent separators on both sides. +fn line_width(active: &[ActiveSection], separator: &str) -> usize { + let sep_w = format::display_width(separator); + let mut total = 0; + + for (i, sec) in active.iter().enumerate() { + if i > 0 { + let prev = &active[i - 1]; + if !prev.is_spacer && !sec.is_spacer { + total += sep_w; + } + } + total += format::display_width(&sec.output.raw); + } + + total +} +``` + +--- + +## src/layout/flex.rs — Flex expansion with context_bar rebuild + +```rust +use crate::layout::ActiveSection; +use crate::section::RenderContext; +use crate::format; + +/// Expand the winning flex section to fill remaining terminal width. +/// +/// Rules: +/// - Spacers take priority over non-spacer flex sections +/// - Only one flex section wins per line +/// - Spacer: fill with spaces +/// - context_bar: rebuild bar with wider width (recalculate filled/empty chars) +/// - Other: pad with trailing spaces +pub fn flex_expand( + active: &mut Vec<ActiveSection>, + ctx: &RenderContext, + separator: &str, +) -> Option<()> { + let current_width = line_width(active, separator); + let term_width = ctx.term_width as usize; + + if current_width >= term_width { + return Some(()); + } + + // Find winning flex section: spacer wins over non-spacer + let mut flex_idx: Option<usize> = None; + for (i, sec) in active.iter().enumerate() { + if !sec.is_flex { continue; } + match flex_idx { + None => flex_idx = Some(i), + Some(prev) => { + if sec.is_spacer && !active[prev].is_spacer { + flex_idx = Some(i); + } + } + } + } + + let idx = flex_idx?; + let extra = term_width - current_width; + + if active[idx].is_spacer { + let padding = " ".repeat(extra + 1); + active[idx].output = crate::section::SectionOutput { + raw: padding.clone(), + ansi: padding, + }; + } else if active[idx].id == "context_bar" { + // Rebuild context_bar with wider bar_width + let cur_bar_width = ctx.config.sections.context_bar.bar_width; + let new_bar_width = cur_bar_width + extra as u16; + if let Some(mut output) = crate::section::context_bar::render_at_width(ctx, new_bar_width) { + // Re-apply formatting after flex rebuild + let base = &ctx.config.sections.context_bar.base; + format::apply_formatting( + &mut output.raw, &mut output.ansi, + base, ctx.theme, &ctx.config.colors, + ); + active[idx].output = output; + } + } else { + let padding = " ".repeat(extra); + active[idx].output.raw.push_str(&padding); + active[idx].output.ansi.push_str(&padding); + } + + Some(()) +} + +fn line_width(active: &[ActiveSection], separator: &str) -> usize { + let sep_w = format::display_width(separator); + let mut total = 0; + for (i, sec) in active.iter().enumerate() { + if i > 0 { + let this_gap = sep_w + format::display_width(&sec.output.raw); + total += this_gap; + } else { + total += format::display_width(&sec.output.raw); + } + } + total +} +``` + +--- + +## src/layout/justify.rs — Spread/space-between gap math + +```rust +use crate::color; +use crate::config::JustifyMode; +use crate::format; +use crate::layout::ActiveSection; + +/// Distribute extra space evenly across gaps between sections. +/// Center the separator core (e.g., `|` from ` | `) within each gap. +/// Remainder chars distributed left-to-right. +pub fn justify( + active: &[ActiveSection], + term_width: u16, + separator: &str, + mode: JustifyMode, +) -> String { + let content_width: usize = active.iter() + .map(|s| format::display_width(&s.output.raw)) + .sum(); + + let num_gaps = active.len().saturating_sub(1); + if num_gaps == 0 { + return active.first().map(|s| s.output.ansi.clone()).unwrap_or_default(); + } + + let available = (term_width as usize).saturating_sub(content_width); + let gap_width = available / num_gaps; + let gap_remainder = available % num_gaps; + + // Extract separator core (non-space chars, e.g. "|" from " | ") + let sep_core = separator.trim(); + let sep_core_len = format::display_width(sep_core); + + let mut output = String::new(); + for (i, sec) in active.iter().enumerate() { + if i > 0 { + let this_gap = gap_width + if i - 1 < gap_remainder { 1 } else { 0 }; + let gap_str = build_gap(sep_core, sep_core_len, this_gap); + output.push_str(&format!("{}{gap_str}{}", color::DIM, color::RESET)); + } + output.push_str(&sec.output.ansi); + } + + output +} + +/// Center the separator core within a gap of `total` columns. +fn build_gap(core: &str, core_len: usize, total: usize) -> String { + if core.is_empty() || core_len == 0 { + return " ".repeat(total); + } + + let pad_total = total.saturating_sub(core_len); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + + format!("{}{core}{}", " ".repeat(pad_left), " ".repeat(pad_right)) +} +``` + +--- + +## src/bin/claude-statusline.rs — CLI entry point + +```rust +use claude_statusline::{config, input, theme, width, cache, color, section}; +use claude_statusline::section::RenderContext; +use std::io::Read; + +fn main() { + let args: Vec<String> = std::env::args().collect(); + + // Parse CLI flags (no clap — only 4 flags) + if args.iter().any(|a| a == "--help" || a == "-h") { + print_help(); + return; + } + if args.iter().any(|a| a == "--config-schema") { + println!("{}", include_str!("../../schema.json")); + return; + } + if args.iter().any(|a| a == "--print-defaults") { + println!("{}", include_str!("../../defaults.json")); + return; + } + if args.iter().any(|a| a == "--list-sections") { + for (id, _) in section::registry() { + println!("{id}"); + } + return; + } + + let cli_color = args.iter() + .find_map(|a| a.strip_prefix("--color=")) + .map(|s| s.to_string()); + + let config_path = args.iter() + .position(|a| a == "--config") + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()); + + let cli_width = args.iter() + .find_map(|a| a.strip_prefix("--width=").or_else(|| { + args.iter().position(|x| x == "--width") + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()) + })) + .and_then(|s| s.parse::<u16>().ok()); + + let no_cache = args.iter().any(|a| a == "--no-cache") + || std::env::var("CLAUDE_STATUSLINE_NO_CACHE").is_ok(); + let no_shell = args.iter().any(|a| a == "--no-shell") + || std::env::var("CLAUDE_STATUSLINE_NO_SHELL").is_ok(); + let clear_cache = args.iter().any(|a| a == "--clear-cache"); + + let is_test = args.iter().any(|a| a == "--test"); + let dump_state = args.iter().find_map(|a| { + if a == "--dump-state" { Some("text") } + else { a.strip_prefix("--dump-state=") } + }); + let validate_config = args.iter().any(|a| a == "--validate-config"); + + // Load config + let (config, warnings) = match config::load_config(config_path) { + Ok(v) => v, + Err(e) => { + eprintln!("claude-statusline: {e}"); + std::process::exit(1); + } + }; + + if validate_config { + if warnings.is_empty() { + println!("config valid"); + std::process::exit(0); + } else { + for w in &warnings { + eprintln!("{w}"); + } + std::process::exit(1); + } + } + + // Warn on unknown keys (non-fatal) + if config.global.warn_unknown_keys { + for w in &warnings { + eprintln!("claude-statusline: {w}"); + } + } + + // Read stdin JSON + let input_data: input::InputData = if is_test { + serde_json::from_str(include_str!("../../test_data.json")) + .unwrap_or_default() + } else { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() { + return; // empty stdin -> exit 0, no output + } + match serde_json::from_str(&buf) { + Ok(v) => v, + Err(e) => { + eprintln!("claude-statusline: stdin: {e}"); + std::process::exit(1); + } + } + }; + + // Detect environment + let detected_theme = theme::detect_theme(&config); + let term_width = width::detect_width(cli_width, config.global.width, config.global.width_margin); + let tier = width::width_tier( + term_width, + config.global.breakpoints.narrow, + config.global.breakpoints.medium, + ); + + let project_dir = input_data.workspace.as_ref() + .and_then(|w| w.project_dir.as_deref()) + .unwrap_or("."); + + let session = cache::session_id(project_dir); + let cache = cache::Cache::new(&config.global.cache_dir, &session); + + let color_enabled = color::should_use_color( + cli_color.as_deref(), + &config.global.color, + ); + + let vcs_type = detect_vcs(project_dir, &config); + + // Handle --dump-state + if let Some(format) = dump_state { + dump_state_output(format, &config, term_width, tier, detected_theme, vcs_type, project_dir, &session); + return; + } + + // Build render context + let project_path = std::path::Path::new(project_dir); + let ctx = RenderContext { + input: &input_data, + config: &config, + theme: detected_theme, + width_tier: tier, + term_width, + vcs_type, + project_dir: project_path, + cache: &cache, + glyphs_enabled: config.glyphs.enabled, + color_enabled, + }; + + // Render and output + let output = claude_statusline::layout::render_all(&ctx); + print!("{output}"); +} + +fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType { + let prefer = config.sections.vcs.prefer.as_str(); + let path = std::path::Path::new(dir); + + match prefer { + "jj" => { + if path.join(".jj").is_dir() { section::VcsType::Jj } + else { section::VcsType::None } + } + "git" => { + if path.join(".git").is_dir() { section::VcsType::Git } + else { section::VcsType::None } + } + _ => { + if path.join(".jj").is_dir() { section::VcsType::Jj } + else if path.join(".git").is_dir() { section::VcsType::Git } + else { section::VcsType::None } + } + } +} + +fn dump_state_output( + format: &str, + config: &config::Config, + term_width: u16, + tier: width::WidthTier, + theme: theme::Theme, + vcs: section::VcsType, + project_dir: &str, + session_id: &str, +) { + // JSON output with comprehensive debug info + let json = serde_json::json!({ + "terminal": { + "effective_width": term_width, + "width_margin": config.global.width_margin, + "width_tier": format!("{tier:?}"), + }, + "theme": theme.as_str(), + "vcs": format!("{vcs:?}"), + "layout": { + "justify": format!("{:?}", config.global.justify), + "separator": &config.global.separator, + }, + "paths": { + "project_dir": project_dir, + "cache_dir": config.global.cache_dir.replace("{session_id}", session_id), + }, + "session_id": session_id, + }); + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&json).unwrap()), + _ => println!("{json:#}"), + } +} + +fn print_help() { + println!("claude-statusline — Fast, configurable status line for Claude Code + +USAGE: + claude-statusline Read JSON from stdin, output status line + claude-statusline --test Render with mock data to validate config + claude-statusline --dump-state[=text|json] Output internal state for debugging + claude-statusline --validate-config Validate config (exit 0=ok, 1=errors) + claude-statusline --config-schema Print schema JSON to stdout + claude-statusline --print-defaults Print defaults JSON to stdout + claude-statusline --list-sections List all section IDs + claude-statusline --config <path> Use alternate config file + claude-statusline --no-cache Disable caching for this run + claude-statusline --no-shell Disable all shell-outs (serve stale cache) + claude-statusline --clear-cache Remove cache directory and exit + claude-statusline --width <cols> Force terminal width + claude-statusline --color=auto|always|never Override color detection + claude-statusline --help Show this help"); +} +``` + +--- + +## 24 Built-in Sections + +| Section | Color | Shell-out | Cached | Width-tier varies | +|---------|-------|-----------|--------|-------------------| +| model | bold | no | no | no | +| provider | dim | no | no | no | +| project | cyan | no | no | no (truncation only) | +| vcs | green/yellow/dim | git/jj | yes (3s/5s/30s) | yes (ahead/behind hidden narrow) | +| beads | yellow/green/blue/dim | br | yes (30s) | yes (counts hidden narrow) | +| context_bar | threshold | no | no | no (flex-expandable) | +| context_usage | threshold/dim | no | no | no | +| context_remaining | threshold/dim | no | no | no | +| tokens_raw | dim | no | no | yes (labels/decimals) | +| cache_efficiency | dim/green/boldgreen | no | no | no | +| cost | threshold | no | no | yes (decimal places) | +| cost_velocity | dim | no | no | no | +| token_velocity | dim | no | no | yes (suffix) | +| cost_trend | dim | no | trend file | no | +| context_trend | threshold | no | trend file | no | +| lines_changed | green/red | no | no | no | +| duration | dim | no | no | no | +| tools | dim | no | no | yes (label/last name) | +| turns | dim | no | no | no | +| load | dim | sysctl/proc | yes (10s) | no | +| version | dim | no | no | no | +| time | dim | date | no | no | +| output_style | magenta | no | no | no | +| hostname | dim | hostname | no | no | +| spacer | n/a | no | no | n/a (virtual, flex) | +| custom | configurable | bash -c | yes (user TTL) | no | + +### Shell-out optimization + +**Combined git call**: The bash version forks three separate git processes per render (branch, dirty, ahead/behind), each with independent cache TTLs. The Rust port combines these into a single `git status --porcelain=v2 --branch` call (with `-uno` when `untracked = "no"` and `-c status.submoduleSummary=false` when `submodules = false`; `fast_mode` skips untracked and submodules regardless) that returns branch name, ahead/behind counts, and dirty status in one fork. The three cache TTLs are preserved by caching the parsed sub-results independently (branch for 3s, dirty for 5s, ahead/behind for 30s), but all three are populated from a single execution when any one expires. + +**Parallel shell-outs on cache miss**: When multiple shell-out sections have expired caches in the same render (e.g., vcs + load + beads), execute them in parallel using `std::thread::scope`. Cache hits are >90% of renders, so this only matters for the occasional cold render. + +```rust +// Parallel shell-outs using std::thread::scope (borrows from calling scope, no 'static) +std::thread::scope(|s| { + let vcs_handle = s.spawn(|| { + shell::exec_with_timeout("git", &["-C", dir, "status", "--porcelain=v2", "--branch"], + None, Duration::from_millis(200)) + }); + let load_handle = s.spawn(|| { + shell::exec_with_timeout("sysctl", &["-n", "vm.loadavg"], None, Duration::from_millis(100)) + }); + let beads_handle = s.spawn(|| { + shell::exec_with_timeout("br", &["status", "--json"], None, Duration::from_millis(200)) + }); + + let vcs_out = vcs_handle.join().ok().flatten(); + let load_out = load_handle.join().ok().flatten(); + let beads_out = beads_handle.join().ok().flatten(); + // ... cache results ... +}); +``` + +**Lazy shell-outs**: Layout planning happens before invoking shell-outs. Sections that are dropped by width or preset never execute their commands. + +**Command timeouts + stable env**: All shell-outs use `global.shell_timeout_ms` (default: 200ms), cap output to `global.shell_max_output_bytes` (default: 8192), and merge `global.shell_env` with per-command env. Set `GIT_OPTIONAL_LOCKS=0`, `GIT_TERMINAL_PROMPT=0`, and `LC_ALL=C` to avoid lock contention, prompts, and locale-dependent parsing. On timeout or error, return stale cache if available. + +**Allow/deny list**: If `global.shell_enabled = false` (or `--no-shell` / `CLAUDE_STATUSLINE_NO_SHELL`), or a command is not in `shell_allowlist` / is in `shell_denylist`, skip execution and return stale cache if present. + +**Circuit breaker**: Track consecutive failures per command key (timeout or non-zero exit). If failures exceed `shell_failure_threshold` (default: 3), open a cooldown window (`shell_cooldown_ms`, default: 30s) and serve stale cache or `None` without executing the command. Resets on success or cooldown expiry. + +--- + +## Layout Engine Algorithms + +### Priority Drop +When total width exceeds terminal width: +If `drop_strategy = "tiered"` (default): +1. Remove ALL priority 3 sections at once. Recalculate. +2. If still too wide, remove ALL priority 2 sections at once. +3. Priority 1 sections never drop. + +If `drop_strategy = "gradual"`: +1. Drop sections one-by-one by (priority desc, width cost desc, rightmost first), recompute after each removal. +2. Priority 1 sections never drop. + +### Flex Expansion +When total width is less than terminal width and a flex section exists: +- Spacers take priority over non-spacer flex sections +- Only one flex section wins per line +- **Spacer**: Fill with spaces +- **context_bar**: Rebuild bar with wider width (recalculate filled/empty chars) +- **Other**: Pad with trailing spaces + +### Justify Modes +- **left** (default): Fixed separators, left-packed. Flex expansion applies. +- **spread / space-between**: Distribute extra space evenly across gaps. Center separator core (e.g., `|`) within each gap. Remainder chars distributed left-to-right. Bypassed if any spacer is present. + +### Separator Handling +- Default separator: ` | ` (configurable) +- Spacers suppress adjacent separators on both sides +- Separators rendered dim +- SEP_CORE = non-space portion (e.g., `|` from ` | `) + +### Responsive Layout +When `global.responsive = true` and layout is a preset name (string): +- width < 60: use "dense" preset (1 line) +- 60-99: use "standard" preset (2 lines) +- >= 100: use "verbose" preset (3 lines) + +Use `breakpoint_hysteresis` (default: 2) to avoid toggling presets if width fluctuates within ±hysteresis columns of a breakpoint. + +Explicit array layouts ignore responsive mode. + +### Render Pipeline (lazy) +1. Resolve layout preset -> ordered section ids by line +2. Build a RenderPlan using cached widths or fixed estimates per section +3. Start render budget timer; render only included sections (shell-outs skipped for dropped sections) +4. Recompute widths from real outputs; if overflow remains, apply one bounded reflow (priority drop + flex rebuild) to avoid oscillation +5. If `global.render_budget_ms` (default: 8ms) is exceeded, drop remaining lowest-priority sections and emit partial output + +--- + +## Architectural Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Section system | Registry of `SectionDescriptor { id, render_fn, estimated_width, shell_out }` | Single source of truth for IDs, validation, CLI introspection | +| Config types | Fully typed structs with `#[serde(flatten)]` for shared SectionBase | Compile-time safety, IDE completion, no stringly-typed lookups | +| Layout pipeline | 3-phase: Plan → Render survivors → Reflow | Lazy: dropped sections never shell-out | +| Color override | `apply_formatting` re-wraps `raw` text (discards section's internal ANSI) | Matches bash behavior where user color overrides trump section defaults | +| Shell-out timeout | Polling `try_wait()` + 5ms sleep | No extra crate, bounded latency | +| Parallel shell-outs | `std::thread::scope` | Borrows from calling scope, no `'static` requirement | +| Width memoization | `Mutex<Option<(u16, Instant)>>` with 1s TTL | Avoids repeating process tree walk on 2-3 consecutive renders | +| Cache concurrency | `flock(LOCK_EX \| LOCK_NB)` | Skip cache on contention rather than blocking render | +| Render budget | `global.render_budget_ms` (default: 8ms) with graceful degradation | Prevents UI stalls from cascading shell-out delays | +| Shell circuit breaker | Per-command failure tracking with cooldown window | Stops hammering failing commands; serves stale cache instead | +| Config discovery | XDG + dot-config + legacy `~/.claude` fallback chain | Modern tooling compat without breaking existing users | + +--- + +## Width Detection Priority Chain + +1. `--width <cols>` CLI flag +2. `CLAUDE_STATUSLINE_WIDTH` env var +3. `global.width` config (explicit override) +4. `ioctl(TIOCGWINSZ)` on stdout fd (zero-cost syscall, correct when stdout is a real TTY) +5. Process tree walk: start at PID, walk ppid chain, find TTY, `stty size < /dev/{tty}` (handles multiplexed terminals where stdout is a pipe) +6. `stty size < /dev/tty` +7. `$COLUMNS` env var +8. `tput cols` +9. Fallback: 120 + +Final width = detected - `global.width_margin` (default: 4) + +**Memoization**: Cache detected width for 1 second. Since renders happen every ~300ms, this avoids repeating the process tree walk on 2-3 consecutive renders. In-memory only (a `(u16, Instant)` tuple), not file-based. + +Width tiers (for progressive disclosure within sections): +- narrow: < breakpoints.narrow (default 60) +- medium: < breakpoints.medium (default 100) +- wide: >= breakpoints.medium + +--- + +## Cache System + +### Session ID +MD5 of `workspace.project_dir`, truncated to 12 hex chars. Same algorithm as bash for cache sharing during migration. + +### Cache Namespace +`{session_id}-{cache_version}-{config_hash}` to avoid stale data across upgrades or config edits. + +### Directory +Template: `/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/` (configurable). Created with `chmod 700`. Ownership verified (not a symlink, owned by current user, not world-writable). Caching disabled if suspicious. + +### Cache GC +- At most once per `cache_gc_interval_hours` (default: 24), scan `/tmp/claude-sl-*` and delete dirs older than `cache_gc_days` (default: 7), owned by current user, not symlinks. +- Use a lock file in `/tmp` to avoid concurrent GC across renders. +- GC runs asynchronously after render output is written, never blocking the status line. + +### Per-key Caching +- File per key: `$CACHE_DIR/$sanitized_key` +- TTL checked via file mtime vs current time +- **TTL jitter**: Add a random +/- `cache_ttl_jitter_pct` (default: 10%) to TTL to desynchronize expiration across concurrent renders +- **Atomic writes**: Write to `$key.tmp`, then `rename()` to `$key` (prevents partial reads if process is killed mid-write) +- **Stale fallback**: On command failure, return previous cached value if it exists (prevents status line flicker during transient errors like git lock contention) +- Key sanitization: `[^a-zA-Z0-9_-]` -> `_` +- **Concurrency guard**: Use per-key lock files with `flock` (short wait, then no-cache) to prevent interleaved writes across concurrent renders. Trend appends use the same lock. + +### Trend Tracking +- Append-only comma-separated files: `$CACHE_DIR/trend_{key}` +- **Write throttling**: Append at most once per 5 seconds (configurable). This is for sparkline data quality — 8 points at 5s intervals covers 40s of history (meaningful trends), vs 8 points at 300ms covering 2.4s (noise). +- Skip append if value unchanged from last point +- Max N points (default 8), trim from left +- Sparkline: 8 Unicode block chars (▁▂▃▄▅▆▇█), normalized to min/max range +- **Flat-series guard**: When min == max, render mid-height blocks (▄) for all points (prevents divide-by-zero in normalization) + +--- + +## Implementation Phases + +### Phase 1: Skeleton + Pure Sections +- `main.rs`: CLI flags, stdin read, `--test` with mock data, `--help` +- `config.rs`: Load embedded defaults, merge user config (recursive serde_json::Value merge) +- `input.rs`: Deserialize stdin JSON +- `color.rs`: ANSI constants, `color_by_name()` with compound styles and palette refs +- `theme.rs`: COLORFGBG parsing +- `format.rs`: `human_tokens()`, `human_duration()`, truncation (right/middle/left, grapheme-cluster-safe using `unicode-segmentation`) +- `metrics.rs`: Compute derived values once (cost velocity, token velocity, usage %, totals), reused by all sections +- Port 17 pure sections (no shell-outs): + model, provider, project, context_bar, context_usage, context_remaining, tokens_raw, cache_efficiency, + cost, cost_velocity, token_velocity, lines_changed, duration, tools, turns, + version, output_style +- `section/mod.rs`: Dispatch, `apply_formatting()`, `apply_truncation()` +- **Verify**: `--test` produces correct output for all pure sections + +**Exit criteria**: +- `claude-statusline --test` succeeds and produces stable output +- Config load + deep merge + defaults verified with unit tests + +**Non-goals**: +- No shell-outs +- No cache system yet + +### Phase 2: Layout Engine +- `layout/mod.rs`: `resolve_layout()` (preset lookup, responsive override) +- `layout/priority.rs`: Drop tier 3 all at once, then tier 2. Never tier 1. +- `layout/flex.rs`: Spacer expansion, context_bar rebuild, generic padding +- `layout/justify.rs`: Spread/space-between gap math with centered separator core +- Width tier calculation (narrow < 60, medium 60-99, wide >= 100) +- Separator handling (spacers suppress adjacent separators) +- All width calculations use `unicode-width::UnicodeWidthStr::width()` on raw text, not `.len()` or `.chars().count()`. Correctly handles CJK (2 cells), Nerd Font glyphs (1 or 2 cells), and zero-width joiners. +- **Verify**: Parity test - same stdin JSON at 80/120/175 cols = same raw output as bash + +**Exit criteria**: +- Parity tests pass for layout at 3 widths +- Width tiering and drop strategy behave deterministically + +### Phase 3: Width Detection + Cache + Shell-out Sections +- `width.rs`: Full priority chain (cli > env > config > ioctl > process tree walk > stty > COLUMNS > tput > 120), 1s in-memory memoization +- `cache.rs`: Secure dir (symlink + ownership + world-writable checks), per-key TTL via mtime, atomic write-rename, stale fallback on command failure +- `trend.rs`: Append-only files, sparkline, write throttling (5s), flat-series guard +- `glyph.rs`: Nerd Font + ASCII fallback +- Port shell-out sections: vcs (combined `git status --porcelain=v2 --branch` + jj), beads, load, hostname, time, custom commands +- Parallel shell-out execution on cache miss (`std::thread::scope`) +- **Verify**: Full parity with bash version including VCS and cache + +**Exit criteria**: +- All shell-outs respect timeout and cache +- Render time under budget in warm runs + +### Phase 4: Polish + Validation +- `--dump-state` (enhanced: width detection source, per-section render timing in microseconds, priority drop reasons, cache hit/miss per key, trend throttle state; supports `text` and `json` output) +- `--validate-config` (strict deserialize with path-aware errors via `serde_path_to_error` for unknown keys, type mismatches, deprecated fields; exit 0 = valid, exit 1 = errors) +- `--config-schema` (print schema JSON to stdout) +- `--print-defaults` (print defaults JSON to stdout) +- `--list-sections` (list all registered section IDs with metadata) +- Comprehensive snapshot tests + +**Exit criteria**: +- Snapshot tests stable across 3 widths +- `--validate-config` errors are actionable + +### Phase 5: Distribution (post-MVP) +- Cross-compile: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu +- GitHub Actions: CI (fmt, clippy, test) + Release (build, tar, checksums) +- Homebrew tap formula +- Update install.sh to prefer Rust binary +- Update README + +--- + +## Error Handling + +- **Startup**: Fail fast on explicit config path not found or stdin parse error +- **Sections**: Return `Option<SectionOutput>` - missing data = None = section skipped; unknown section IDs become validation errors via registry +- **Shell-outs**: Disabled (`--no-shell`), circuit-open, or denied by allow/deny list = skip and return stale cache if present; otherwise command failure or timeout = None unless stale cache is present +- **Cache**: Creation failure = disable caching, run commands directly + +--- + +## Testing + +1. **Unit tests**: Per-module (config merge, format helpers, color resolution, sparkline, priority drop, flex, justify) +2. **Snapshot tests**: Known JSON input -> expected raw output at specific widths +3. **Parity tests**: Run both bash and Rust with same input, diff raw output (ANSI stripped) +4. **Benchmarks**: `criterion` measuring parse-to-stdout. Target: <1ms warm +5. **Config validation tests**: `--validate-config` rejects unknown keys and type mismatches + +--- + +## Verification + +```bash +# Smoke test +echo '{"model":{"id":"claude-opus-4-6"},"cost":{"total_cost_usd":0.42}}' | claude-statusline + +# Parity with bash +echo "$TEST_JSON" | bash statusline.sh 2>/dev/null > /tmp/bash_out.txt +echo "$TEST_JSON" | claude-statusline > /tmp/rust_out.txt +diff <(sed 's/\x1b\[[0-9;]*m//g' /tmp/bash_out.txt) <(sed 's/\x1b\[[0-9;]*m//g' /tmp/rust_out.txt) + +# Performance comparison +hyperfine --warmup 3 'echo "$TEST_JSON" | claude-statusline' 'echo "$TEST_JSON" | bash statusline.sh' +``` + +--- + +## Distribution + +- `cargo install claude-statusline` (crates.io) +- `brew install tayloreernisse/tap/claude-statusline` (Homebrew tap, pre-built) +- GitHub Releases (direct download, macOS arm64+x86_64, Linux x86_64) + +--- + +## Switchover + +Drop-in replacement. Change `~/.claude/settings.json`: +```json +{ "statusLine": "claude-statusline" } +``` +Bash version remains available as fallback. Cache directories are compatible. + +--- + +## Edge Cases to Port Correctly + +1. Empty stdin -> exit 0, no output +2. `null` JSON values -> treated as missing (not the string "null") +3. Config deep merge: nested objects merged recursively, arrays replaced entirely +4. Separator core extraction: trim leading/trailing spaces from separator +5. context_bar flex rebuild: must re-apply `apply_formatting()` after +6. jj ahead/behind: hardcoded to 0/0 (jj has no upstream concept) +7. Tool name truncation: >12 chars -> 11 chars + `~` (must use display width, not char count) +8. Beads parts joined with ` | ` (literal, not global separator) +9. `stat -f %m` (macOS) vs `stat -c %Y` (Linux) for mtime — Rust uses `std::fs::metadata().modified()` (portable) +10. Session ID: first 12 chars of MD5 hex of project_dir +11. Truncation must split on grapheme cluster boundaries (not bytes or chars) +12. Stale cache fallback: on shell-out failure, return previous cached value if exists +13. Trend sparkline flat series: min == max -> mid-height block (▄) for all points +14. ioctl(TIOCGWINSZ) returns 0 when stdout is a pipe -- must fall through to process tree walk +15. Combined `git status --porcelain=v2 --branch` may fail on repos with no commits -- fall back to individual commands +16. Circuit breaker must track failures per command key in-memory (not cached) so it resets on process restart +17. Cache GC must only delete dirs owned by current user and must not follow symlinks +18. XDG config discovery: only use XDG/dot-config paths if the file actually exists (don't create empty files) +19. Render budget: partial output must still be valid ANSI (no unclosed escape sequences) \ No newline at end of file diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..b3ad5fc --- /dev/null +++ b/schema.json @@ -0,0 +1,841 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/tayloreernisse/claude-statusline/schema.json", + "title": "Claude Code Status Line Configuration", + "description": "Configuration for the claude-statusline status line script", + "type": "object", + "required": [ + "version" + ], + "additionalProperties": false, + "properties": { + "version": { + "type": "integer", + "const": 1, + "description": "Config schema version" + }, + "global": { + "$ref": "#/$defs/globalConfig" + }, + "presets": { + "type": "object", + "description": "Named layout presets. Each preset is an array of arrays of section IDs.", + "additionalProperties": { + "$ref": "#/$defs/layoutArray" + } + }, + "layout": { + "description": "Active layout. Either a preset name (string) or a direct layout array.", + "oneOf": [ + { + "type": "string", + "description": "Name of a preset defined in 'presets'" + }, + { + "$ref": "#/$defs/layoutArray" + } + ], + "default": "standard" + }, + "sections": { + "type": "object", + "description": "Configuration for built-in sections", + "properties": { + "model": { + "$ref": "#/$defs/basicSection" + }, + "provider": { + "$ref": "#/$defs/basicSection" + }, + "project": { + "$ref": "#/$defs/basicSection" + }, + "vcs": { + "$ref": "#/$defs/vcsSection" + }, + "beads": { + "$ref": "#/$defs/beadsSection" + }, + "context_bar": { + "$ref": "#/$defs/contextBarSection" + }, + "context_usage": { + "$ref": "#/$defs/contextUsageSection" + }, + "tokens_raw": { + "$ref": "#/$defs/tokensRawSection" + }, + "cache_efficiency": { + "$ref": "#/$defs/basicSection" + }, + "cost": { + "$ref": "#/$defs/costSection" + }, + "cost_velocity": { + "$ref": "#/$defs/basicSection" + }, + "token_velocity": { + "$ref": "#/$defs/basicSection" + }, + "lines_changed": { + "$ref": "#/$defs/basicSection" + }, + "duration": { + "$ref": "#/$defs/basicSection" + }, + "tools": { + "$ref": "#/$defs/toolsSection" + }, + "turns": { + "$ref": "#/$defs/cachedSection" + }, + "load": { + "$ref": "#/$defs/cachedSection" + }, + "version": { + "$ref": "#/$defs/basicSection" + }, + "time": { + "$ref": "#/$defs/timeSection" + }, + "output_style": { + "$ref": "#/$defs/basicSection" + }, + "hostname": { + "$ref": "#/$defs/basicSection" + } + }, + "additionalProperties": false + }, + "custom": { + "type": "array", + "description": "Custom command sections", + "items": { + "$ref": "#/$defs/customCommand" + }, + "default": [] + }, + "colors": { + "type": "object", + "description": "Themed color palettes for light/dark modes", + "properties": { + "dark": { "$ref": "#/$defs/colorPalette" }, + "light": { "$ref": "#/$defs/colorPalette" } + }, + "additionalProperties": false + }, + "glyphs": { + "type": "object", + "description": "Glyph configuration for Nerd Fonts and fallbacks", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable Nerd Font glyphs" + }, + "set": { + "type": "object", + "description": "Nerd Font glyph mappings", + "additionalProperties": { "type": "string" } + }, + "fallback": { + "type": "object", + "description": "ASCII fallback glyphs when Nerd Fonts are disabled", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false + } + }, + "$defs": { + "globalConfig": { + "type": "object", + "description": "Global settings", + "properties": { + "separator": { + "type": "string", + "description": "Separator between sections on a line. When justify is 'left', this is used as-is. When justify is 'spread' or 'space-between', the non-space characters (e.g. '|') are kept as a visual anchor and extra space is added around them.", + "default": " | " + }, + "justify": { + "type": "string", + "enum": [ + "left", + "spread", + "space-between" + ], + "description": "How sections distribute across the terminal width. 'left': pack left with fixed separators (flex sections expand). 'spread': distribute gaps evenly across all separators. 'space-between': first section flush left, last flush right, gaps evenly distributed.", + "default": "left" + }, + "vcs": { + "type": "string", + "enum": [ + "auto", + "git", + "jj", + "none" + ], + "description": "VCS detection mode", + "default": "auto" + }, + "width": { + "type": "integer", + "minimum": 40, + "description": "Explicit terminal width override. If omitted, auto-detection walks the process tree to find an ancestor with a real TTY, falling back to stty via /dev/tty, COLUMNS, tput cols, or 120." + }, + "width_margin": { + "type": "integer", + "minimum": 0, + "default": 4, + "description": "Columns to subtract from detected width. Accounts for terminal multiplexer borders (Zellij/tmux) or Claude Code UI chrome that reduce the actual visible area." + }, + "cache_dir": { + "type": "string", + "description": "Cache directory template. {session_id} is replaced at runtime.", + "default": "/tmp/claude-sl-{session_id}" + }, + "responsive": { + "type": "boolean", + "description": "Enable responsive layout selection based on terminal width", + "default": true + }, + "breakpoints": { + "type": "object", + "description": "Width breakpoints for responsive layout selection", + "properties": { + "narrow": { + "type": "integer", + "description": "Width below which 'dense' preset is used", + "default": 60 + }, + "medium": { + "type": "integer", + "description": "Width below which 'standard' preset is used (above uses 'verbose')", + "default": 100 + } + }, + "additionalProperties": false + }, + "theme": { + "type": "string", + "enum": ["auto", "dark", "light"], + "description": "Color theme. 'auto' detects from terminal.", + "default": "auto" + } + }, + "additionalProperties": false + }, + "colorName": { + "type": "string", + "enum": [ + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "dim", + "bold" + ], + "description": "Named ANSI color" + }, + "colorPalette": { + "type": "object", + "description": "Semantic color palette for a theme", + "properties": { + "success": { "type": "string" }, + "warning": { "type": "string" }, + "danger": { "type": "string" }, + "critical": { "type": "string" }, + "muted": { "type": "string" }, + "accent": { "type": "string" }, + "highlight": { "type": "string" }, + "info": { "type": "string" } + }, + "additionalProperties": false + }, + "layoutArray": { + "type": "array", + "description": "Array of lines, each line is an array of section IDs. Use 'spacer' (or '_spacer1', '_spacer2' etc.) as a virtual section that expands to fill remaining width, pushing sections apart.", + "items": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "minItems": 1, + "examples": [ + [ + [ + "model", + "provider", + "project", + "vcs" + ], + [ + "context_bar", + "cost", + "duration" + ] + ] + ] + }, + "priority": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ], + "description": "Display priority. 1=always show, 2=drop if tight, 3=drop first", + "default": 2 + }, + "thresholds": { + "type": "object", + "description": "Threshold values for color transitions", + "properties": { + "warn": { + "type": "number" + }, + "danger": { + "type": "number" + }, + "critical": { + "type": "number" + } + }, + "additionalProperties": false + }, + "basicSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "flex": { + "type": "boolean", + "default": false + }, + "min_width": { + "type": "integer", + "minimum": 0 + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "vcsSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "min_width": { + "type": "integer", + "minimum": 0, + "default": 8 + }, + "prefer": { + "type": "string", + "enum": [ + "auto", + "git", + "jj" + ], + "default": "auto", + "description": "VCS preference. auto detects .jj/ first, then .git/" + }, + "show_ahead_behind": { + "type": "boolean", + "default": true + }, + "show_dirty": { + "type": "boolean", + "default": true + }, + "ttl": { + "type": "object", + "properties": { + "branch": { + "type": "number", + "default": 3 + }, + "dirty": { + "type": "number", + "default": 5 + }, + "ahead_behind": { + "type": "number", + "default": 30 + } + }, + "additionalProperties": false + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "beadsSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "show_wip": { + "type": "boolean", + "default": true, + "description": "Show current WIP bead ID" + }, + "show_wip_count": { + "type": "boolean", + "default": true, + "description": "Show count of in-progress beads (medium+ width)" + }, + "show_ready_count": { + "type": "boolean", + "default": true, + "description": "Show count of ready beads" + }, + "show_open_count": { + "type": "boolean", + "default": true, + "description": "Show count of open/pending beads (medium+ width)" + }, + "show_closed_count": { + "type": "boolean", + "default": true, + "description": "Show count of completed beads (wide width only)" + }, + "ttl": { + "type": "number", + "default": 30 + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "contextBarSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "flex": { + "type": "boolean", + "default": true + }, + "min_width": { + "type": "integer", + "minimum": 0, + "default": 15 + }, + "bar_width": { + "type": "integer", + "minimum": 3, + "default": 10 + }, + "thresholds": { + "$ref": "#/$defs/thresholds" + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "contextUsageSection": { + "type": "object", + "description": "Shows context usage as 'used/total' (e.g., '125k/200k')", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "capacity": { + "type": "integer", + "default": 200000, + "description": "Context window capacity in tokens. Used when max_tokens is not provided by Claude Code." + }, + "thresholds": { + "$ref": "#/$defs/thresholds" + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "tokensRawSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "format": { + "type": "string", + "default": "{input} in/{output} out", + "description": "Format template. {input} and {output} are replaced." + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "costSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "thresholds": { + "$ref": "#/$defs/thresholds" + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "toolsSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "min_width": { + "type": "integer", + "minimum": 0, + "default": 6 + }, + "show_last_name": { + "type": "boolean", + "default": true + }, + "ttl": { + "type": "number", + "default": 2 + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "cachedSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "ttl": { + "type": "number" + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "timeSection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "format": { + "type": "string", + "default": "%H:%M", + "description": "strftime format string" + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + }, + "color": { + "$ref": "#/$defs/colorName" + } + }, + "additionalProperties": false + }, + "colorMatch": { + "type": "object", + "description": "Map of output value to color name", + "additionalProperties": { + "type": "string", + "enum": [ + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "dim", + "bold" + ] + } + }, + "customCommand": { + "type": "object", + "required": [ + "id", + "command" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique identifier, used in layout arrays", + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "label": { + "type": "string", + "description": "Display label prefix. Defaults to id." + }, + "command": { + "type": "string", + "description": "Shell command to execute. stdout is captured as the value." + }, + "ttl": { + "type": "number", + "description": "Cache TTL in seconds", + "default": 30 + }, + "priority": { + "$ref": "#/$defs/priority" + }, + "flex": { + "type": "boolean", + "default": false + }, + "min_width": { + "type": "integer", + "minimum": 0, + "default": 4 + }, + "color": { + "type": "object", + "properties": { + "match": { + "$ref": "#/$defs/colorMatch" + } + }, + "additionalProperties": false + }, + "default_color": { + "$ref": "#/$defs/colorName", + "description": "Default color override. Uses 'default_color' to avoid conflict with 'color.match'." + }, + "prefix": { + "type": "string" + }, + "suffix": { + "type": "string" + }, + "pad": { + "type": "integer", + "minimum": 1 + }, + "align": { + "type": "string", + "enum": ["left", "right", "center"], + "default": "left" + } + }, + "additionalProperties": false + } + } +} diff --git a/src/bin/claude-statusline.rs b/src/bin/claude-statusline.rs new file mode 100644 index 0000000..f0ff3c8 --- /dev/null +++ b/src/bin/claude-statusline.rs @@ -0,0 +1,288 @@ +use claude_statusline::section::RenderContext; +use claude_statusline::{cache, color, config, input, layout, metrics, section, theme, width}; +use std::io::Read; + +fn main() { + let args: Vec<String> = std::env::args().collect(); + + if args.iter().any(|a| a == "--help" || a == "-h") { + print_help(); + return; + } + if args.iter().any(|a| a == "--config-schema") { + println!("{}", include_str!("../../schema.json")); + return; + } + if args.iter().any(|a| a == "--print-defaults") { + println!("{}", include_str!("../../defaults.json")); + return; + } + if args.iter().any(|a| a == "--list-sections") { + for (id, _) in section::registry() { + println!("{id}"); + } + return; + } + + let cli_color = args + .iter() + .find_map(|a| a.strip_prefix("--color=")) + .map(|s| s.to_string()); + + let config_path = args + .iter() + .position(|a| a == "--config") + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()); + + let cli_width: Option<u16> = args.iter().find_map(|a| { + a.strip_prefix("--width=") + .and_then(|s| s.parse().ok()) + .or_else(|| { + args.iter() + .position(|x| x == "--width") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse().ok()) + }) + }); + + let is_test = args.iter().any(|a| a == "--test"); + let validate_config = args.iter().any(|a| a == "--validate-config"); + let dump_state = args.iter().find_map(|a| { + if a == "--dump-state" { + Some("text") + } else { + a.strip_prefix("--dump-state=") + } + }); + + // Load config + let (config, warnings) = match config::load_config(config_path) { + Ok(v) => v, + Err(e) => { + eprintln!("claude-statusline: {e}"); + std::process::exit(1); + } + }; + + if validate_config { + if warnings.is_empty() { + println!("config valid"); + std::process::exit(0); + } else { + for w in &warnings { + eprintln!("{w}"); + } + std::process::exit(1); + } + } + + if config.global.warn_unknown_keys { + for w in &warnings { + eprintln!("claude-statusline: {w}"); + } + } + + // Read stdin JSON + let input_data: input::InputData = if is_test { + test_input() + } else { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() { + return; + } + match serde_json::from_str(&buf) { + Ok(v) => v, + Err(e) => { + eprintln!("claude-statusline: stdin: {e}"); + std::process::exit(1); + } + } + }; + + // Detect environment + let detected_theme = theme::detect_theme(&config); + let term_width = + width::detect_width(cli_width, config.global.width, config.global.width_margin); + let tier = width::width_tier( + term_width, + config.global.breakpoints.narrow, + config.global.breakpoints.medium, + ); + + let project_dir = input_data + .workspace + .as_ref() + .and_then(|w| w.project_dir.as_deref()) + .unwrap_or("."); + + let session = cache::session_id(project_dir); + let cache = cache::Cache::new(&config.global.cache_dir, &session); + + let color_enabled = color::should_use_color(cli_color.as_deref(), &config.global.color); + + let vcs_type = detect_vcs(project_dir, &config); + + // Handle --dump-state + if let Some(format) = dump_state { + dump_state_output( + format, + &config, + term_width, + tier, + detected_theme, + vcs_type, + project_dir, + &session, + ); + return; + } + + // Build render context + let project_path = std::path::Path::new(project_dir); + let computed_metrics = metrics::ComputedMetrics::from_input(&input_data); + + let ctx = RenderContext { + input: &input_data, + config: &config, + theme: detected_theme, + width_tier: tier, + term_width, + vcs_type, + project_dir: project_path, + cache: &cache, + glyphs_enabled: config.glyphs.enabled, + color_enabled, + metrics: computed_metrics, + }; + + let output = layout::render_all(&ctx); + print!("{output}"); +} + +fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType { + let prefer = config.sections.vcs.prefer.as_str(); + let path = std::path::Path::new(dir); + + match prefer { + "jj" => { + if path.join(".jj").is_dir() { + section::VcsType::Jj + } else { + section::VcsType::None + } + } + "git" => { + if path.join(".git").is_dir() { + section::VcsType::Git + } else { + section::VcsType::None + } + } + _ => { + if path.join(".jj").is_dir() { + section::VcsType::Jj + } else if path.join(".git").is_dir() { + section::VcsType::Git + } else { + section::VcsType::None + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn dump_state_output( + format: &str, + config: &config::Config, + term_width: u16, + tier: width::WidthTier, + theme: theme::Theme, + vcs: section::VcsType, + project_dir: &str, + session_id: &str, +) { + let json = serde_json::json!({ + "terminal": { + "effective_width": term_width, + "width_margin": config.global.width_margin, + "width_tier": format!("{tier:?}"), + }, + "theme": theme.as_str(), + "vcs": format!("{vcs:?}"), + "layout": { + "justify": format!("{:?}", config.global.justify), + "separator": &config.global.separator, + }, + "paths": { + "project_dir": project_dir, + "cache_dir": config.global.cache_dir.replace("{session_id}", session_id), + }, + "session_id": session_id, + }); + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&json).unwrap()), + _ => println!("{json:#}"), + } +} + +fn test_input() -> input::InputData { + serde_json::from_str( + r#"{ + "model": { + "id": "claude-opus-4-6-20260101", + "display_name": "Opus 4.6" + }, + "cost": { + "total_cost_usd": 0.42, + "total_duration_ms": 840000, + "total_lines_added": 156, + "total_lines_removed": 23, + "total_tool_uses": 7, + "last_tool_name": "Edit", + "total_turns": 12 + }, + "context_window": { + "used_percentage": 58.5, + "total_input_tokens": 115000, + "total_output_tokens": 8500, + "context_window_size": 200000, + "current_usage": { + "cache_read_input_tokens": 75000, + "cache_creation_input_tokens": 15000 + } + }, + "workspace": { + "project_dir": "/Users/taylor/projects/foo" + }, + "version": "1.0.80", + "output_style": { + "name": "learning" + } + }"#, + ) + .unwrap_or_default() +} + +fn print_help() { + println!( + "claude-statusline \u{2014} Fast, configurable status line for Claude Code + +USAGE: + claude-statusline Read JSON from stdin, output status line + claude-statusline --test Render with mock data to validate config + claude-statusline --dump-state[=text|json] Output internal state for debugging + claude-statusline --validate-config Validate config (exit 0=ok, 1=errors) + claude-statusline --config-schema Print schema JSON to stdout + claude-statusline --print-defaults Print defaults JSON to stdout + claude-statusline --list-sections List all section IDs + claude-statusline --config <path> Use alternate config file + claude-statusline --no-cache Disable caching for this run + claude-statusline --no-shell Disable all shell-outs (serve stale cache) + claude-statusline --clear-cache Remove cache directory and exit + claude-statusline --width <cols> Force terminal width + claude-statusline --color=auto|always|never Override color detection + claude-statusline --help Show this help" + ); +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..3433a5e --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,154 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +pub struct Cache { + dir: Option<PathBuf>, +} + +impl Cache { + /// Create cache with secure directory. Returns disabled cache on failure. + pub fn new(template: &str, session_id: &str) -> Self { + let dir_str = template.replace("{session_id}", session_id); + let dir = PathBuf::from(&dir_str); + + if !dir.exists() { + if fs::create_dir_all(&dir).is_err() { + return Self { dir: None }; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); + } + } + + // Security: verify ownership, not a symlink, not world-writable + if !verify_cache_dir(&dir) { + return Self { dir: None }; + } + + Self { dir: Some(dir) } + } + + pub fn dir(&self) -> Option<&Path> { + self.dir.as_deref() + } + + /// Get cached value if fresher than TTL. + pub fn get(&self, key: &str, ttl: Duration) -> Option<String> { + let path = self.key_path(key)?; + let meta = fs::metadata(&path).ok()?; + let modified = meta.modified().ok()?; + let age = SystemTime::now().duration_since(modified).ok()?; + if age < ttl { + fs::read_to_string(&path).ok() + } else { + None + } + } + + /// Get stale cached value (ignores TTL). Used as fallback on command failure. + pub fn get_stale(&self, key: &str) -> Option<String> { + let path = self.key_path(key)?; + fs::read_to_string(&path).ok() + } + + /// Atomic write: write to .tmp then rename (prevents partial reads). + /// Uses flock with LOCK_NB — skips cache on contention rather than blocking. + pub fn set(&self, key: &str, value: &str) -> Option<()> { + let path = self.key_path(key)?; + let tmp = path.with_extension("tmp"); + + // Try non-blocking flock + let lock_path = path.with_extension("lock"); + let lock_file = fs::File::create(&lock_path).ok()?; + if !try_flock(&lock_file) { + return None; // contention — skip cache write + } + + let mut f = fs::File::create(&tmp).ok()?; + f.write_all(value.as_bytes()).ok()?; + fs::rename(&tmp, &path).ok()?; + + unlock(&lock_file); + Some(()) + } + + fn key_path(&self, key: &str) -> Option<PathBuf> { + let dir = self.dir.as_ref()?; + let safe_key: String = key + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + Some(dir.join(safe_key)) + } +} + +fn verify_cache_dir(dir: &Path) -> bool { + let meta = match fs::symlink_metadata(dir) { + Ok(m) => m, + Err(_) => return false, + }; + if !meta.is_dir() || meta.file_type().is_symlink() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Must be owned by current user + if meta.uid() != unsafe { libc::getuid() } { + return false; + } + // Must not be world-writable + if meta.mode() & 0o002 != 0 { + return false; + } + } + + true +} + +fn try_flock(file: &fs::File) -> bool { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + ret == 0 + } + #[cfg(not(unix))] + { + let _ = file; + true + } +} + +fn unlock(file: &fs::File) { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + unsafe { + libc::flock(file.as_raw_fd(), libc::LOCK_UN); + } + } + #[cfg(not(unix))] + { + let _ = file; + } +} + +/// Session ID: first 12 chars of MD5 hex of project_dir. +/// Same algorithm as bash for cache compatibility during migration. +pub fn session_id(project_dir: &str) -> String { + use md5::{Digest, Md5}; + let hash = Md5::digest(project_dir.as_bytes()); + format!("{:x}", hash)[..12].to_string() +} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..e0ad97a --- /dev/null +++ b/src/color.rs @@ -0,0 +1,76 @@ +use crate::config::ThemeColors; +use crate::theme::Theme; + +pub const RESET: &str = "\x1b[0m"; +pub const BOLD: &str = "\x1b[1m"; +pub const DIM: &str = "\x1b[2m"; +pub const RED: &str = "\x1b[31m"; +pub const GREEN: &str = "\x1b[32m"; +pub const YELLOW: &str = "\x1b[33m"; +pub const BLUE: &str = "\x1b[34m"; +pub const MAGENTA: &str = "\x1b[35m"; +pub const CYAN: &str = "\x1b[36m"; +pub const WHITE: &str = "\x1b[37m"; + +/// Resolve a color name to ANSI escape sequence(s). +pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String { + if let Some(key) = name.strip_prefix("p:") { + let map = match theme { + Theme::Dark => &palette.dark, + Theme::Light => &palette.light, + }; + if let Some(resolved) = map.get(key) { + return resolve_color(resolved, theme, palette); + } + return RESET.to_string(); + } + + let mut result = String::new(); + for part in name.split_whitespace() { + result.push_str(match part { + "red" => RED, + "green" => GREEN, + "yellow" => YELLOW, + "blue" => BLUE, + "magenta" => MAGENTA, + "cyan" => CYAN, + "white" => WHITE, + "dim" => DIM, + "bold" => BOLD, + _ => "", + }); + } + + if result.is_empty() { + RESET.to_string() + } else { + result + } +} + +/// Determine whether color output should be used. +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; + } + + if let Some(flag) = cli_color { + return match flag { + "always" => true, + "never" => false, + _ => atty_stdout(), + }; + } + + match config_color { + crate::config::ColorMode::Always => true, + crate::config::ColorMode::Never => false, + crate::config::ColorMode::Auto => { + atty_stdout() && std::env::var("TERM").map_or(true, |t| t != "dumb") + } + } +} + +fn atty_stdout() -> bool { + unsafe { libc::isatty(libc::STDOUT_FILENO) != 0 } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..02f57ee --- /dev/null +++ b/src/config.rs @@ -0,0 +1,721 @@ +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; + +const DEFAULTS_JSON: &str = include_str!("../defaults.json"); + +// ── Top-level Config ──────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct Config { + pub version: u32, + pub global: GlobalConfig, + pub colors: ThemeColors, + pub glyphs: GlyphConfig, + pub presets: HashMap<String, Vec<Vec<String>>>, + pub layout: LayoutValue, + pub sections: Sections, + pub custom: Vec<CustomCommand>, +} + +impl Default for Config { + fn default() -> Self { + serde_json::from_str(DEFAULTS_JSON).expect("embedded defaults must parse") + } +} + +// ── Layout: preset name or explicit array ─────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum LayoutValue { + Preset(String), + Custom(Vec<Vec<String>>), +} + +impl Default for LayoutValue { + fn default() -> Self { + Self::Preset("standard".into()) + } +} + +// ── Global settings ───────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct GlobalConfig { + pub separator: String, + pub justify: JustifyMode, + pub vcs: String, + pub width: Option<u16>, + pub width_margin: u16, + pub cache_dir: String, + pub cache_gc_days: u16, + pub cache_gc_interval_hours: u16, + pub cache_ttl_jitter_pct: u8, + pub responsive: bool, + pub breakpoints: Breakpoints, + pub render_budget_ms: u64, + pub theme: String, + pub color: ColorMode, + pub warn_unknown_keys: bool, + pub shell_enabled: bool, + pub shell_allowlist: Vec<String>, + pub shell_denylist: Vec<String>, + pub shell_timeout_ms: u64, + pub shell_max_output_bytes: usize, + pub shell_failure_threshold: u8, + pub shell_cooldown_ms: u64, + pub shell_env: HashMap<String, String>, + pub cache_version: u32, + pub drop_strategy: String, + pub breakpoint_hysteresis: u16, +} + +impl Default for GlobalConfig { + fn default() -> Self { + Self { + separator: " | ".into(), + justify: JustifyMode::Left, + vcs: "auto".into(), + width: None, + width_margin: 4, + cache_dir: "/tmp/claude-sl-{session_id}".into(), + cache_gc_days: 7, + cache_gc_interval_hours: 24, + cache_ttl_jitter_pct: 10, + responsive: true, + breakpoints: Breakpoints::default(), + render_budget_ms: 8, + theme: "auto".into(), + color: ColorMode::Auto, + warn_unknown_keys: true, + shell_enabled: true, + shell_allowlist: Vec::new(), + shell_denylist: Vec::new(), + shell_timeout_ms: 200, + shell_max_output_bytes: 8192, + shell_failure_threshold: 3, + shell_cooldown_ms: 30_000, + shell_env: HashMap::new(), + cache_version: 1, + drop_strategy: "tiered".into(), + breakpoint_hysteresis: 2, + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum JustifyMode { + #[default] + Left, + Spread, + SpaceBetween, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ColorMode { + #[default] + Auto, + Always, + Never, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct Breakpoints { + pub narrow: u16, + pub medium: u16, +} + +impl Default for Breakpoints { + fn default() -> Self { + Self { + narrow: 60, + medium: 100, + } + } +} + +// ── Color palettes ────────────────────────────────────────────────────── + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ThemeColors { + pub dark: HashMap<String, String>, + pub light: HashMap<String, String>, +} + +// ── Glyph config ──────────────────────────────────────────────────────── + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct GlyphConfig { + pub enabled: bool, + pub set: HashMap<String, String>, + pub fallback: HashMap<String, String>, +} + +// ── Shared section base (flattened into each section) ─────────────────── + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SectionBase { + pub enabled: bool, + pub priority: u8, + pub flex: bool, + pub min_width: Option<u16>, + pub prefix: Option<String>, + pub suffix: Option<String>, + pub pad: Option<u16>, + pub align: Option<String>, + pub color: Option<String>, +} + +impl Default for SectionBase { + fn default() -> Self { + Self { + enabled: true, + priority: 2, + flex: false, + min_width: None, + prefix: None, + suffix: None, + pad: None, + align: None, + color: None, + } + } +} + +// ── Per-section typed configs ─────────────────────────────────────────── + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Sections { + pub model: SectionBase, + pub provider: SectionBase, + pub project: ProjectSection, + pub vcs: VcsSection, + pub beads: BeadsSection, + pub context_bar: ContextBarSection, + pub context_usage: ContextUsageSection, + pub context_remaining: ContextRemainingSection, + pub tokens_raw: TokensRawSection, + pub cache_efficiency: SectionBase, + pub cost: CostSection, + pub cost_velocity: SectionBase, + pub token_velocity: SectionBase, + pub cost_trend: TrendSection, + pub context_trend: ContextTrendSection, + pub lines_changed: SectionBase, + pub duration: SectionBase, + pub tools: ToolsSection, + pub turns: CachedSection, + pub load: CachedSection, + pub version: SectionBase, + pub time: TimeSection, + pub output_style: SectionBase, + pub hostname: SectionBase, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ProjectSection { + #[serde(flatten)] + pub base: SectionBase, + pub truncate: TruncateConfig, +} + +impl Default for ProjectSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 1, + ..Default::default() + }, + truncate: TruncateConfig { + enabled: true, + max: 30, + style: "middle".into(), + }, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct TruncateConfig { + pub enabled: bool, + pub max: usize, + pub style: String, +} + +impl Default for TruncateConfig { + fn default() -> Self { + Self { + enabled: false, + max: 0, + style: "right".into(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct VcsSection { + #[serde(flatten)] + pub base: SectionBase, + pub prefer: String, + pub show_ahead_behind: bool, + pub show_dirty: bool, + pub untracked: String, + pub submodules: bool, + pub fast_mode: bool, + pub truncate: TruncateConfig, + pub ttl: VcsTtl, +} + +impl Default for VcsSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 1, + min_width: Some(8), + ..Default::default() + }, + prefer: "auto".into(), + show_ahead_behind: true, + show_dirty: true, + untracked: "normal".into(), + submodules: false, + fast_mode: false, + truncate: TruncateConfig { + enabled: true, + max: 25, + style: "right".into(), + }, + ttl: VcsTtl::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct VcsTtl { + pub branch: u64, + pub dirty: u64, + pub ahead_behind: u64, +} + +impl Default for VcsTtl { + fn default() -> Self { + Self { + branch: 3, + dirty: 5, + ahead_behind: 30, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct BeadsSection { + #[serde(flatten)] + pub base: SectionBase, + pub show_wip: bool, + pub show_wip_count: bool, + pub show_ready_count: bool, + pub show_open_count: bool, + pub show_closed_count: bool, + pub ttl: u64, +} + +impl Default for BeadsSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 3, + ..Default::default() + }, + show_wip: true, + show_wip_count: true, + show_ready_count: true, + show_open_count: true, + show_closed_count: true, + ttl: 30, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextBarSection { + #[serde(flatten)] + pub base: SectionBase, + pub bar_width: u16, + pub thresholds: Thresholds, +} + +impl Default for ContextBarSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 1, + flex: true, + min_width: Some(15), + ..Default::default() + }, + bar_width: 10, + thresholds: Thresholds { + warn: 50.0, + danger: 70.0, + critical: 85.0, + }, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Thresholds { + pub warn: f64, + pub danger: f64, + pub critical: f64, +} + +impl Default for Thresholds { + fn default() -> Self { + Self { + warn: 50.0, + danger: 70.0, + critical: 85.0, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextUsageSection { + #[serde(flatten)] + pub base: SectionBase, + pub capacity: u64, + pub thresholds: Thresholds, +} + +impl Default for ContextUsageSection { + fn default() -> Self { + Self { + base: SectionBase { + enabled: false, + priority: 2, + ..Default::default() + }, + capacity: 200_000, + thresholds: Thresholds::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextRemainingSection { + #[serde(flatten)] + pub base: SectionBase, + pub format: String, + pub thresholds: Thresholds, +} + +impl Default for ContextRemainingSection { + fn default() -> Self { + Self { + base: SectionBase { + enabled: false, + priority: 2, + ..Default::default() + }, + format: "{remaining} left".into(), + thresholds: Thresholds::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct TokensRawSection { + #[serde(flatten)] + pub base: SectionBase, + pub format: String, +} + +impl Default for TokensRawSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 3, + ..Default::default() + }, + format: "{input} in/{output} out".into(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct CostSection { + #[serde(flatten)] + pub base: SectionBase, + pub thresholds: Thresholds, +} + +impl Default for CostSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 1, + ..Default::default() + }, + thresholds: Thresholds { + warn: 5.0, + danger: 8.0, + critical: 10.0, + }, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct TrendSection { + #[serde(flatten)] + pub base: SectionBase, + pub width: u8, +} + +impl Default for TrendSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 3, + ..Default::default() + }, + width: 8, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ContextTrendSection { + #[serde(flatten)] + pub base: SectionBase, + pub width: u8, + pub thresholds: Thresholds, +} + +impl Default for ContextTrendSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 3, + ..Default::default() + }, + width: 8, + thresholds: Thresholds::default(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct ToolsSection { + #[serde(flatten)] + pub base: SectionBase, + pub show_last_name: bool, + pub ttl: u64, +} + +impl Default for ToolsSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 2, + min_width: Some(6), + ..Default::default() + }, + show_last_name: true, + ttl: 2, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct CachedSection { + #[serde(flatten)] + pub base: SectionBase, + pub ttl: u64, +} + +impl Default for CachedSection { + fn default() -> Self { + Self { + base: SectionBase { + priority: 3, + ..Default::default() + }, + ttl: 10, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct TimeSection { + #[serde(flatten)] + pub base: SectionBase, + pub format: String, +} + +impl Default for TimeSection { + fn default() -> Self { + Self { + base: SectionBase { + enabled: false, + priority: 3, + ..Default::default() + }, + format: "%H:%M".into(), + } + } +} + +// ── Custom command sections ───────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +pub struct CustomCommand { + pub id: String, + #[serde(default)] + pub command: Option<String>, + #[serde(default)] + pub exec: Option<Vec<String>>, + #[serde(default)] + pub format: Option<String>, + #[serde(default)] + pub label: Option<String>, + #[serde(default = "default_custom_ttl")] + pub ttl: u64, + #[serde(default = "default_priority")] + pub priority: u8, + #[serde(default)] + pub flex: bool, + #[serde(default)] + pub min_width: Option<u16>, + #[serde(default)] + pub color: Option<CustomColor>, + #[serde(default)] + pub default_color: Option<String>, + #[serde(default)] + pub prefix: Option<String>, + #[serde(default)] + pub suffix: Option<String>, + #[serde(default)] + pub pad: Option<u16>, + #[serde(default)] + pub align: Option<String>, +} + +fn default_custom_ttl() -> u64 { + 30 +} +fn default_priority() -> u8 { + 2 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CustomColor { + #[serde(rename = "match", default)] + pub match_map: HashMap<String, String>, +} + +// ── Deep merge ────────────────────────────────────────────────────────── + +/// Recursive JSON merge: user values win, arrays replaced entirely. +pub fn deep_merge(base: &mut Value, patch: &Value) { + match (base, patch) { + (Value::Object(base_map), Value::Object(patch_map)) => { + for (k, v) in patch_map { + let entry = base_map.entry(k.clone()).or_insert(Value::Null); + deep_merge(entry, v); + } + } + (base, patch) => { + *base = patch.clone(); + } + } +} + +// ── Config loading ────────────────────────────────────────────────────── + +/// Load config: embedded defaults deep-merged with user overrides. +pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec<String>), crate::Error> { + let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?; + + let user_path = explicit_path + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var("CLAUDE_STATUSLINE_CONFIG") + .ok() + .map(Into::into) + }) + .or_else(xdg_config_path) + .or_else(dot_config_path) + .unwrap_or_else(|| { + let mut p = dirs_home().unwrap_or_default(); + p.push(".claude/statusline.json"); + p + }); + + if user_path.exists() { + let user_json: Value = serde_json::from_str(&std::fs::read_to_string(&user_path)?)?; + deep_merge(&mut base, &user_json); + } else if explicit_path.is_some() { + return Err(crate::Error::ConfigNotFound( + user_path.display().to_string(), + )); + } + + let mut warnings = Vec::new(); + let config: Config = serde_ignored::deserialize(base, |path| { + warnings.push(format!("unknown config key: {path}")); + })?; + + Ok((config, warnings)) +} + +fn xdg_config_path() -> Option<std::path::PathBuf> { + let val = std::env::var("XDG_CONFIG_HOME").ok()?; + let mut p = std::path::PathBuf::from(val); + p.push("claude/statusline.json"); + if p.exists() { + Some(p) + } else { + None + } +} + +fn dot_config_path() -> Option<std::path::PathBuf> { + let mut p = dirs_home()?; + p.push(".config/claude/statusline.json"); + if p.exists() { + Some(p) + } else { + None + } +} + +fn dirs_home() -> Option<std::path::PathBuf> { + std::env::var("HOME").ok().map(Into::into) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..31c5cda --- /dev/null +++ b/src/error.rs @@ -0,0 +1,35 @@ +use std::fmt; +use std::io; + +#[derive(Debug)] +pub enum Error { + Io(io::Error), + Json(serde_json::Error), + ConfigNotFound(String), + EmptyStdin, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "io: {e}"), + Self::Json(e) => write!(f, "json: {e}"), + Self::ConfigNotFound(p) => write!(f, "config not found: {p}"), + Self::EmptyStdin => write!(f, "empty stdin"), + } + } +} + +impl std::error::Error for Error {} + +impl From<io::Error> for Error { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +impl From<serde_json::Error> for Error { + fn from(e: serde_json::Error) -> Self { + Self::Json(e) + } +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..b4c0a52 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,177 @@ +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::color; +use crate::config::SectionBase; +use crate::theme::Theme; + +/// Display width using unicode-width (handles CJK, emoji, Nerd Font glyphs). +pub fn display_width(s: &str) -> usize { + // Strip ANSI escape sequences before measuring + let stripped = strip_ansi(s); + UnicodeWidthStr::width(stripped.as_str()) +} + +fn strip_ansi(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut in_escape = false; + for c in s.chars() { + if in_escape { + if c.is_ascii_alphabetic() { + in_escape = false; + } + } else if c == '\x1b' { + in_escape = true; + } else { + result.push(c); + } + } + result +} + +/// Format token count: 1500 -> "1.5k", 2000000 -> "2.0M" +pub fn human_tokens(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}k", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + +/// Format duration: 840000ms -> "14m", 7200000ms -> "2h0m", 45000ms -> "45s" +pub fn human_duration(ms: u64) -> String { + let secs = ms / 1000; + if secs >= 3600 { + format!("{}h{}m", secs / 3600, (secs % 3600) / 60) + } else if secs >= 60 { + format!("{}m", secs / 60) + } else { + format!("{secs}s") + } +} + +/// Grapheme-cluster-safe truncation. +pub fn truncate(s: &str, max: usize, style: &str) -> String { + let w = display_width(s); + if w <= max { + return s.to_string(); + } + + let graphemes: Vec<&str> = s.graphemes(true).collect(); + match style { + "middle" => truncate_middle(&graphemes, max), + "left" => truncate_left(&graphemes, max), + _ => truncate_right(&graphemes, max), + } +} + +fn truncate_right(graphemes: &[&str], max: usize) -> String { + let mut result = String::new(); + let mut w = 0; + for &g in graphemes { + let gw = UnicodeWidthStr::width(g); + if w + gw + 1 > max { + break; + } + result.push_str(g); + w += gw; + } + result.push('\u{2026}'); + result +} + +fn truncate_middle(graphemes: &[&str], max: usize) -> String { + let half = (max - 1) / 2; + let mut left = String::new(); + let mut left_w = 0; + for &g in graphemes { + let gw = UnicodeWidthStr::width(g); + if left_w + gw > half { + break; + } + left.push_str(g); + left_w += gw; + } + + let right_budget = max - 1 - left_w; + let mut right_graphemes = Vec::new(); + let mut right_w = 0; + for &g in graphemes.iter().rev() { + let gw = UnicodeWidthStr::width(g); + if right_w + gw > right_budget { + break; + } + right_graphemes.push(g); + right_w += gw; + } + right_graphemes.reverse(); + + format!("{left}\u{2026}{}", right_graphemes.join("")) +} + +fn truncate_left(graphemes: &[&str], max: usize) -> String { + let budget = max - 1; + let mut parts = Vec::new(); + let mut w = 0; + for &g in graphemes.iter().rev() { + let gw = UnicodeWidthStr::width(g); + if w + gw > budget { + break; + } + parts.push(g); + w += gw; + } + parts.reverse(); + format!("\u{2026}{}", parts.join("")) +} + +/// Apply per-section formatting: prefix, suffix, color override, pad+align. +pub fn apply_formatting( + raw: &mut String, + ansi: &mut String, + base: &SectionBase, + theme: Theme, + palette: &crate::config::ThemeColors, +) { + if let Some(ref pfx) = base.prefix { + *raw = format!("{pfx}{raw}"); + *ansi = format!("{pfx}{ansi}"); + } + if let Some(ref sfx) = base.suffix { + raw.push_str(sfx); + ansi.push_str(sfx); + } + + if let Some(ref color_name) = base.color { + let c = color::resolve_color(color_name, theme, palette); + *ansi = format!("{c}{raw}{}", color::RESET); + } + + if let Some(pad) = base.pad { + let pad = pad as usize; + let raw_w = display_width(raw); + if pad > raw_w { + let needed = pad - raw_w; + let spaces: String = " ".repeat(needed); + let align = base.align.as_deref().unwrap_or("left"); + match align { + "right" => { + *raw = format!("{spaces}{raw}"); + *ansi = format!("{spaces}{ansi}"); + } + "center" => { + let left = needed / 2; + let right = needed - left; + *raw = format!("{}{raw}{}", " ".repeat(left), " ".repeat(right)); + *ansi = format!("{}{ansi}{}", " ".repeat(left), " ".repeat(right)); + } + _ => { + raw.push_str(&spaces); + ansi.push_str(&spaces); + } + } + } + } +} diff --git a/src/glyph.rs b/src/glyph.rs new file mode 100644 index 0000000..f5dda17 --- /dev/null +++ b/src/glyph.rs @@ -0,0 +1,13 @@ +use crate::config::GlyphConfig; + +/// Look up a named glyph. Returns Nerd Font icon when enabled, ASCII fallback otherwise. +pub fn glyph<'a>(name: &str, config: &'a GlyphConfig) -> &'a str { + if config.enabled { + if let Some(val) = config.set.get(name) { + if !val.is_empty() { + return val; + } + } + } + config.fallback.get(name).map(|s| s.as_str()).unwrap_or("") +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..bd8a32c --- /dev/null +++ b/src/input.rs @@ -0,0 +1,60 @@ +use serde::Deserialize; + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct InputData { + pub model: Option<ModelInfo>, + pub cost: Option<CostInfo>, + pub context_window: Option<ContextWindow>, + pub workspace: Option<Workspace>, + pub version: Option<String>, + pub output_style: Option<OutputStyle>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ModelInfo { + pub id: Option<String>, + pub display_name: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct CostInfo { + pub total_cost_usd: Option<f64>, + pub total_duration_ms: Option<u64>, + pub total_lines_added: Option<u64>, + pub total_lines_removed: Option<u64>, + pub total_tool_uses: Option<u64>, + pub last_tool_name: Option<String>, + pub total_turns: Option<u64>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct ContextWindow { + pub used_percentage: Option<f64>, + pub total_input_tokens: Option<u64>, + pub total_output_tokens: Option<u64>, + pub context_window_size: Option<u64>, + pub current_usage: Option<CurrentUsage>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct CurrentUsage { + pub cache_read_input_tokens: Option<u64>, + pub cache_creation_input_tokens: Option<u64>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct Workspace { + pub project_dir: Option<String>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct OutputStyle { + pub name: Option<String>, +} diff --git a/src/layout/flex.rs b/src/layout/flex.rs new file mode 100644 index 0000000..9aa538c --- /dev/null +++ b/src/layout/flex.rs @@ -0,0 +1,82 @@ +use crate::format; +use crate::layout::ActiveSection; +use crate::section::{self, RenderContext}; + +/// Expand the winning flex section to fill remaining terminal width. +/// +/// Rules: +/// - Spacers take priority over non-spacer flex sections +/// - Only one flex section wins per line +/// - Spacer: fill with spaces +/// - context_bar: rebuild bar with wider width +/// - Other: pad with trailing spaces +pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator: &str) { + let current_width = line_width(active, separator); + let term_width = ctx.term_width as usize; + + if current_width >= term_width { + return; + } + + // Find winning flex section: spacer wins over non-spacer + let mut flex_idx: Option<usize> = None; + for (i, sec) in active.iter().enumerate() { + if !sec.is_flex { + continue; + } + match flex_idx { + None => flex_idx = Some(i), + Some(prev) => { + if sec.is_spacer && !active[prev].is_spacer { + flex_idx = Some(i); + } + } + } + } + + let Some(idx) = flex_idx else { return }; + let extra = term_width - current_width; + + if active[idx].is_spacer { + let padding = " ".repeat(extra + 1); + active[idx].output = section::SectionOutput { + raw: padding.clone(), + ansi: padding, + }; + } else if active[idx].id == "context_bar" { + // Rebuild context_bar with wider bar_width + let cur_bar_width = ctx.config.sections.context_bar.bar_width; + let new_bar_width = cur_bar_width + extra as u16; + if let Some(mut output) = section::context_bar::render_at_width(ctx, new_bar_width) { + // Re-apply formatting after flex rebuild + let base = &ctx.config.sections.context_bar.base; + format::apply_formatting( + &mut output.raw, + &mut output.ansi, + base, + ctx.theme, + &ctx.config.colors, + ); + active[idx].output = output; + } + } else { + let padding = " ".repeat(extra); + active[idx].output.raw.push_str(&padding); + active[idx].output.ansi.push_str(&padding); + } +} + +fn line_width(active: &[ActiveSection], separator: &str) -> usize { + let sep_w = format::display_width(separator); + let mut total = 0; + for (i, sec) in active.iter().enumerate() { + if i > 0 { + let prev = &active[i - 1]; + if !prev.is_spacer && !sec.is_spacer { + total += sep_w; + } + } + total += format::display_width(&sec.output.raw); + } + total +} diff --git a/src/layout/justify.rs b/src/layout/justify.rs new file mode 100644 index 0000000..298204c --- /dev/null +++ b/src/layout/justify.rs @@ -0,0 +1,63 @@ +use crate::config::JustifyMode; +use crate::format; +use crate::layout::ActiveSection; + +/// Distribute extra space evenly across gaps between sections. +/// Center the separator core (e.g., `|` from ` | `) within each gap. +/// Remainder chars distributed left-to-right. +pub fn justify( + active: &[ActiveSection], + term_width: u16, + separator: &str, + _mode: JustifyMode, +) -> String { + let content_width: usize = active + .iter() + .map(|s| format::display_width(&s.output.raw)) + .sum(); + + let num_gaps = active.len().saturating_sub(1); + if num_gaps == 0 { + return active + .first() + .map(|s| s.output.ansi.clone()) + .unwrap_or_default(); + } + + let available = (term_width as usize).saturating_sub(content_width); + let gap_width = available / num_gaps; + let gap_remainder = available % num_gaps; + + // Extract separator core (non-space chars, e.g. "|" from " | ") + let sep_core = separator.trim(); + let sep_core_len = format::display_width(sep_core); + + let mut output = String::new(); + for (i, sec) in active.iter().enumerate() { + if i > 0 { + let this_gap = gap_width + usize::from(i - 1 < gap_remainder); + let gap_str = build_gap(sep_core, sep_core_len, this_gap); + output.push_str(&format!( + "{}{gap_str}{}", + crate::color::DIM, + crate::color::RESET + )); + } + output.push_str(&sec.output.ansi); + } + + output +} + +/// Center the separator core within a gap of `total` columns. +fn build_gap(core: &str, core_len: usize, total: usize) -> String { + if core.is_empty() || core_len == 0 { + return " ".repeat(total); + } + + let pad_total = total.saturating_sub(core_len); + let pad_left = pad_total / 2; + let pad_right = pad_total - pad_left; + + format!("{}{core}{}", " ".repeat(pad_left), " ".repeat(pad_right)) +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs new file mode 100644 index 0000000..51fa8d8 --- /dev/null +++ b/src/layout/mod.rs @@ -0,0 +1,177 @@ +pub mod flex; +pub mod justify; +pub mod priority; + +use crate::config::{Config, JustifyMode, LayoutValue}; +use crate::section::{self, RenderContext, SectionOutput}; + +/// A section that survived priority drops and has rendered output. +pub struct ActiveSection { + pub id: String, + pub output: SectionOutput, + pub priority: u8, + pub is_spacer: bool, + pub is_flex: bool, +} + +/// Resolve layout: preset lookup with optional responsive override. +pub fn resolve_layout(config: &Config, term_width: u16) -> Vec<Vec<String>> { + match &config.layout { + LayoutValue::Preset(name) => { + let effective = if config.global.responsive { + responsive_preset(term_width, &config.global.breakpoints) + } else { + name.as_str() + }; + config + .presets + .get(effective) + .or_else(|| config.presets.get(name.as_str())) + .cloned() + .unwrap_or_else(|| vec![vec!["model".into(), "project".into()]]) + } + LayoutValue::Custom(lines) => lines.clone(), + } +} + +fn responsive_preset(width: u16, bp: &crate::config::Breakpoints) -> &'static str { + if width < bp.narrow { + "dense" + } else if width < bp.medium { + "standard" + } else { + "verbose" + } +} + +/// Full render: resolve layout, render each line, join with newlines. +pub fn render_all(ctx: &RenderContext) -> String { + let layout = resolve_layout(ctx.config, ctx.term_width); + let separator = &ctx.config.global.separator; + + let lines: Vec<String> = layout + .iter() + .filter_map(|line_ids| render_line(line_ids, ctx, separator)) + .collect(); + + lines.join("\n") +} + +/// Render a single layout line. +/// Three phases: render all -> priority drop -> flex/justify. +fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) -> Option<String> { + // Phase 1: Render all sections, collect active ones + let mut active: Vec<ActiveSection> = Vec::new(); + + for id in section_ids { + if let Some(output) = section::render_section(id, ctx) { + if output.raw.is_empty() && !section::is_spacer(id) { + continue; + } + + let (prio, is_flex) = section_meta(id, ctx.config); + active.push(ActiveSection { + id: id.clone(), + output, + priority: prio, + is_spacer: section::is_spacer(id), + is_flex, + }); + } + } + + if active.is_empty() || active.iter().all(|s| s.is_spacer) { + return None; + } + + // Phase 2: Priority drop if overflowing + let mut active = priority::priority_drop(active, ctx.term_width, separator); + + // Phase 3: Flex expand or justify + let line = if ctx.config.global.justify != JustifyMode::Left + && !active.iter().any(|s| s.is_spacer) + && active.len() > 1 + { + justify::justify( + &active, + ctx.term_width, + separator, + ctx.config.global.justify, + ) + } else { + flex::flex_expand(&mut active, ctx, separator); + assemble_left(&active, separator, ctx.color_enabled) + }; + + Some(line) +} + +/// Left-aligned assembly with separator dimming and spacer suppression. +fn assemble_left(active: &[ActiveSection], separator: &str, color_enabled: bool) -> String { + let mut output = String::new(); + let mut prev_is_spacer = false; + + for (i, sec) in active.iter().enumerate() { + if i > 0 && !prev_is_spacer && !sec.is_spacer { + if color_enabled { + output.push_str(&format!( + "{}{separator}{}", + crate::color::DIM, + crate::color::RESET + )); + } else { + output.push_str(separator); + } + } + output.push_str(&sec.output.ansi); + prev_is_spacer = sec.is_spacer; + } + + output +} + +/// Look up section priority and flex from config. +fn section_meta(id: &str, config: &Config) -> (u8, bool) { + if section::is_spacer(id) { + return (1, true); + } + + macro_rules! meta_base { + ($section:expr) => { + ($section.priority, $section.flex) + }; + } + macro_rules! meta_flat { + ($section:expr) => { + ($section.base.priority, $section.base.flex) + }; + } + + match id { + "model" => meta_base!(config.sections.model), + "provider" => meta_base!(config.sections.provider), + "project" => meta_flat!(config.sections.project), + "vcs" => meta_flat!(config.sections.vcs), + "beads" => meta_flat!(config.sections.beads), + "context_bar" => meta_flat!(config.sections.context_bar), + "context_usage" => meta_flat!(config.sections.context_usage), + "context_remaining" => meta_flat!(config.sections.context_remaining), + "tokens_raw" => meta_flat!(config.sections.tokens_raw), + "cache_efficiency" => meta_base!(config.sections.cache_efficiency), + "cost" => meta_flat!(config.sections.cost), + "cost_velocity" => meta_base!(config.sections.cost_velocity), + "token_velocity" => meta_base!(config.sections.token_velocity), + "cost_trend" => meta_flat!(config.sections.cost_trend), + "context_trend" => meta_flat!(config.sections.context_trend), + "lines_changed" => meta_base!(config.sections.lines_changed), + "duration" => meta_base!(config.sections.duration), + "tools" => meta_flat!(config.sections.tools), + "turns" => meta_flat!(config.sections.turns), + "load" => meta_flat!(config.sections.load), + "version" => meta_base!(config.sections.version), + "time" => meta_flat!(config.sections.time), + "output_style" => meta_base!(config.sections.output_style), + "hostname" => meta_base!(config.sections.hostname), + _ => (2, false), // custom sections default + } +} diff --git a/src/layout/priority.rs b/src/layout/priority.rs new file mode 100644 index 0000000..bbd11b2 --- /dev/null +++ b/src/layout/priority.rs @@ -0,0 +1,43 @@ +use crate::format; +use crate::layout::ActiveSection; + +/// Drop priority 3 sections (all at once), then priority 2, until line fits. +/// Priority 1 sections never drop. +pub fn priority_drop( + mut active: Vec<ActiveSection>, + term_width: u16, + separator: &str, +) -> Vec<ActiveSection> { + if line_width(&active, separator) <= term_width as usize { + return active; + } + + // Drop all priority 3 + active.retain(|s| s.priority < 3); + if line_width(&active, separator) <= term_width as usize { + return active; + } + + // Drop all priority 2 + active.retain(|s| s.priority < 2); + active +} + +/// Calculate total display width including separators. +/// Spacers suppress adjacent separators on both sides. +fn line_width(active: &[ActiveSection], separator: &str) -> usize { + let sep_w = format::display_width(separator); + let mut total = 0; + + for (i, sec) in active.iter().enumerate() { + if i > 0 { + let prev = &active[i - 1]; + if !prev.is_spacer && !sec.is_spacer { + total += sep_w; + } + } + total += format::display_width(&sec.output.raw); + } + + total +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..bdb2b76 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +pub mod error; +pub use error::Error; +pub mod cache; +pub mod color; +pub mod config; +pub mod format; +pub mod glyph; +pub mod input; +pub mod layout; +pub mod metrics; +pub mod section; +pub mod shell; +pub mod theme; +pub mod trend; +pub mod width; diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..6804d4c --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,47 @@ +use crate::input::InputData; + +/// Derived metrics computed once from InputData, reused by all sections. +#[derive(Debug, Clone, Default)] +pub struct ComputedMetrics { + pub total_tokens: u64, + pub usage_pct: f64, + pub cost_velocity: Option<f64>, + pub token_velocity: Option<f64>, + pub cache_efficiency_pct: Option<f64>, +} + +impl ComputedMetrics { + pub fn from_input(input: &InputData) -> Self { + let mut m = Self::default(); + + if let Some(ref cw) = input.context_window { + let input_tok = cw.total_input_tokens.unwrap_or(0); + let output_tok = cw.total_output_tokens.unwrap_or(0); + m.total_tokens = input_tok + output_tok; + m.usage_pct = cw.used_percentage.unwrap_or(0.0); + + if let Some(ref usage) = cw.current_usage { + let cache_read = usage.cache_read_input_tokens.unwrap_or(0); + let cache_create = usage.cache_creation_input_tokens.unwrap_or(0); + let total_cache = cache_read + cache_create; + if total_cache > 0 { + m.cache_efficiency_pct = Some(cache_read as f64 / total_cache as f64 * 100.0); + } + } + } + + if let Some(ref cost) = input.cost { + if let (Some(cost_usd), Some(duration_ms)) = + (cost.total_cost_usd, cost.total_duration_ms) + { + if duration_ms > 0 { + let minutes = duration_ms as f64 / 60_000.0; + m.cost_velocity = Some(cost_usd / minutes); + m.token_velocity = Some(m.total_tokens as f64 / minutes); + } + } + } + + m + } +} diff --git a/src/section/beads.rs b/src/section/beads.rs new file mode 100644 index 0000000..a7fdfcb --- /dev/null +++ b/src/section/beads.rs @@ -0,0 +1,48 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use crate::shell; +use std::time::Duration; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.beads.base.enabled { + return None; + } + + // Check if .beads/ exists in project dir + if !ctx.project_dir.join(".beads").is_dir() { + return None; + } + + 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, + )?; + // Count JSON array items (simple: count opening braces at indent level 1) + let count = out.matches("\"id\"").count(); + let summary = format!("{count}"); + ctx.cache.set("beads_summary", &summary); + Some(summary) + })?; + + 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/cache_efficiency.rs b/src/section/cache_efficiency.rs new file mode 100644 index 0000000..1af3204 --- /dev/null +++ b/src/section/cache_efficiency.rs @@ -0,0 +1,29 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.cache_efficiency.enabled { + return None; + } + + let pct = ctx.metrics.cache_efficiency_pct?; + let pct_int = pct.round() as u64; + + let raw = format!("Cache: {pct_int}%"); + + let color_code = if pct >= 80.0 { + color::GREEN + } else if pct >= 50.0 { + color::YELLOW + } else { + color::RED + }; + + let ansi = if ctx.color_enabled { + format!("{color_code}{raw}{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/context_bar.rs b/src/section/context_bar.rs new file mode 100644 index 0000000..0acde7a --- /dev/null +++ b/src/section/context_bar.rs @@ -0,0 +1,45 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +/// Render context bar at a given bar_width. Called both at initial render +/// and during flex expansion (with wider bar_width). +pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOutput> { + let pct = ctx.input.context_window.as_ref()?.used_percentage?; + let pct_int = pct.round() as u16; + + let filled = (u32::from(pct_int) * u32::from(bar_width) / 100) as usize; + let empty = bar_width as usize - filled; + + let bar = "=".repeat(filled) + &"-".repeat(empty); + let raw = format!("[{bar}] {pct_int}%"); + + let thresh = &ctx.config.sections.context_bar.thresholds; + let color_code = threshold_color(pct, thresh); + + let ansi = if ctx.color_enabled { + format!("{color_code}[{bar}] {pct_int}%{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.context_bar.base.enabled { + return None; + } + render_at_width(ctx, ctx.config.sections.context_bar.bar_width) +} + +fn threshold_color(pct: f64, thresh: &crate::config::Thresholds) -> String { + if pct >= thresh.critical { + format!("{}{}", color::RED, color::BOLD) + } else if pct >= thresh.danger { + color::RED.to_string() + } else if pct >= thresh.warn { + color::YELLOW.to_string() + } else { + color::GREEN.to_string() + } +} diff --git a/src/section/context_remaining.rs b/src/section/context_remaining.rs new file mode 100644 index 0000000..bec5277 --- /dev/null +++ b/src/section/context_remaining.rs @@ -0,0 +1,43 @@ +use crate::color; +use crate::format; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.context_remaining.base.enabled { + return None; + } + + let cw = ctx.input.context_window.as_ref()?; + let pct = cw.used_percentage.unwrap_or(0.0); + let capacity = cw.context_window_size.unwrap_or(200_000); + let used_tokens = (pct / 100.0 * capacity as f64) as u64; + let remaining = capacity.saturating_sub(used_tokens); + + let remaining_str = format::human_tokens(remaining); + let raw = ctx + .config + .sections + .context_remaining + .format + .replace("{remaining}", &remaining_str); + + let thresh = &ctx.config.sections.context_remaining.thresholds; + // Invert thresholds: high usage = low remaining = more danger + let color_code = if pct >= thresh.critical { + format!("{}{}", color::RED, color::BOLD) + } else if pct >= thresh.danger { + color::RED.to_string() + } else if pct >= thresh.warn { + color::YELLOW.to_string() + } else { + color::GREEN.to_string() + }; + + let ansi = if ctx.color_enabled { + format!("{color_code}{raw}{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/context_trend.rs b/src/section/context_trend.rs new file mode 100644 index 0000000..5207003 --- /dev/null +++ b/src/section/context_trend.rs @@ -0,0 +1,47 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use crate::trend; +use std::time::Duration; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.context_trend.base.enabled { + return None; + } + + let pct = ctx.input.context_window.as_ref()?.used_percentage?; + let pct_int = pct.round() as i64; + + let width = ctx.config.sections.context_trend.width as usize; + let csv = trend::append( + ctx.cache, + "context", + pct_int, + width, + Duration::from_secs(30), + )?; + let spark = trend::sparkline(&csv, width); + + if spark.is_empty() { + return None; + } + + let thresh = &ctx.config.sections.context_trend.thresholds; + let color_code = if pct >= thresh.critical { + format!("{}{}", color::RED, color::BOLD) + } else if pct >= thresh.danger { + color::RED.to_string() + } else if pct >= thresh.warn { + color::YELLOW.to_string() + } else { + color::GREEN.to_string() + }; + + let raw = spark.clone(); + let ansi = if ctx.color_enabled { + format!("{color_code}{spark}{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/context_usage.rs b/src/section/context_usage.rs new file mode 100644 index 0000000..202ebd5 --- /dev/null +++ b/src/section/context_usage.rs @@ -0,0 +1,49 @@ +use crate::color; +use crate::format; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.context_usage.base.enabled { + return None; + } + + let cw = ctx.input.context_window.as_ref()?; + let pct = cw.used_percentage?; + let pct_int = pct.round() as u64; + + let total_input = cw.total_input_tokens.unwrap_or(0); + let total_output = cw.total_output_tokens.unwrap_or(0); + let used = total_input + total_output; + let capacity = cw + .context_window_size + .unwrap_or(ctx.config.sections.context_usage.capacity); + + let raw = format!( + "{}/{} ({pct_int}%)", + format::human_tokens(used), + format::human_tokens(capacity), + ); + + let thresh = &ctx.config.sections.context_usage.thresholds; + let color_code = threshold_color(pct, thresh); + + let ansi = if ctx.color_enabled { + format!("{color_code}{raw}{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} + +fn threshold_color(pct: f64, thresh: &crate::config::Thresholds) -> String { + if pct >= thresh.critical { + format!("{}{}", color::RED, color::BOLD) + } else if pct >= thresh.danger { + color::RED.to_string() + } else if pct >= thresh.warn { + color::YELLOW.to_string() + } else { + color::GREEN.to_string() + } +} diff --git a/src/section/cost.rs b/src/section/cost.rs new file mode 100644 index 0000000..347ea48 --- /dev/null +++ b/src/section/cost.rs @@ -0,0 +1,39 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use crate::width::WidthTier; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.cost.base.enabled { + return None; + } + + let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?; + + let decimals = match ctx.width_tier { + WidthTier::Narrow => 0, + WidthTier::Medium => 2, + WidthTier::Wide => 4, + }; + + let cost_str = format!("{cost_val:.decimals$}"); + let raw = format!("${cost_str}"); + + let thresh = &ctx.config.sections.cost.thresholds; + let color_code = if cost_val >= thresh.critical { + format!("{}{}", color::RED, color::BOLD) + } else if cost_val >= thresh.danger { + color::RED.to_string() + } else if cost_val >= thresh.warn { + color::YELLOW.to_string() + } else { + color::GREEN.to_string() + }; + + let ansi = if ctx.color_enabled { + format!("{color_code}${cost_str}{}", color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/cost_trend.rs b/src/section/cost_trend.rs new file mode 100644 index 0000000..ced2389 --- /dev/null +++ b/src/section/cost_trend.rs @@ -0,0 +1,36 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use crate::trend; +use std::time::Duration; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.cost_trend.base.enabled { + return None; + } + + let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?; + let cost_cents = (cost_val * 100.0) as i64; + + let width = ctx.config.sections.cost_trend.width as usize; + let csv = trend::append( + ctx.cache, + "cost", + cost_cents, + width, + Duration::from_secs(30), + )?; + let spark = trend::sparkline(&csv, width); + + if spark.is_empty() { + return None; + } + + let raw = format!("${spark}"); + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/cost_velocity.rs b/src/section/cost_velocity.rs new file mode 100644 index 0000000..7534372 --- /dev/null +++ b/src/section/cost_velocity.rs @@ -0,0 +1,19 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.cost_velocity.enabled { + return None; + } + + let velocity = ctx.metrics.cost_velocity?; + let raw = format!("${velocity:.2}/min"); + + 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 new file mode 100644 index 0000000..0d540e7 --- /dev/null +++ b/src/section/custom.rs @@ -0,0 +1,66 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use crate::shell; +use std::time::Duration; + +/// Render a custom command section by ID. +pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> { + 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}"); + + let cached = ctx.cache.get(&cache_key, ttl); + let output_str = cached.or_else(|| { + let result = if let Some(ref exec) = cmd_cfg.exec { + if exec.is_empty() { + return None; + } + let args: Vec<&str> = exec[1..].iter().map(|s| s.as_str()).collect(); + shell::exec_with_timeout(&exec[0], &args, None, timeout) + } else if let Some(ref command) = cmd_cfg.command { + shell::exec_with_timeout("sh", &["-c", command], None, timeout) + } else { + None + }; + if let Some(ref val) = result { + ctx.cache.set(&cache_key, val); + } + result + })?; + + 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() + } 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) { + 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 { + let c = color::resolve_color(default_c, ctx.theme, &ctx.config.colors); + format!("{c}{raw}{}", color::RESET) + } else { + raw.clone() + } + } else if let Some(ref default_c) = cmd_cfg.default_color { + let c = color::resolve_color(default_c, ctx.theme, &ctx.config.colors); + format!("{c}{raw}{}", color::RESET) + } else { + raw.clone() + } + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/duration.rs b/src/section/duration.rs new file mode 100644 index 0000000..4421809 --- /dev/null +++ b/src/section/duration.rs @@ -0,0 +1,23 @@ +use crate::color; +use crate::format; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.duration.enabled { + return None; + } + + let ms = ctx.input.cost.as_ref()?.total_duration_ms?; + if ms == 0 { + return None; + } + + let raw = format::human_duration(ms); + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/hostname.rs b/src/section/hostname.rs new file mode 100644 index 0000000..fbd4325 --- /dev/null +++ b/src/section/hostname.rs @@ -0,0 +1,34 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.hostname.enabled { + return None; + } + + let name = hostname()?; + let raw = name.clone(); + let ansi = if ctx.color_enabled { + format!("{}{name}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} + +fn hostname() -> Option<String> { + // Try gethostname via libc + let mut buf = [0u8; 256]; + let ret = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + if ret == 0 { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let name = String::from_utf8_lossy(&buf[..end]).to_string(); + if !name.is_empty() { + return Some(name); + } + } + + // Fallback: HOSTNAME env var + std::env::var("HOSTNAME").ok() +} diff --git a/src/section/lines_changed.rs b/src/section/lines_changed.rs new file mode 100644 index 0000000..f2c5740 --- /dev/null +++ b/src/section/lines_changed.rs @@ -0,0 +1,32 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.lines_changed.enabled { + return None; + } + + let cost = ctx.input.cost.as_ref()?; + let added = cost.total_lines_added.unwrap_or(0); + let removed = cost.total_lines_removed.unwrap_or(0); + + if added == 0 && removed == 0 { + return None; + } + + let raw = format!("+{added}/-{removed}"); + + let ansi = if ctx.color_enabled { + format!( + "{}+{added}{}{}/-{removed}{}", + color::GREEN, + color::RESET, + color::RED, + color::RESET, + ) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/load.rs b/src/section/load.rs new file mode 100644 index 0000000..cb6c970 --- /dev/null +++ b/src/section/load.rs @@ -0,0 +1,53 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; +use std::time::Duration; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.load.base.enabled { + return None; + } + + let ttl = Duration::from_secs(ctx.config.sections.load.ttl); + let cached = ctx.cache.get("load_avg", ttl); + + let load_str = cached.or_else(|| { + // Read load average from /proc/loadavg (Linux) or sysctl (macOS) + #[cfg(target_os = "linux")] + { + let content = std::fs::read_to_string("/proc/loadavg").ok()?; + let load1 = content.split_whitespace().next()?; + ctx.cache.set("load_avg", load1); + Some(load1.to_string()) + } + #[cfg(target_os = "macos")] + { + let out = crate::shell::exec_with_timeout( + "sysctl", + &["-n", "vm.loadavg"], + None, + Duration::from_millis(100), + )?; + // sysctl output: "{ 1.23 4.56 7.89 }" + let load1 = out + .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.') + .split_whitespace() + .next()? + .to_string(); + ctx.cache.set("load_avg", &load1); + Some(load1) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + None + } + })?; + + let raw = format!("load {load_str}"); + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/mod.rs b/src/section/mod.rs new file mode 100644 index 0000000..6cd92fd --- /dev/null +++ b/src/section/mod.rs @@ -0,0 +1,118 @@ +use crate::cache::Cache; +use crate::config::Config; +use crate::input::InputData; +use crate::metrics::ComputedMetrics; +use crate::theme::Theme; +use crate::width::WidthTier; + +use std::path::Path; + +pub mod beads; +pub mod cache_efficiency; +pub mod context_bar; +pub mod context_remaining; +pub mod context_trend; +pub mod context_usage; +pub mod cost; +pub mod cost_trend; +pub mod cost_velocity; +pub mod custom; +pub mod duration; +pub mod hostname; +pub mod lines_changed; +pub mod load; +pub mod model; +pub mod output_style; +pub mod project; +pub mod provider; +pub mod time; +pub mod token_velocity; +pub mod tokens_raw; +pub mod tools; +pub mod turns; +pub mod vcs; +pub mod version; + +/// What every section renderer returns. +#[derive(Debug, Clone)] +pub struct SectionOutput { + pub raw: String, + pub ansi: String, +} + +/// Type alias for section render functions. +pub type RenderFn = fn(&RenderContext) -> Option<SectionOutput>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VcsType { + Git, + Jj, + None, +} + +/// Context passed to every section renderer. +pub struct RenderContext<'a> { + pub input: &'a InputData, + pub config: &'a Config, + pub theme: Theme, + pub width_tier: WidthTier, + pub term_width: u16, + pub vcs_type: VcsType, + pub project_dir: &'a Path, + pub cache: &'a Cache, + pub glyphs_enabled: bool, + pub color_enabled: bool, + pub metrics: ComputedMetrics, +} + +/// Build the registry of all built-in sections. +pub fn registry() -> Vec<(&'static str, RenderFn)> { + 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), + ] +} + +/// Dispatch: look up section by ID and render it. +pub fn render_section(id: &str, ctx: &RenderContext) -> Option<SectionOutput> { + if is_spacer(id) { + return Some(SectionOutput { + raw: " ".into(), + ansi: " ".into(), + }); + } + + for (name, render_fn) in registry() { + if name == id { + return render_fn(ctx); + } + } + + custom::render(id, ctx) +} + +pub fn is_spacer(id: &str) -> bool { + id == "spacer" || id.starts_with("_spacer") +} diff --git a/src/section/model.rs b/src/section/model.rs new file mode 100644 index 0000000..08e95af --- /dev/null +++ b/src/section/model.rs @@ -0,0 +1,68 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.model.enabled { + return None; + } + + let model = ctx.input.model.as_ref()?; + let id = model.id.as_deref().unwrap_or("?"); + let id_lower = id.to_ascii_lowercase(); + + let base_name = if id_lower.contains("opus") { + "Opus" + } else if id_lower.contains("sonnet") { + "Sonnet" + } else if id_lower.contains("haiku") { + "Haiku" + } else { + return Some(simple_output( + model.display_name.as_deref().unwrap_or(id), + ctx, + )); + }; + + let version = extract_version(&id_lower, &base_name.to_ascii_lowercase()); + + let name = match version { + Some(v) => format!("{base_name} {v}"), + None => base_name.to_string(), + }; + + let raw = format!("[{name}]"); + let ansi = if ctx.color_enabled { + format!("{}[{name}]{}", color::BOLD, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} + +fn extract_version(id: &str, family: &str) -> Option<String> { + let parts: Vec<&str> = id.split('-').collect(); + for window in parts.windows(3) { + if window[0] == family { + if let (Ok(a), Ok(b)) = (window[1].parse::<u8>(), window[2].parse::<u8>()) { + return Some(format!("{a}.{b}")); + } + } + if window[2] == family { + if let (Ok(a), Ok(b)) = (window[0].parse::<u8>(), window[1].parse::<u8>()) { + return Some(format!("{a}.{b}")); + } + } + } + None +} + +fn simple_output(name: &str, ctx: &RenderContext) -> SectionOutput { + let raw = format!("[{name}]"); + let ansi = if ctx.color_enabled { + format!("{}[{name}]{}", color::BOLD, color::RESET) + } else { + raw.clone() + }; + SectionOutput { raw, ansi } +} diff --git a/src/section/output_style.rs b/src/section/output_style.rs new file mode 100644 index 0000000..9f8337c --- /dev/null +++ b/src/section/output_style.rs @@ -0,0 +1,19 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.output_style.enabled { + return None; + } + + let style_name = ctx.input.output_style.as_ref()?.name.as_deref()?; + let raw = style_name.to_string(); + + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/project.rs b/src/section/project.rs new file mode 100644 index 0000000..cf17905 --- /dev/null +++ b/src/section/project.rs @@ -0,0 +1,36 @@ +use crate::color; +use crate::format; +use crate::glyph; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.project.base.enabled { + return None; + } + + let dir = ctx.input.workspace.as_ref()?.project_dir.as_deref()?; + + let name = std::path::Path::new(dir).file_name()?.to_str()?; + + let truncated = if ctx.config.sections.project.truncate.enabled + && ctx.config.sections.project.truncate.max > 0 + { + format::truncate( + name, + ctx.config.sections.project.truncate.max, + &ctx.config.sections.project.truncate.style, + ) + } else { + name.to_string() + }; + + let folder_glyph = glyph::glyph("folder", &ctx.config.glyphs); + let raw = format!("{folder_glyph}{truncated}"); + let ansi = if ctx.color_enabled { + format!("{}{folder_glyph}{truncated}{}", color::CYAN, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/provider.rs b/src/section/provider.rs new file mode 100644 index 0000000..94dd06b --- /dev/null +++ b/src/section/provider.rs @@ -0,0 +1,35 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.provider.enabled { + return None; + } + + let model = ctx.input.model.as_ref()?; + let id = model.id.as_deref().unwrap_or(""); + let id_lower = id.to_ascii_lowercase(); + + let provider = if id_lower.contains("claude") + || id_lower.contains("opus") + || id_lower.contains("sonnet") + || id_lower.contains("haiku") + { + "Anthropic" + } else if id_lower.contains("gpt") || id_lower.contains("o1") || id_lower.contains("o3") { + "OpenAI" + } else if id_lower.contains("gemini") { + "Google" + } else { + return None; + }; + + let raw = provider.to_string(); + let ansi = if ctx.color_enabled { + format!("{}{provider}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/time.rs b/src/section/time.rs new file mode 100644 index 0000000..96b8066 --- /dev/null +++ b/src/section/time.rs @@ -0,0 +1,37 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.time.base.enabled { + return None; + } + + // Simple HH:MM format without chrono dependency + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + let secs = now.as_secs(); + + // Get local time offset using libc + let (hour, minute) = local_time(secs)?; + + let raw = format!("{hour:02}:{minute:02}"); + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} + +fn local_time(epoch_secs: u64) -> Option<(u32, u32)> { + let time_t = epoch_secs as libc::time_t; + let mut tm = std::mem::MaybeUninit::<libc::tm>::uninit(); + let result = unsafe { libc::localtime_r(&time_t, tm.as_mut_ptr()) }; + if result.is_null() { + return None; + } + let tm = unsafe { tm.assume_init() }; + Some((tm.tm_hour as u32, tm.tm_min as u32)) +} diff --git a/src/section/token_velocity.rs b/src/section/token_velocity.rs new file mode 100644 index 0000000..6dc46ec --- /dev/null +++ b/src/section/token_velocity.rs @@ -0,0 +1,20 @@ +use crate::color; +use crate::format; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.token_velocity.enabled { + return None; + } + + let velocity = ctx.metrics.token_velocity?; + let raw = format!("{} tok/min", format::human_tokens(velocity as u64)); + + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/tokens_raw.rs b/src/section/tokens_raw.rs new file mode 100644 index 0000000..566ca4e --- /dev/null +++ b/src/section/tokens_raw.rs @@ -0,0 +1,33 @@ +use crate::color; +use crate::format; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.tokens_raw.base.enabled { + return None; + } + + let cw = ctx.input.context_window.as_ref()?; + let input_tok = cw.total_input_tokens.unwrap_or(0); + let output_tok = cw.total_output_tokens.unwrap_or(0); + + if input_tok == 0 && output_tok == 0 { + return None; + } + + let raw = ctx + .config + .sections + .tokens_raw + .format + .replace("{input}", &format::human_tokens(input_tok)) + .replace("{output}", &format::human_tokens(output_tok)); + + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/tools.rs b/src/section/tools.rs new file mode 100644 index 0000000..2cf2d90 --- /dev/null +++ b/src/section/tools.rs @@ -0,0 +1,33 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.tools.base.enabled { + return None; + } + + let cost = ctx.input.cost.as_ref()?; + let count = cost.total_tool_uses.unwrap_or(0); + + if count == 0 { + return None; + } + + let last = if ctx.config.sections.tools.show_last_name { + cost.last_tool_name + .as_deref() + .map(|n| format!(" ({n})")) + .unwrap_or_default() + } else { + String::new() + }; + + let raw = format!("{count} tools{last}"); + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/turns.rs b/src/section/turns.rs new file mode 100644 index 0000000..43c1c2e --- /dev/null +++ b/src/section/turns.rs @@ -0,0 +1,24 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.turns.base.enabled { + return None; + } + + let count = ctx.input.cost.as_ref()?.total_turns?; + if count == 0 { + return None; + } + + let label = if count == 1 { "turn" } else { "turns" }; + let raw = format!("{count} {label}"); + + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/vcs.rs b/src/section/vcs.rs new file mode 100644 index 0000000..4c43993 --- /dev/null +++ b/src/section/vcs.rs @@ -0,0 +1,166 @@ +use crate::color; +use crate::glyph; +use crate::section::{RenderContext, SectionOutput, VcsType}; +use crate::shell::{self, GitStatusV2}; +use crate::width::WidthTier; +use std::time::Duration; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.vcs.base.enabled { + return None; + } + if ctx.vcs_type == VcsType::None { + return None; + } + + let dir = ctx.project_dir.to_str()?; + let ttl = &ctx.config.sections.vcs.ttl; + let glyphs = &ctx.config.glyphs; + + match ctx.vcs_type { + VcsType::Git => render_git(ctx, dir, ttl, glyphs), + VcsType::Jj => render_jj(ctx, dir, ttl, glyphs), + VcsType::None => None, + } +} + +fn render_git( + ctx: &RenderContext, + dir: &str, + ttl: &crate::config::VcsTtl, + glyphs: &crate::config::GlyphConfig, +) -> Option<SectionOutput> { + 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, + ); + match output { + Some(ref out) => { + let s = shell::parse_git_status_v2(out); + if let Some(ref b) = s.branch { + ctx.cache.set("vcs_branch", b); + } + ctx.cache + .set("vcs_dirty", if s.is_dirty { "1" } else { "" }); + ctx.cache + .set("vcs_ab", &format!("{} {}", s.ahead, s.behind)); + s + } + None => GitStatusV2 { + branch: branch_cached.or_else(|| ctx.cache.get_stale("vcs_branch")), + is_dirty: dirty_cached + .or_else(|| ctx.cache.get_stale("vcs_dirty")) + .is_some_and(|v| !v.is_empty()), + ahead: 0, + behind: 0, + }, + } + } else { + GitStatusV2 { + branch: branch_cached, + is_dirty: dirty_cached.is_some_and(|v| !v.is_empty()), + ahead: ab_cached + .as_ref() + .and_then(|s| s.split_whitespace().next()?.parse().ok()) + .unwrap_or(0), + behind: ab_cached + .as_ref() + .and_then(|s| s.split_whitespace().nth(1)?.parse().ok()) + .unwrap_or(0), + } + }; + + let branch = status.branch.as_deref().unwrap_or("?"); + let branch_glyph = glyph::glyph("branch", glyphs); + let dirty_glyph = if status.is_dirty && ctx.config.sections.vcs.show_dirty { + glyph::glyph("dirty", glyphs) + } else { + "" + }; + + let mut raw = format!("{branch_glyph}{branch}{dirty_glyph}"); + let mut ansi = if ctx.color_enabled { + let mut s = format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET); + if !dirty_glyph.is_empty() { + s.push_str(&format!("{}{dirty_glyph}{}", color::YELLOW, color::RESET)); + } + s + } else { + raw.clone() + }; + + // Ahead/behind: medium+ width only + if ctx.config.sections.vcs.show_ahead_behind + && ctx.width_tier != WidthTier::Narrow + && (status.ahead > 0 || status.behind > 0) + { + let ahead_g = glyph::glyph("ahead", glyphs); + let behind_g = glyph::glyph("behind", glyphs); + let mut ab = String::new(); + if status.ahead > 0 { + ab.push_str(&format!("{ahead_g}{}", status.ahead)); + } + if status.behind > 0 { + ab.push_str(&format!("{behind_g}{}", status.behind)); + } + raw.push_str(&format!(" {ab}")); + if ctx.color_enabled { + ansi.push_str(&format!(" {}{ab}{}", color::DIM, color::RESET)); + } else { + ansi = raw.clone(); + } + } + + Some(SectionOutput { raw, ansi }) +} + +fn render_jj( + ctx: &RenderContext, + _dir: &str, + ttl: &crate::config::VcsTtl, + glyphs: &crate::config::GlyphConfig, +) -> Option<SectionOutput> { + let branch_ttl = Duration::from_secs(ttl.branch); + let timeout = Duration::from_millis(200); + + let branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| { + let out = shell::exec_with_timeout( + "jj", + &[ + "log", + "-r", + "@", + "--no-graph", + "-T", + "if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))", + "--color=never", + ], + None, + timeout, + )?; + ctx.cache.set("vcs_branch", &out); + Some(out) + })?; + + let branch_glyph = glyph::glyph("branch", glyphs); + let raw = format!("{branch_glyph}{branch}"); + let ansi = if ctx.color_enabled { + format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/section/version.rs b/src/section/version.rs new file mode 100644 index 0000000..61cfaa3 --- /dev/null +++ b/src/section/version.rs @@ -0,0 +1,19 @@ +use crate::color; +use crate::section::{RenderContext, SectionOutput}; + +pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { + if !ctx.config.sections.version.enabled { + return None; + } + + let ver = ctx.input.version.as_deref()?; + let raw = format!("v{ver}"); + + let ansi = if ctx.color_enabled { + format!("{}{raw}{}", color::DIM, color::RESET) + } else { + raw.clone() + }; + + Some(SectionOutput { raw, ansi }) +} diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..81eb76f --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,89 @@ +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Stable env for all git commands. +const GIT_ENV: &[(&str, &str)] = &[ + ("GIT_OPTIONAL_LOCKS", "0"), + ("GIT_TERMINAL_PROMPT", "0"), + ("LC_ALL", "C"), +]; + +/// Execute a command with a polling timeout. Returns None on timeout or error. +pub fn exec_with_timeout( + program: &str, + args: &[&str], + dir: Option<&str>, + timeout: Duration, +) -> Option<String> { + let mut cmd = Command::new(program); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::null()); + + if let Some(d) = dir { + cmd.current_dir(d); + } + + if program == "git" { + for (k, v) in GIT_ENV { + cmd.env(k, v); + } + } + + let mut child = cmd.spawn().ok()?; + let start = Instant::now(); + + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let output = child.wait_with_output().ok()?; + return Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + Ok(None) => { + if start.elapsed() >= timeout { + let _ = child.kill(); + return None; + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => return None, + } + } +} + +/// Parsed result from `git status --porcelain=v2 --branch`. +#[derive(Debug, Default)] +pub struct GitStatusV2 { + pub branch: Option<String>, + pub ahead: u32, + pub behind: u32, + pub is_dirty: bool, +} + +/// Parse combined `git status --porcelain=v2 --branch` output. +pub fn parse_git_status_v2(output: &str) -> GitStatusV2 { + let mut result = GitStatusV2::default(); + + for line in output.lines() { + if let Some(rest) = line.strip_prefix("# branch.head ") { + result.branch = Some(rest.to_string()); + } else if let Some(rest) = line.strip_prefix("# branch.ab ") { + for part in rest.split_whitespace() { + if let Some(n) = part.strip_prefix('+') { + result.ahead = n.parse().unwrap_or(0); + } else if let Some(n) = part.strip_prefix('-') { + result.behind = n.parse().unwrap_or(0); + } + } + } else if line.starts_with('1') + || line.starts_with('2') + || line.starts_with('?') + || line.starts_with('u') + { + result.is_dirty = true; + } + } + + result +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..a109e6f --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,42 @@ +use crate::config::Config; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Theme { + Dark, + Light, +} + +impl Theme { + pub fn as_str(&self) -> &'static str { + match self { + Self::Dark => "dark", + Self::Light => "light", + } + } +} + +/// Detection priority: +/// 1. Config override (global.theme = "dark" or "light") +/// 2. COLORFGBG env var: parse bg from "fg;bg", bg 9-15 = light, 0-8 = dark +/// 3. Default: dark +pub fn detect_theme(config: &Config) -> Theme { + match config.global.theme.as_str() { + "dark" => return Theme::Dark, + "light" => return Theme::Light, + _ => {} + } + + if let Ok(val) = std::env::var("COLORFGBG") { + if let Some(bg_str) = val.rsplit(';').next() { + if let Ok(bg) = bg_str.parse::<u8>() { + return if bg > 8 && bg < 16 { + Theme::Light + } else { + Theme::Dark + }; + } + } + } + + Theme::Dark +} diff --git a/src/trend.rs b/src/trend.rs new file mode 100644 index 0000000..7d138b6 --- /dev/null +++ b/src/trend.rs @@ -0,0 +1,87 @@ +use crate::cache::Cache; +use std::time::Duration; + +const SPARKLINE_CHARS: &[char] = &[ + '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}', +]; + +/// Append a value to a trend file. Throttled to at most once per `interval`. +/// Returns the full comma-separated series (for immediate sparkline rendering). +pub fn append( + cache: &Cache, + key: &str, + value: i64, + max_points: usize, + interval: Duration, +) -> Option<String> { + let trend_key = format!("trend_{key}"); + + // Check throttle: skip if last write was within interval + if let Some(existing) = cache.get(&trend_key, interval) { + return Some(existing); + } + + // Read current series (ignoring TTL) + let mut series: Vec<i64> = cache + .get_stale(&trend_key) + .unwrap_or_default() + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + // Skip if value unchanged from last point + if series.last() == Some(&value) { + // Still update the file mtime so throttle window resets + let csv = series + .iter() + .map(|v| v.to_string()) + .collect::<Vec<_>>() + .join(","); + cache.set(&trend_key, &csv); + return Some(csv); + } + + series.push(value); + + // Trim from left to max_points + if series.len() > max_points { + series = series[series.len() - max_points..].to_vec(); + } + + let csv = series + .iter() + .map(|v| v.to_string()) + .collect::<Vec<_>>() + .join(","); + cache.set(&trend_key, &csv); + Some(csv) +} + +/// Render a sparkline from comma-separated values. +pub fn sparkline(csv: &str, width: usize) -> String { + let vals: Vec<i64> = csv + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + if vals.is_empty() { + return String::new(); + } + + let min = *vals.iter().min().unwrap(); + let max = *vals.iter().max().unwrap(); + let count = vals.len().min(width); + + if max == min { + return "\u{2584}".repeat(count); + } + + let range = (max - min) as f64; + vals.iter() + .take(width) + .map(|&v| { + let idx = (((v - min) as f64 / range) * 7.0) as usize; + SPARKLINE_CHARS[idx.min(7)] + }) + .collect() +} diff --git a/src/width.rs b/src/width.rs new file mode 100644 index 0000000..f192e23 --- /dev/null +++ b/src/width.rs @@ -0,0 +1,199 @@ +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +static CACHED_WIDTH: Mutex<Option<(u16, Instant)>> = Mutex::new(None); +const WIDTH_TTL: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WidthTier { + Narrow, + Medium, + Wide, +} + +pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier { + if width < narrow_bp { + WidthTier::Narrow + } else if width < medium_bp { + WidthTier::Medium + } else { + WidthTier::Wide + } +} + +/// Detect terminal width. Memoized for 1 second across renders. +/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120 +pub fn detect_width(cli_width: Option<u16>, config_width: Option<u16>, config_margin: u16) -> u16 { + // Check memo first + if let Ok(guard) = CACHED_WIDTH.lock() { + if let Some((w, ts)) = *guard { + if ts.elapsed() < WIDTH_TTL { + return w; + } + } + } + + let raw = detect_raw(cli_width, config_width); + let effective = raw.saturating_sub(config_margin).max(40); + + // Store in memo + if let Ok(mut guard) = CACHED_WIDTH.lock() { + *guard = Some((effective, Instant::now())); + } + + effective +} + +fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 { + // 1. --width CLI flag + if let Some(w) = cli_width { + if w > 0 { + return w; + } + } + + // 2. CLAUDE_STATUSLINE_WIDTH env var + if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") { + if let Ok(w) = val.parse::<u16>() { + if w > 0 { + return w; + } + } + } + + // 3. Config override + if let Some(w) = config_width { + if w > 0 { + return w; + } + } + + // 4. ioctl(TIOCGWINSZ) on stdout + if let Some(w) = ioctl_width(libc::STDOUT_FILENO) { + if w > 0 { + return w; + } + } + + // 5. Process tree walk: find ancestor with real TTY + if let Some(w) = process_tree_width() { + if w > 0 { + return w; + } + } + + // 6. stty size < /dev/tty + if let Some(w) = stty_dev_tty() { + if w > 0 { + return w; + } + } + + // 7. COLUMNS env var + if let Ok(val) = std::env::var("COLUMNS") { + if let Ok(w) = val.parse::<u16>() { + if w > 0 { + return w; + } + } + } + + // 8. tput cols + if let Some(w) = tput_cols() { + if w > 0 { + return w; + } + } + + // 9. Fallback + 120 +} + +fn ioctl_width(fd: i32) -> Option<u16> { + #[repr(C)] + struct Winsize { + ws_row: u16, + ws_col: u16, + ws_xpixel: u16, + ws_ypixel: u16, + } + + let mut ws = Winsize { + ws_row: 0, + ws_col: 0, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + // TIOCGWINSZ value differs by platform + #[cfg(target_os = "macos")] + const TIOCGWINSZ: libc::c_ulong = 0x4008_7468; + #[cfg(target_os = "linux")] + const TIOCGWINSZ: libc::c_ulong = 0x5413; + + let ret = unsafe { libc::ioctl(fd, TIOCGWINSZ, &mut ws) }; + if ret == 0 && ws.ws_col > 0 { + Some(ws.ws_col) + } else { + None + } +} + +/// Walk process tree from current PID, find ancestor with a real TTY, +/// then query its width via stty. +fn process_tree_width() -> Option<u16> { + let mut pid = std::process::id(); + + while pid > 1 { + let output = std::process::Command::new("ps") + .args(["-o", "tty=", "-p", &pid.to_string()]) + .output() + .ok()?; + + let tty = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !tty.is_empty() && tty != "??" && tty != "-" { + let dev_path = format!("/dev/{tty}"); + if let Ok(out) = std::process::Command::new("stty") + .arg("size") + .stdin(std::fs::File::open(&dev_path).ok()?) + .output() + { + let s = String::from_utf8_lossy(&out.stdout); + if let Some(cols_str) = s.split_whitespace().nth(1) { + if let Ok(w) = cols_str.parse::<u16>() { + return Some(w); + } + } + } + } + + // Walk to parent + let ppid_out = std::process::Command::new("ps") + .args(["-o", "ppid=", "-p", &pid.to_string()]) + .output() + .ok()?; + let ppid_str = String::from_utf8_lossy(&ppid_out.stdout).trim().to_string(); + pid = ppid_str.parse().ok()?; + } + + None +} + +fn stty_dev_tty() -> Option<u16> { + let tty = std::fs::File::open("/dev/tty").ok()?; + let out = std::process::Command::new("stty") + .arg("size") + .stdin(tty) + .output() + .ok()?; + let s = String::from_utf8_lossy(&out.stdout); + s.split_whitespace().nth(1)?.parse().ok() +} + +fn tput_cols() -> Option<u16> { + let out = std::process::Command::new("tput") + .arg("cols") + .output() + .ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse().ok() +} diff --git a/statusline.sh b/statusline.sh new file mode 100755 index 0000000..9805f50 --- /dev/null +++ b/statusline.sh @@ -0,0 +1,2245 @@ +#!/usr/bin/env bash +# Simple shell-style status line for Claude Code +set -euo pipefail + +# Dependencies check +if ! command -v jq &>/dev/null; then + printf "statusline: jq not found\n" >&2 + exit 1 +fi + +# Read JSON from stdin +INPUT=$(cat) + +# ANSI Colors (dimmed for status line) +C_RESET='\033[0m' +C_DIM='\033[2m' +C_GREEN='\033[32m' +C_CYAN='\033[36m' +C_YELLOW='\033[33m' + +# Get current directory +CWD=$(echo "$INPUT" | jq -r '.workspace.current_dir // .workspace.project_dir // empty') +if [ -z "$CWD" ]; then + CWD=$(pwd) +fi + +# Shorten path: replace home with ~ +CWD_DISPLAY="${CWD/#$HOME/\~}" + +# Get username and hostname +USER=$(whoami) +HOST=$(hostname -s) + +# Detect VCS (prefer jj over git) +VCS_BRANCH="" +VCS_DIRTY="" +if [ -d "$CWD/.jj" ]; then + # Jujutsu + VCS_BRANCH=$(cd "$CWD" && jj log -r @ --no-graph -T 'if(bookmarks, bookmarks.join(","), change_id.shortest(8))' --color=never 2>/dev/null || echo "") + if [ -n "$VCS_BRANCH" ]; then + VCS_DIRTY=$(cd "$CWD" && jj diff --stat --color=never 2>/dev/null | tail -1 || echo "") + if [ -n "$VCS_DIRTY" ] && [[ "$VCS_DIRTY" != *"0 files changed"* ]]; then + VCS_DIRTY="*" + else + VCS_DIRTY="" + fi + fi +elif [ -d "$CWD/.git" ]; then + # Git + VCS_BRANCH=$(cd "$CWD" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + if [ -n "$VCS_BRANCH" ]; then + VCS_STATUS=$(cd "$CWD" && git status --porcelain --untracked-files=no 2>/dev/null || echo "") + if [ -n "$VCS_STATUS" ]; then + VCS_DIRTY="*" + else + VCS_DIRTY="" + fi + fi +fi + +# Build prompt +OUTPUT="" + +# user@host +OUTPUT+="${C_GREEN}${USER}@${HOST}${C_RESET}" + +# :path +OUTPUT+="${C_DIM}:${C_RESET}" +OUTPUT+="${C_CYAN}${CWD_DISPLAY}${C_RESET}" + +# (branch*) +if [ -n "$VCS_BRANCH" ]; then + OUTPUT+=" ${C_DIM}(${C_RESET}" + OUTPUT+="${C_GREEN}${VCS_BRANCH}${C_RESET}" + if [ -n "$VCS_DIRTY" ]; then + OUTPUT+="${C_YELLOW}${VCS_DIRTY}${C_RESET}" + fi + OUTPUT+="${C_DIM})${C_RESET}" +fi + +printf '%b' "$OUTPUT" + # Mock data for testing config without Claude Code + INPUT_JSON='{ + "model": {"id": "claude-opus-4-6-20260101", "display_name": "Opus 4.6"}, + "cost": { + "total_cost_usd": 0.42, + "total_duration_ms": 840000, + "total_lines_added": 156, + "total_lines_removed": 23, + "total_tool_uses": 7, + "last_tool_name": "Edit", + "total_turns": 12 + }, + "context_window": { + "used_percentage": 58.5, + "total_input_tokens": 115000, + "total_output_tokens": 8500, + "context_window_size": 200000, + "current_usage": { + "cache_read_input_tokens": 75000, + "cache_creation_input_tokens": 15000 + } + }, + "workspace": {"project_dir": "'"$(pwd)"'"}, + "version": "1.0.80", + "output_style": {"name": "learning"} + }' +elif [[ "$CLI_MODE" == "dump-state" ]]; then + # Dump-state mode uses minimal mock data to compute state + INPUT_JSON='{"workspace":{"project_dir":"'"$(pwd)"'"}}' +else + INPUT_JSON="$(cat)" + if [[ -z "$INPUT_JSON" ]]; then + exit 0 + fi +fi + +# ── ANSI Colors ─────────────────────────────────────────────────────────────── + +C_RESET='\033[0m' +C_BOLD='\033[1m' +C_DIM='\033[2m' +C_RED='\033[31m' +C_GREEN='\033[32m' +C_YELLOW='\033[33m' +C_BLUE='\033[34m' +C_MAGENTA='\033[35m' +C_CYAN='\033[36m' +C_WHITE='\033[37m' + +# ── Config Loading ──────────────────────────────────────────────────────────── + +# SCRIPT_DIR already resolved above +DEFAULTS_PATH="$SCRIPT_DIR/defaults.json" + +if [[ -f "$DEFAULTS_PATH" ]]; then + DEFAULTS_JSON="$(cat "$DEFAULTS_PATH")" +else + # Minimal fallback if defaults.json is missing + DEFAULTS_JSON='{"global":{},"presets":{"standard":[["model","project"]]},"layout":"standard","sections":{}}' +fi + +# User config contains only overrides — merged on top of defaults +USER_CONFIG_PATH="${CLAUDE_STATUSLINE_CONFIG:-$HOME/.claude/statusline.json}" +if [[ -f "$USER_CONFIG_PATH" ]]; then + USER_CONFIG_JSON="$(cat "$USER_CONFIG_PATH")" + # Deep merge: defaults * user (user wins) + CONFIG_JSON="$(jq -s '.[0] * .[1]' <<< "$DEFAULTS_JSON"$'\n'"$USER_CONFIG_JSON")" +else + CONFIG_JSON="$DEFAULTS_JSON" +fi + +# ── Config Preloading ───────────────────────────────────────────────────────── +# Extract all config values in a single jq call for performance. +# Each value is stored in a CFG_* variable. The cfg() function checks these +# preloaded values first, falling back to jq for non-preloaded paths. +# +# Variable naming: .global.separator -> CFG__global__separator +# (dots become double underscores to create valid bash variable names) + +# Preload all config values in one jq call +eval "$(jq -r ' + # Helper to output shell variable assignment + # Converts path like .global.separator to CFG__global__separator + def assign($name; $val): + "CFG_" + ($name | gsub("\\."; "__")) + "=" + ($val | @sh) + "\n"; + + # Global settings + assign(".global.separator"; .global.separator // " | ") + + assign(".global.justify"; .global.justify // "left") + + assign(".global.width"; (.global.width // "") | tostring) + + assign(".global.width_margin"; (.global.width_margin // 4) | tostring) + + assign(".global.responsive"; (.global.responsive // true) | tostring) + + assign(".global.theme"; .global.theme // "auto") + + assign(".global.vcs"; .global.vcs // "auto") + + assign(".global.cache_dir"; .global.cache_dir // "/tmp/claude-sl-{session_id}") + + assign(".global.breakpoints.narrow"; (.global.breakpoints.narrow // 60) | tostring) + + assign(".global.breakpoints.medium"; (.global.breakpoints.medium // 100) | tostring) + + + # Glyphs + assign(".glyphs.enabled"; (.glyphs.enabled // false) | tostring) + + + # Glyph set (Nerd Font icons) + (.glyphs.set // {} | to_entries | map( + assign(".glyphs.set." + .key; .value // "") + ) | add // "") + + + # Glyph fallbacks (ASCII) + (.glyphs.fallback // {} | to_entries | map( + assign(".glyphs.fallback." + .key; .value // "") + ) | add // "") + + + # Section enabled flags and priorities for all builtin sections + # Need to capture root context since we are inside a map + (. as $root | ["model", "provider", "project", "vcs", "beads", "context_bar", "context_usage", "tokens_raw", + "cache_efficiency", "cost", "cost_velocity", "token_velocity", "cost_trend", "context_trend", + "lines_changed", "duration", "tools", "turns", "load", "version", "time", + "output_style", "hostname"] | map(. as $section | + assign(".sections." + $section + ".enabled"; ($root.sections[$section].enabled // true) | tostring) + + assign(".sections." + $section + ".priority"; ($root.sections[$section].priority // 2) | tostring) + + assign(".sections." + $section + ".flex"; ($root.sections[$section].flex // false) | tostring) + ) | add // "") + + + # Section-specific settings + # VCS + assign(".sections.vcs.prefer"; .sections.vcs.prefer // "auto") + + assign(".sections.vcs.show_dirty"; (.sections.vcs.show_dirty // true) | tostring) + + assign(".sections.vcs.show_ahead_behind"; (.sections.vcs.show_ahead_behind // true) | tostring) + + assign(".sections.vcs.ttl.branch"; (.sections.vcs.ttl.branch // 3) | tostring) + + assign(".sections.vcs.ttl.dirty"; (.sections.vcs.ttl.dirty // 5) | tostring) + + assign(".sections.vcs.ttl.ahead_behind"; (.sections.vcs.ttl.ahead_behind // 30) | tostring) + + + # Beads + assign(".sections.beads.ttl"; (.sections.beads.ttl // 30) | tostring) + + assign(".sections.beads.show_wip"; (.sections.beads.show_wip // true) | tostring) + + assign(".sections.beads.show_wip_count"; (.sections.beads.show_wip_count // true) | tostring) + + assign(".sections.beads.show_ready_count"; (.sections.beads.show_ready_count // true) | tostring) + + assign(".sections.beads.show_open_count"; (.sections.beads.show_open_count // true) | tostring) + + assign(".sections.beads.show_closed_count"; (.sections.beads.show_closed_count // true) | tostring) + + + # Context bar + assign(".sections.context_bar.bar_width"; (.sections.context_bar.bar_width // 10) | tostring) + + assign(".sections.context_bar.thresholds.warn"; (.sections.context_bar.thresholds.warn // 50) | tostring) + + assign(".sections.context_bar.thresholds.danger"; (.sections.context_bar.thresholds.danger // 70) | tostring) + + assign(".sections.context_bar.thresholds.critical"; (.sections.context_bar.thresholds.critical // 85) | tostring) + + + # Tokens raw + assign(".sections.tokens_raw.format"; .sections.tokens_raw.format // "{input}in/{output}out") + + + # Cost + assign(".sections.cost.thresholds.warn"; (.sections.cost.thresholds.warn // 5.00) | tostring) + + assign(".sections.cost.thresholds.danger"; (.sections.cost.thresholds.danger // 8.00) | tostring) + + assign(".sections.cost.thresholds.critical"; (.sections.cost.thresholds.critical // 10.00) | tostring) + + + # Cost trend + assign(".sections.cost_trend.width"; (.sections.cost_trend.width // 8) | tostring) + + + # Context trend + assign(".sections.context_trend.width"; (.sections.context_trend.width // 8) | tostring) + + assign(".sections.context_trend.thresholds.warn"; (.sections.context_trend.thresholds.warn // 50) | tostring) + + assign(".sections.context_trend.thresholds.danger"; (.sections.context_trend.thresholds.danger // 70) | tostring) + + assign(".sections.context_trend.thresholds.critical"; (.sections.context_trend.thresholds.critical // 85) | tostring) + + + # Tools + assign(".sections.tools.show_last_name"; (.sections.tools.show_last_name // true) | tostring) + + + # Load + assign(".sections.load.ttl"; (.sections.load.ttl // 10) | tostring) + + + # Time + assign(".sections.time.format"; .sections.time.format // "%H:%M") + + + # Truncation settings for sections that support it + # Need to capture root context since we are inside a map + (. as $root | ["project", "vcs"] | map(. as $section | + assign(".sections." + $section + ".truncate.enabled"; ($root.sections[$section].truncate.enabled // false) | tostring) + + assign(".sections." + $section + ".truncate.max"; ($root.sections[$section].truncate.max // 0) | tostring) + + assign(".sections." + $section + ".truncate.style"; $root.sections[$section].truncate.style // "right") + ) | add // "") + + + # Themed color palettes (dark and light) + # Need to capture root context since we are inside nested maps + (. as $root | ["dark", "light"] | map(. as $theme | + ["success", "warning", "danger", "critical", "muted", "accent", "highlight", "info"] | map(. as $color | + assign(".colors." + $theme + "." + $color; $root.colors[$theme][$color] // "") + ) | add // "" + ) | add // "") + + + "" +' <<< "$CONFIG_JSON" 2>/dev/null)" || true + +# Helper: read config value with default (optimized with preloading) +# First checks preloaded CFG_* variables, falls back to jq for non-preloaded paths. +# Path conversion: .global.separator -> CFG__global__separator +cfg() { + local path="$1" default="${2:-}" + + # Convert path to variable name: .global.separator -> CFG__global__separator + local var_name="CFG_${path//./__}" + + # Check preloaded value first (fast path) + # Use eval for indirect variable access (bash 3.x compatible) + local val + eval "val=\"\${$var_name:-__CFG_UNSET__}\"" + if [[ "$val" != "__CFG_UNSET__" ]]; then + if [[ -z "$val" || "$val" == "null" ]]; then + printf '%s' "$default" + else + printf '%s' "$val" + fi + return + fi + + # Fallback to jq for non-preloaded paths (slow path) + val="$(jq -e "$path" <<< "$CONFIG_JSON" 2>/dev/null | jq -r 'tostring' 2>/dev/null)" || true + if [[ -z "$val" || "$val" == "null" ]]; then + printf '%s' "$default" + else + printf '%s' "$val" + fi +} + +# Helper: read from stdin JSON +inp() { + local path="$1" default="${2:-}" + local val + val="$(jq -r "$path // empty" <<< "$INPUT_JSON" 2>/dev/null)" || true + if [[ -z "$val" ]]; then + printf '%s' "$default" + else + printf '%s' "$val" + fi +} + +# Helper: check if section is enabled (uses preloaded values) +section_enabled() { + local section="$1" + local val + val="$(cfg ".sections.${section}.enabled" "true")" + [[ "$val" == "true" ]] +} + +# Helper: get section priority (uses preloaded values) +section_priority() { + local section="$1" + cfg ".sections.${section}.priority" "2" +} + +# ── Theme Detection ────────────────────────────────────────────────────────── +# Detects terminal background (light/dark) for color palette selection. +# Detection priority: +# 1. Config override (global.theme = "dark" or "light") +# 2. COLORFGBG env var (set by some terminals, format: "fg;bg") +# 3. Default to dark (most common for developers) +# +# Note: OSC 11 query is complex and unreliable without a TTY, so we use +# simpler heuristics. Users can always override via config. + +_detect_theme_impl() { + # 1. Config override + local theme_cfg + theme_cfg="$(cfg '.global.theme' 'auto')" + if [[ "$theme_cfg" != "auto" ]]; then + echo "$theme_cfg" + return + fi + + # 2. COLORFGBG env var (set by xterm, rxvt, some others) + # Format: "fg;bg" or "fg;bg;unused" — bg > 8 usually indicates light background + if [[ -n "${COLORFGBG:-}" ]]; then + local bg="${COLORFGBG##*;}" + # Standard ANSI colors 0-7 are dark, 8-15 are bright (light) + # Some terminals use 15 for white background + if [[ "$bg" =~ ^[0-9]+$ ]] && (( bg > 8 && bg < 16 )); then + echo "light" + return + fi + # bg values 0-8 or outside range -> dark + if [[ "$bg" =~ ^[0-9]+$ ]] && (( bg <= 8 )); then + echo "dark" + return + fi + fi + + # 3. Default to dark (most common for developers) + echo "dark" +} + +# Initialize detected theme +DETECTED_THEME="$(_detect_theme_impl)" + +# ── Color System ────────────────────────────────────────────────────────────── + +color_by_name() { + local name="$1" + + # Check for palette reference (p:colorname) + if [[ "$name" == p:* ]]; then + local palette_key="${name#p:}" + local resolved="" + + # Try themed palette first: .colors.{theme}.{key} + if [[ -n "$DETECTED_THEME" ]]; then + resolved="$(cfg ".colors.${DETECTED_THEME}.${palette_key}" "")" + fi + + # Fall back to flat palette: .colors.{key} (backwards compatibility) + if [[ -z "$resolved" ]]; then + resolved="$(cfg ".colors.${palette_key}" "")" + fi + + if [[ -z "$resolved" ]]; then + printf '%s' "$C_RESET" + return + fi + name="$resolved" + fi + + # Handle compound styles (e.g., "red bold") + local result="" + for part in $name; do + case "$part" in + red) result+="$C_RED" ;; + green) result+="$C_GREEN" ;; + yellow) result+="$C_YELLOW" ;; + blue) result+="$C_BLUE" ;; + magenta) result+="$C_MAGENTA" ;; + cyan) result+="$C_CYAN" ;; + white) result+="$C_WHITE" ;; + dim) result+="$C_DIM" ;; + bold) result+="$C_BOLD" ;; + *) ;; + esac + done + + if [[ -z "$result" ]]; then + printf '%s' "$C_RESET" + else + printf '%s' "$result" + fi +} + +# ── Glyph System ───────────────────────────────────────────────────────────── +# Nerd Font glyphs with automatic ASCII fallback. +# When glyphs.enabled is true, uses fancy Unicode/Powerline symbols. +# When disabled (default), uses plain ASCII characters. + +GLYPHS_ENABLED="$(cfg '.glyphs.enabled' 'false')" + +# glyph NAME — returns the appropriate glyph or fallback +glyph() { + local name="$1" + if [[ "$GLYPHS_ENABLED" == "true" ]]; then + local val + val="$(cfg ".glyphs.set.${name}" "")" + if [[ -n "$val" ]]; then + printf '%s' "$val" + return + fi + fi + # Fallback (either glyphs disabled or specific glyph not in set) + cfg ".glyphs.fallback.${name}" "" +} + +# ── Cache Infrastructure ────────────────────────────────────────────────────── + +# Session ID needs to be stable across invocations for trends to accumulate. +# Use project directory hash so same project = same session cache. +_project_dir="$(inp '.workspace.project_dir' "$(pwd)")" + +# Sanitize project directory: reject paths with shell metacharacters +# This prevents command injection via malicious workspace.project_dir +sanitize_path() { + local path="$1" + # Reject paths containing shell metacharacters that could enable injection + # Allow: alphanumeric, slash, dash, underscore, dot, space, tilde + if [[ "$path" =~ [^a-zA-Z0-9/_.\-\ ~] ]]; then + # Fall back to current directory if path contains suspicious chars + pwd + else + printf '%s' "$path" + fi +} +_project_dir="$(sanitize_path "$_project_dir")" + +SESSION_ID="$(printf '%s' "$_project_dir" | md5 -q 2>/dev/null || printf '%s' "$_project_dir" | md5sum | cut -d' ' -f1)" +SESSION_ID="${SESSION_ID:0:12}" # Truncate to 12 chars + +CACHE_DIR_TEMPLATE="$(cfg '.global.cache_dir' '/tmp/claude-sl-{session_id}')" +CACHE_DIR="${CACHE_DIR_TEMPLATE//\{session_id\}/$SESSION_ID}" + +# Secure cache directory creation: prevent symlink attacks +if [[ ! -d "$CACHE_DIR" ]]; then + mkdir -p "$CACHE_DIR" 2>/dev/null || true + chmod 700 "$CACHE_DIR" 2>/dev/null || true +fi +# Verify we own the cache directory (defense against pre-created symlinks) +if [[ ! -d "$CACHE_DIR" ]] || [[ ! -O "$CACHE_DIR" ]]; then + # Disable caching if directory is suspicious + CACHE_DIR="" +fi + +# get_mtime FILE — portable mtime retrieval +get_mtime() { + local file="$1" + case "$(uname -s)" in + Darwin) stat -f %m "$file" 2>/dev/null ;; + *) stat -c %Y "$file" 2>/dev/null ;; + esac || echo 0 +} + +# cached_value KEY TTL_SECONDS COMMAND [ARGS...] +# Returns cached value if fresh, otherwise runs command and caches result. +# SECURITY: Commands are executed directly (not via eval) to prevent injection. +cached_value() { + local key="$1" ttl="$2" + shift 2 + + # If caching is disabled, just run the command + if [[ -z "$CACHE_DIR" ]]; then + "$@" 2>/dev/null || true + return + fi + + # Sanitize cache key to prevent path traversal + local safe_key="${key//[^a-zA-Z0-9_-]/_}" + local cache_file="$CACHE_DIR/$safe_key" + + if [[ -f "$cache_file" ]]; then + local now mtime age + now="$(date +%s)" + mtime="$(get_mtime "$cache_file")" + age=$(( now - mtime )) + if (( age < ttl )); then + cat "$cache_file" + return 0 + fi + fi + + local result + # Execute command directly without eval — prevents injection + result="$("$@" 2>/dev/null)" || true + printf '%s' "$result" > "$cache_file" + printf '%s' "$result" +} + +# ── Terminal Width ──────────────────────────────────────────────────────────── +# Claude Code runs the script without a TTY, so tput/COLUMNS often return wrong +# values. Detection priority: +# 1. Explicit config override (global.width) +# 2. CLAUDE_STATUSLINE_WIDTH env var +# 3. Walk process tree to find ancestor with a real TTY, then stty size on it +# 4. stty via /dev/tty (works on some systems) +# 5. COLUMNS env var +# 6. tput cols +# 7. Fallback: 120 + +detect_width() { + # 1. Config override + local cfg_width + cfg_width="$(cfg '.global.width' '')" + if [[ -n "$cfg_width" && "$cfg_width" != "0" ]]; then + echo "$cfg_width" + return + fi + + # 2. Env var override + if [[ -n "${CLAUDE_STATUSLINE_WIDTH:-}" ]]; then + echo "$CLAUDE_STATUSLINE_WIDTH" + return + fi + + # 3. Walk process tree to find ancestor's TTY + # The script runs without a TTY, but the shell that launched Claude Code has one. + local pid=$$ + while [[ "$pid" -gt 1 ]]; do + local tty_name + tty_name="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')" || break + if [[ -n "$tty_name" && "$tty_name" != "??" && "$tty_name" != "-" && "$tty_name" != "" ]]; then + local w + w="$(stty size < "/dev/$tty_name" 2>/dev/null | awk '{print $2}')" || true + if [[ -n "$w" ]] && (( w > 0 )); then + echo "$w" + return + fi + fi + pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')" || break + done + + # 4. stty via /dev/tty + local tty_width + tty_width="$(stty size < /dev/tty 2>/dev/null | awk '{print $2}')" || true + if [[ -n "$tty_width" ]] && (( tty_width > 0 )); then + echo "$tty_width" + return + fi + + # 5. COLUMNS env var (often wrong in non-interactive shells) + if [[ -n "${COLUMNS:-}" ]] && (( ${COLUMNS:-0} > 0 )); then + echo "$COLUMNS" + return + fi + + # 6. tput cols + local tput_width + tput_width="$(tput cols 2>/dev/null)" || true + if [[ -n "$tput_width" ]] && (( tput_width > 0 )); then + echo "$tput_width" + return + fi + + # 7. Fallback + echo 120 +} + +# The detected PTY width may exceed the actual rendering area due to +# terminal multiplexer borders (Zellij/tmux) or Claude Code UI chrome. +# Configurable via global.width_margin (default: 4). +WIDTH_MARGIN="$(cfg '.global.width_margin' '4')" +TERM_WIDTH=$(( $(detect_width) - WIDTH_MARGIN )) + +# ── Responsive Layout Selection ────────────────────────────────────────────── +# Auto-select layout preset based on terminal width when responsive mode is enabled. +# Breakpoints: narrow (<60) -> dense, medium (60-99) -> standard, wide (>=100) -> verbose + +RESPONSIVE="$(cfg '.global.responsive' 'true')" +RESPONSIVE_LAYOUT="" +if [[ "$RESPONSIVE" == "true" ]]; then + NARROW_BP="$(cfg '.global.breakpoints.narrow' '60')" + MEDIUM_BP="$(cfg '.global.breakpoints.medium' '100')" + + if (( TERM_WIDTH < NARROW_BP )); then + RESPONSIVE_LAYOUT="dense" + elif (( TERM_WIDTH < MEDIUM_BP )); then + RESPONSIVE_LAYOUT="standard" + else + RESPONSIVE_LAYOUT="verbose" + fi +fi + +# ── Width Tiers for Progressive Disclosure ─────────────────────────────────── +# Sections can show different levels of detail based on available width. +# Uses the same breakpoints as responsive layout selection. + +if (( TERM_WIDTH < ${NARROW_BP:-60} )); then + WIDTH_TIER="narrow" +elif (( TERM_WIDTH < ${MEDIUM_BP:-100} )); then + WIDTH_TIER="medium" +else + WIDTH_TIER="wide" +fi + +# ── Sparkline Helper ───────────────────────────────────────────────────────── + +sparkline() { + local values="$1" # comma-separated: "10,20,30,25,40" + local width="${2:-8}" + local chars="▁▂▃▄▅▆▇█" + + # Parse values into array + IFS=',' read -ra vals <<< "$values" + local count=${#vals[@]} + if (( count == 0 )); then + printf '%s' "" + return + fi + + # Find min/max + local min=${vals[0]} max=${vals[0]} + local v + for v in "${vals[@]}"; do + (( v < min )) && min=$v + (( v > max )) && max=$v + done + + # Handle flat line + if (( max == min )); then + local mid_char="${chars:4:1}" + local result="" + local i + for (( i = 0; i < count && i < width; i++ )); do + result+="$mid_char" + done + printf '%s' "$result" + return + fi + + # Map values to sparkline characters + local result="" + local range=$((max - min)) + local char_count=8 + local i + for (( i = 0; i < count && i < width; i++ )); do + local v=${vals[$i]} + local idx=$(( (v - min) * (char_count - 1) / range )) + result+="${chars:$idx:1}" + done + + printf '%s' "$result" +} + +# ── Trend Tracking ─────────────────────────────────────────────────────────── + +# Track a value for trend sparkline +track_trend() { + local key="$1" value="$2" max_points="${3:-8}" + local trend_file="$CACHE_DIR/trend_${key}" + + # Read existing values + local existing="" + [[ -f "$trend_file" ]] && existing="$(cat "$trend_file")" + + # Append new value + if [[ -n "$existing" ]]; then + existing="${existing},${value}" + else + existing="$value" + fi + + # Trim to max points + IFS=',' read -ra vals <<< "$existing" + if (( ${#vals[@]} > max_points )); then + vals=("${vals[@]: -$max_points}") + existing=$(IFS=','; echo "${vals[*]}") + fi + + printf '%s' "$existing" > "$trend_file" + printf '%s' "$existing" +} + +get_trend() { + local key="$1" + local trend_file="$CACHE_DIR/trend_${key}" + [[ -f "$trend_file" ]] && cat "$trend_file" || echo "" +} + +# ── Human-Readable Helpers ──────────────────────────────────────────────────── + +human_tokens() { + local n="${1:-0}" + if (( n >= 1000000 )); then + printf '%.1fM' "$(echo "scale=1; $n / 1000000" | bc 2>/dev/null || echo "$n")" + elif (( n >= 1000 )); then + printf '%.1fk' "$(echo "scale=1; $n / 1000" | bc 2>/dev/null || echo "$n")" + else + printf '%d' "$n" + fi +} + +human_duration() { + local ms="${1:-0}" + local secs=$(( ms / 1000 )) + if (( secs >= 3600 )); then + printf '%dh%dm' $(( secs / 3600 )) $(( (secs % 3600) / 60 )) + elif (( secs >= 60 )); then + printf '%dm' $(( secs / 60 )) + else + printf '%ds' "$secs" + fi +} + +# ── Truncation Helpers ─────────────────────────────────────────────────────── + +truncate_right() { + local s="$1" max="$2" ellipsis="${3:-…}" + if (( ${#s} <= max )); then + printf '%s' "$s" + else + printf '%s%s' "${s:0:$((max - 1))}" "$ellipsis" + fi +} + +truncate_middle() { + local s="$1" max="$2" ellipsis="${3:-…}" + if (( ${#s} <= max )); then + printf '%s' "$s" + else + local half=$(( (max - 1) / 2 )) + printf '%s%s%s' "${s:0:$half}" "$ellipsis" "${s: -$((max - 1 - half))}" + fi +} + +truncate_left() { + local s="$1" max="$2" ellipsis="${3:-…}" + if (( ${#s} <= max )); then + printf '%s' "$s" + else + printf '%s%s' "$ellipsis" "${s: -$((max - 1))}" + fi +} + +# Apply truncation to a section based on its config +# Modifies SEC_RAW and SEC_ANSI globals +apply_truncation() { + local id="$1" + local trunc_enabled trunc_max trunc_style + + # Read truncation config (works for both builtin and custom sections) + if is_builtin "$id"; then + trunc_enabled="$(cfg ".sections.${id}.truncate.enabled" "false")" + trunc_max="$(cfg ".sections.${id}.truncate.max" "0")" + trunc_style="$(cfg ".sections.${id}.truncate.style" "right")" + else + local idx + idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true + if [[ -n "$idx" ]]; then + trunc_enabled="$(jq -r ".custom[$idx].truncate.enabled // false" <<< "$CONFIG_JSON")" + trunc_max="$(jq -r ".custom[$idx].truncate.max // 0" <<< "$CONFIG_JSON")" + trunc_style="$(jq -r ".custom[$idx].truncate.style // \"right\"" <<< "$CONFIG_JSON")" + else + return + fi + fi + + # Skip if truncation not enabled or max is 0 + if [[ "$trunc_enabled" != "true" ]]; then + return + fi + if [[ "$trunc_max" == "0" || -z "$trunc_max" ]]; then + return + fi + + # Only truncate if content exceeds max + if (( ${#SEC_RAW} > trunc_max )); then + case "$trunc_style" in + middle) SEC_RAW="$(truncate_middle "$SEC_RAW" "$trunc_max")" ;; + left) SEC_RAW="$(truncate_left "$SEC_RAW" "$trunc_max")" ;; + *) SEC_RAW="$(truncate_right "$SEC_RAW" "$trunc_max")" ;; + esac + # Regenerate ANSI version - apply dim styling to truncated content + SEC_ANSI="${C_DIM}${SEC_RAW}${C_RESET}" + fi +} + +# ── VCS Detection ───────────────────────────────────────────────────────────── + +# PROJECT_DIR is already sanitized via _project_dir above +PROJECT_DIR="$_project_dir" +VCS_PREFER="$(cfg '.sections.vcs.prefer' "$(cfg '.global.vcs' 'auto')")" + +detect_vcs() { + local dir="${PROJECT_DIR:-.}" + case "$VCS_PREFER" in + jj) + if [[ -d "$dir/.jj" ]]; then echo "jj"; else echo "none"; fi + ;; + git) + if [[ -d "$dir/.git" ]]; then echo "git"; else echo "none"; fi + ;; + auto|*) + if [[ -d "$dir/.jj" ]]; then echo "jj" + elif [[ -d "$dir/.git" ]]; then echo "git" + else echo "none" + fi + ;; + esac +} + +VCS_TYPE="$(detect_vcs)" + +# ── VCS Helper Commands ─────────────────────────────────────────────────────── +# Safe wrappers that accept sanitized paths and don't require eval. + +_git_branch() { + local dir="$1" + git -C "$dir" rev-parse --abbrev-ref HEAD +} + +_git_dirty() { + local dir="$1" + git -C "$dir" status --porcelain --untracked-files=no | head -1 +} + +_git_ahead_behind() { + local dir="$1" + git -C "$dir" rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo '0 0' +} + +_jj_branch() { + jj log -r @ --no-graph -T 'if(bookmarks, bookmarks.join(","), change_id.shortest(8))' --color=never +} + +_jj_dirty() { + jj diff --stat --color=never | tail -1 +} + +_system_load() { + if [[ "$(uname)" == "Darwin" ]]; then + sysctl -n vm.loadavg | awk '{print $2}' + else + awk '{print $1}' /proc/loadavg + fi +} + +_beads_wip_id() { + br list --status=in_progress --json 2>/dev/null | jq -r 'if type == "array" then .[0].id // empty else empty end' +} + +# Get all beads stats in one call (cached) +_beads_stats() { + br status --json 2>/dev/null | jq -r '.summary // empty' +} + +# ── Section Renderers ───────────────────────────────────────────────────────── +# Each renderer sets two globals: +# SEC_RAW — plain text (for width calculation) +# SEC_ANSI — ANSI-colored text (for display) + +render_model() { + local display_name model_id + display_name="$(inp '.model.display_name' '')" + model_id="$(inp '.model.id' '?')" + + # Use model_id for parsing (display_name often equals model_id anyway) + local id_to_parse="$model_id" + + # Convert to lowercase for matching (bash 3 compatible) + local id_lower + id_lower="$(printf '%s' "$id_to_parse" | tr '[:upper:]' '[:lower:]')" + + # Extract base model name + local base_name="" + case "$id_lower" in + *opus*) base_name="Opus" ;; + *sonnet*) base_name="Sonnet" ;; + *haiku*) base_name="Haiku" ;; + *) base_name="${display_name:-$model_id}" ;; + esac + + # Try to extract version number (e.g., "4-5" or "3-5" or "4-6" → "4.5" or "3.5" or "4.6") + local version="" + if [[ "$id_lower" =~ (opus|sonnet|haiku)-([0-9]+)-([0-9]+) ]]; then + version="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + elif [[ "$id_lower" =~ ([0-9]+)-([0-9]+)-(opus|sonnet|haiku) ]]; then + version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + fi + + local name + if [[ -n "$version" ]]; then + name="${base_name} ${version}" + else + name="$base_name" + fi + + SEC_RAW="[$name]" + SEC_ANSI="${C_BOLD}[${name}]${C_RESET}" +} + +render_provider() { + local model_id + model_id="$(inp '.model.id' '')" + local provider="" + case "$model_id" in + us.anthropic.*|anthropic.*) provider="Bedrock" ;; + *@[0-9][0-9][0-9][0-9]*) provider="Vertex" ;; + claude-*) provider="Anthropic" ;; + *) provider="" ;; + esac + if [[ -z "$provider" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + SEC_RAW="$provider" + SEC_ANSI="${C_DIM}${provider}${C_RESET}" +} + +render_project() { + local dir + dir="$(inp '.workspace.project_dir' '')" + local name + if [[ -n "$dir" ]]; then + name="$(basename "$dir")" + else + name="$(basename "$(pwd)")" + fi + SEC_RAW="$name" + SEC_ANSI="${C_CYAN}${name}${C_RESET}" +} + +render_vcs() { + if [[ "$VCS_TYPE" == "none" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local dir="${PROJECT_DIR:-.}" + local branch="" dirty="" ahead="" behind="" + local show_dirty show_ahead_behind + show_dirty="$(cfg '.sections.vcs.show_dirty' 'true')" + show_ahead_behind="$(cfg '.sections.vcs.show_ahead_behind' 'true')" + + if [[ "$VCS_TYPE" == "git" ]]; then + local branch_ttl dirty_ttl ab_ttl + branch_ttl="$(cfg '.sections.vcs.ttl.branch' '3')" + dirty_ttl="$(cfg '.sections.vcs.ttl.dirty' '5')" + ab_ttl="$(cfg '.sections.vcs.ttl.ahead_behind' '30')" + + branch="$(cached_value "vcs_branch" "$branch_ttl" _git_branch "$dir")" + + if [[ "$show_dirty" == "true" ]]; then + local status_out + status_out="$(cached_value "vcs_dirty" "$dirty_ttl" _git_dirty "$dir")" + [[ -n "$status_out" ]] && dirty="$(glyph dirty)" + fi + + # Progressive disclosure: ahead/behind only for medium+ width + if [[ "$show_ahead_behind" == "true" && "$WIDTH_TIER" != "narrow" ]]; then + local ab_raw + ab_raw="$(cached_value "vcs_ab" "$ab_ttl" _git_ahead_behind "$dir")" + ahead="$(echo "$ab_raw" | awk '{print $1}')" + behind="$(echo "$ab_raw" | awk '{print $2}')" + fi + elif [[ "$VCS_TYPE" == "jj" ]]; then + local branch_ttl dirty_ttl + branch_ttl="$(cfg '.sections.vcs.ttl.branch' '3')" + dirty_ttl="$(cfg '.sections.vcs.ttl.dirty' '5')" + + branch="$(cached_value "vcs_branch" "$branch_ttl" _jj_branch)" + + if [[ "$show_dirty" == "true" ]]; then + local jj_diff + jj_diff="$(cached_value "vcs_dirty" "$dirty_ttl" _jj_dirty)" + [[ -n "$jj_diff" && "$jj_diff" != *"0 files changed"* && "$jj_diff" != "0"* ]] && dirty="$(glyph dirty)" + fi + # jj doesn't have a direct ahead/behind concept vs upstream + ahead="0" + behind="0" + fi + + # Build output with glyphs + local branch_glyph + branch_glyph="$(glyph branch)" + local raw="${branch_glyph}${branch}${dirty}" + local ansi="${C_GREEN}${branch_glyph}${branch}${C_RESET}" + [[ -n "$dirty" ]] && ansi="${ansi}${C_YELLOW}${dirty}${C_RESET}" + + # Progressive disclosure: show ahead/behind only for medium+ width + if [[ "$show_ahead_behind" == "true" && "$WIDTH_TIER" != "narrow" && ("${ahead:-0}" != "0" || "${behind:-0}" != "0") ]]; then + local ahead_glyph behind_glyph ab_str="" + ahead_glyph="$(glyph ahead)" + behind_glyph="$(glyph behind)" + [[ "${ahead:-0}" != "0" ]] && ab_str="${ahead_glyph}${ahead}" + [[ "${behind:-0}" != "0" ]] && ab_str="${ab_str}${behind_glyph}${behind}" + raw="${raw} ${ab_str}" + ansi="${ansi} ${C_DIM}${ab_str}${C_RESET}" + fi + + SEC_RAW="$raw" + SEC_ANSI="$ansi" +} + +render_beads() { + local ttl + ttl="$(cfg '.sections.beads.ttl' '30')" + local show_wip show_wip_count show_ready show_open show_closed + show_wip="$(cfg '.sections.beads.show_wip' 'true')" + show_wip_count="$(cfg '.sections.beads.show_wip_count' 'true')" + show_ready="$(cfg '.sections.beads.show_ready_count' 'true')" + show_open="$(cfg '.sections.beads.show_open_count' 'true')" + show_closed="$(cfg '.sections.beads.show_closed_count' 'true')" + + if ! command -v br &>/dev/null; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local parts=() + local ansi_parts=() + + # Get all stats in one cached call + local stats_json + stats_json="$(cached_value "beads_stats" "$ttl" _beads_stats)" + + if [[ -z "$stats_json" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Current WIP bead ID (always show if enabled — most important context) + if [[ "$show_wip" == "true" ]]; then + local wip + wip="$(cached_value "beads_wip_id" "$ttl" _beads_wip_id)" + if [[ -n "$wip" ]]; then + parts+=("$wip wip") + ansi_parts+=("${C_YELLOW}${wip}${C_RESET} ${C_DIM}wip${C_RESET}") + fi + fi + + # Ready count (high priority — what's available to work on) + if [[ "$show_ready" == "true" ]]; then + local ready_count + ready_count="$(jq -r '.ready_issues // 0' <<< "$stats_json")" + if [[ "${ready_count:-0}" -gt 0 ]]; then + parts+=("${ready_count} ready") + ansi_parts+=("${C_GREEN}${ready_count}${C_RESET} ${C_DIM}ready${C_RESET}") + fi + fi + + # Open count (medium+ width — total backlog) + if [[ "$show_open" == "true" && "$WIDTH_TIER" != "narrow" ]]; then + local open_count + open_count="$(jq -r '.open_issues // 0' <<< "$stats_json")" + if [[ "${open_count:-0}" -gt 0 ]]; then + parts+=("${open_count} open") + ansi_parts+=("${C_BLUE}${open_count}${C_RESET} ${C_DIM}open${C_RESET}") + fi + fi + + # In-progress count (medium+ width — shows workload) + if [[ "$show_wip_count" == "true" && "$WIDTH_TIER" != "narrow" ]]; then + local wip_count + wip_count="$(jq -r '.in_progress_issues // 0' <<< "$stats_json")" + if [[ "${wip_count:-0}" -gt 0 ]]; then + parts+=("${wip_count} wip") + ansi_parts+=("${C_YELLOW}${wip_count}${C_RESET} ${C_DIM}wip${C_RESET}") + fi + fi + + # Closed count (wide only — shows progress/completion) + if [[ "$show_closed" == "true" && "$WIDTH_TIER" == "wide" ]]; then + local closed_count + closed_count="$(jq -r '.closed_issues // 0' <<< "$stats_json")" + if [[ "${closed_count:-0}" -gt 0 ]]; then + parts+=("${closed_count} done") + ansi_parts+=("${C_DIM}${closed_count} done${C_RESET}") + fi + fi + + if [[ ${#parts[@]} -eq 0 ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local IFS=" | " + SEC_RAW="${parts[*]}" + SEC_ANSI="${ansi_parts[*]}" +} + +render_context_bar() { + local pct + pct="$(inp '.context_window.used_percentage' '')" + if [[ -z "$pct" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Round to integer + local pct_int + pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" + + local bar_width + bar_width="$(cfg '.sections.context_bar.bar_width' '10')" + local filled=$(( pct_int * bar_width / 100 )) + local empty=$(( bar_width - filled )) + + local bar="" + local i + for (( i = 0; i < filled; i++ )); do bar+="="; done + for (( i = 0; i < empty; i++ )); do bar+="-"; done + + SEC_RAW="[${bar}] ${pct_int}%" + + # Color by threshold + local warn danger critical + warn="$(cfg '.sections.context_bar.thresholds.warn' '50')" + danger="$(cfg '.sections.context_bar.thresholds.danger' '70')" + critical="$(cfg '.sections.context_bar.thresholds.critical' '85')" + + local color="$C_GREEN" + if (( pct_int >= critical )); then + color="${C_RED}${C_BOLD}" + elif (( pct_int >= danger )); then + color="$C_RED" + elif (( pct_int >= warn )); then + color="$C_YELLOW" + fi + + SEC_ANSI="${color}[${bar}] ${pct_int}%${C_RESET}" +} + +render_context_usage() { + # Shows context usage as "154k / 200k" (current context used / total capacity) + # NOTE: total_input/output_tokens are CUMULATIVE session totals, not current context! + # We must calculate current usage from: used_percentage * context_window_size / 100 + local pct context_size + pct="$(inp '.context_window.used_percentage' '')" + context_size="$(inp '.context_window.context_window_size' '')" + + if [[ -z "$pct" || -z "$context_size" || "$context_size" == "0" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Calculate current used tokens from percentage + local used + used="$(awk "BEGIN{printf \"%.0f\", $pct * $context_size / 100}" 2>/dev/null || echo "0")" + + if (( used == 0 )); then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local total="$context_size" + + local used_h total_h + used_h="$(human_tokens "$used")" + total_h="$(human_tokens "$total")" + + SEC_RAW="${used_h}/${total_h}" + + # Color by percentage threshold + local pct_int=0 + if [[ -n "$pct" ]]; then + pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" + fi + + local warn danger critical + warn="$(cfg '.sections.context_usage.thresholds.warn' '50')" + danger="$(cfg '.sections.context_usage.thresholds.danger' '70')" + critical="$(cfg '.sections.context_usage.thresholds.critical' '85')" + + local color="$C_GREEN" + if (( pct_int >= critical )); then + color="${C_RED}${C_BOLD}" + elif (( pct_int >= danger )); then + color="$C_RED" + elif (( pct_int >= warn )); then + color="$C_YELLOW" + fi + + SEC_ANSI="${color}${used_h}${C_RESET}${C_DIM}/${total_h}${C_RESET}" +} + +render_tokens_raw() { + local input output + input="$(inp '.context_window.total_input_tokens' '0')" + output="$(inp '.context_window.total_output_tokens' '0')" + + # Progressive disclosure: more detail at wider widths + # narrow: "115k/8k" (compact, no labels) + # medium: "115k in/8k out" (with labels) + # wide: "115.2k in / 8.5k out" (decimal precision, spaced) + local input_h output_h raw + + case "$WIDTH_TIER" in + narrow) + # Compact format: integer k/M values, no labels + input_h="$(human_tokens "$input")" + output_h="$(human_tokens "$output")" + raw="${input_h}/${output_h}" + ;; + medium) + # Standard format: k/M values with labels + input_h="$(human_tokens "$input")" + output_h="$(human_tokens "$output")" + local fmt + fmt="$(cfg '.sections.tokens_raw.format' '{input}in/{output}out')" + raw="${fmt//\{input\}/$input_h}" + raw="${raw//\{output\}/$output_h}" + ;; + wide) + # Verbose format: more precision, spaced separators + # Use one decimal place for better precision + if (( input >= 1000000 )); then + input_h="$(printf '%.1fM' "$(echo "scale=2; $input / 1000000" | bc 2>/dev/null || echo "$input")")" + elif (( input >= 1000 )); then + input_h="$(printf '%.1fk' "$(echo "scale=2; $input / 1000" | bc 2>/dev/null || echo "$input")")" + else + input_h="$input" + fi + if (( output >= 1000000 )); then + output_h="$(printf '%.1fM' "$(echo "scale=2; $output / 1000000" | bc 2>/dev/null || echo "$output")")" + elif (( output >= 1000 )); then + output_h="$(printf '%.1fk' "$(echo "scale=2; $output / 1000" | bc 2>/dev/null || echo "$output")")" + else + output_h="$output" + fi + raw="${input_h} in / ${output_h} out" + ;; + esac + + SEC_RAW="$raw" + SEC_ANSI="${C_DIM}${raw}${C_RESET}" +} + +render_cache_efficiency() { + local cache_read cache_creation + cache_read="$(inp '.context_window.current_usage.cache_read_input_tokens' '0')" + cache_creation="$(inp '.context_window.current_usage.cache_creation_input_tokens' '0')" + + local total=$(( cache_read + cache_creation )) + if (( total == 0 )); then + SEC_RAW="cache:0%" + SEC_ANSI="${C_DIM}cache:0%${C_RESET}" + return + fi + + local pct=$(( cache_read * 100 / total )) + SEC_RAW="cache:${pct}%" + + local color="$C_DIM" + (( pct >= 50 )) && color="$C_GREEN" + (( pct >= 80 )) && color="${C_GREEN}${C_BOLD}" + + SEC_ANSI="${color}cache:${pct}%${C_RESET}" +} + +render_cost() { + local cost_raw + cost_raw="$(inp '.cost.total_cost_usd' '')" + if [[ -z "$cost_raw" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Progressive disclosure: decimal precision based on width tier + # narrow: "$0" (no decimals, rounds to nearest dollar) + # medium: "$0.25" (2 decimals, standard) + # wide: "$0.2547" (4 decimals, precise) + local decimals=2 + case "$WIDTH_TIER" in + narrow) decimals=0 ;; + medium) decimals=2 ;; + wide) decimals=4 ;; + esac + + local cost + cost="$(printf "%.${decimals}f" "$cost_raw" 2>/dev/null || echo "$cost_raw")" + + SEC_RAW="\$${cost}" + + local warn danger critical + warn="$(cfg '.sections.cost.thresholds.warn' '5.00')" + danger="$(cfg '.sections.cost.thresholds.danger' '8.00')" + critical="$(cfg '.sections.cost.thresholds.critical' '10.00')" + + # Compare using awk (cost_raw retains full precision for comparison) + local color="$C_GREEN" + if awk "BEGIN{exit(!($cost_raw >= $critical))}"; then + color="${C_RED}${C_BOLD}" + elif awk "BEGIN{exit(!($cost_raw >= $danger))}"; then + color="$C_RED" + elif awk "BEGIN{exit(!($cost_raw >= $warn))}"; then + color="$C_YELLOW" + fi + + SEC_ANSI="${color}\$${cost}${C_RESET}" +} + +render_cost_velocity() { + local cost duration_ms + cost="$(inp '.cost.total_cost_usd' '0')" + duration_ms="$(inp '.cost.total_duration_ms' '0')" + + if [[ "$duration_ms" == "0" || -z "$duration_ms" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local velocity + velocity="$(awk "BEGIN{printf \"%.2f\", $cost / ($duration_ms / 60000)}" 2>/dev/null || echo "0.00")" + SEC_RAW="\$${velocity}/m" + SEC_ANSI="${C_DIM}\$${velocity}/m${C_RESET}" +} + +render_token_velocity() { + local input output duration_ms + input="$(inp '.context_window.total_input_tokens' '0')" + output="$(inp '.context_window.total_output_tokens' '0')" + duration_ms="$(inp '.cost.total_duration_ms' '0')" + + if [[ "$duration_ms" == "0" || -z "$duration_ms" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local total=$(( input + output )) + local velocity + velocity="$(awk "BEGIN{printf \"%.1f\", $total / ($duration_ms / 60000)}" 2>/dev/null || echo "0.0")" + + # Progressive disclosure: abbreviate based on width tier + local suffix="tok/m" + if [[ "$WIDTH_TIER" == "narrow" ]]; then + suffix="t/m" + fi + + # Human-readable for large values + if awk "BEGIN{exit !($velocity >= 1000)}" 2>/dev/null; then + velocity="$(awk "BEGIN{printf \"%.1f\", $velocity / 1000}" 2>/dev/null)k" + fi + + SEC_RAW="${velocity}${suffix}" + SEC_ANSI="${C_DIM}${velocity}${suffix}${C_RESET}" +} + +render_lines_changed() { + local added removed + added="$(inp '.cost.total_lines_added' '0')" + removed="$(inp '.cost.total_lines_removed' '0')" + + if [[ "$added" == "0" && "$removed" == "0" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + SEC_RAW="+${added} -${removed}" + SEC_ANSI="${C_GREEN}+${added}${C_RESET} ${C_RED}-${removed}${C_RESET}" +} + +render_duration() { + local ms + ms="$(inp '.cost.total_duration_ms' '')" + if [[ -z "$ms" || "$ms" == "0" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local human + human="$(human_duration "$ms")" + SEC_RAW="$human" + SEC_ANSI="${C_DIM}${human}${C_RESET}" +} + +render_tools() { + local tool_count + tool_count="$(inp '.cost.total_tool_uses' '0')" + + if [[ "$tool_count" == "0" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Progressive disclosure: detail based on width tier + # narrow: "42" (just the count) + # medium: "42 tools" (count with label) + # wide: "42 tools (Read)" (count, label, and last tool name) + local raw ansi + + case "$WIDTH_TIER" in + narrow) + raw="$tool_count" + ansi="${C_DIM}${tool_count}${C_RESET}" + ;; + medium) + raw="${tool_count} tools" + ansi="${C_DIM}${tool_count} tools${C_RESET}" + ;; + wide) + raw="${tool_count} tools" + ansi="${C_DIM}${tool_count} tools${C_RESET}" + + local show_last_name + show_last_name="$(cfg '.sections.tools.show_last_name' 'true')" + if [[ "$show_last_name" == "true" ]]; then + local last_tool + last_tool="$(inp '.cost.last_tool_name' '')" + if [[ -n "$last_tool" ]]; then + # Shorten tool name if too long + if (( ${#last_tool} > 12 )); then + last_tool="${last_tool:0:11}~" + fi + raw="${raw} (${last_tool})" + ansi="${ansi} ${C_DIM}(${last_tool})${C_RESET}" + fi + fi + ;; + esac + + SEC_RAW="$raw" + SEC_ANSI="$ansi" +} + +render_turns() { + local turns + turns="$(inp '.cost.total_turns' '')" + if [[ -z "$turns" || "$turns" == "0" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + SEC_RAW="${turns} turns" + SEC_ANSI="${C_DIM}${turns} turns${C_RESET}" +} + +render_load() { + local ttl + ttl="$(cfg '.sections.load.ttl' '10')" + local load_val + load_val="$(cached_value "load" "$ttl" _system_load)" + SEC_RAW="load:${load_val}" + SEC_ANSI="${C_DIM}load:${load_val}${C_RESET}" +} + +render_version() { + local ver + ver="$(inp '.version' '')" + if [[ -z "$ver" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + SEC_RAW="v${ver}" + SEC_ANSI="${C_DIM}v${ver}${C_RESET}" +} + +render_time() { + local fmt + fmt="$(cfg '.sections.time.format' '%H:%M')" + local t + t="$(date +"$fmt")" + SEC_RAW="$t" + SEC_ANSI="${C_DIM}${t}${C_RESET}" +} + +render_output_style() { + local style + style="$(inp '.output_style.name' '')" + if [[ -z "$style" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + SEC_RAW="$style" + SEC_ANSI="${C_MAGENTA}${style}${C_RESET}" +} + +render_hostname() { + local h + h="$(hostname -s 2>/dev/null || echo "?")" + SEC_RAW="$h" + SEC_ANSI="${C_DIM}${h}${C_RESET}" +} + +render_cost_trend() { + local cost + cost="$(inp '.cost.total_cost_usd' '')" + if [[ -z "$cost" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Convert to cents for integer sparkline (avoids floating point issues) + local cents + cents="$(awk "BEGIN{printf \"%.0f\", $cost * 100}" 2>/dev/null || echo "0")" + + # Get sparkline width from config + local width + width="$(cfg '.sections.cost_trend.width' '8')" + + local trend + trend="$(track_trend "cost" "$cents" "$width")" + + local spark + spark="$(sparkline "$trend" "$width")" + + if [[ -z "$spark" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + SEC_RAW="$spark" + SEC_ANSI="${C_DIM}$spark${C_RESET}" +} + +render_context_trend() { + local pct + pct="$(inp '.context_window.used_percentage' '')" + if [[ -z "$pct" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Round to integer + local pct_int + pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" + + # Get sparkline width from config + local width + width="$(cfg '.sections.context_trend.width' '8')" + + local trend + trend="$(track_trend "context" "$pct_int" "$width")" + + local spark + spark="$(sparkline "$trend" "$width")" + + if [[ -z "$spark" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + # Color based on current percentage + local warn danger critical + warn="$(cfg '.sections.context_trend.thresholds.warn' '50')" + danger="$(cfg '.sections.context_trend.thresholds.danger' '70')" + critical="$(cfg '.sections.context_trend.thresholds.critical' '85')" + + local color="$C_DIM" + if (( pct_int >= critical )); then + color="${C_RED}${C_BOLD}" + elif (( pct_int >= danger )); then + color="$C_RED" + elif (( pct_int >= warn )); then + color="$C_YELLOW" + fi + + SEC_RAW="$spark" + SEC_ANSI="${color}$spark${C_RESET}" +} + +# ── Custom Command Renderer ────────────────────────────────────────────────── + +# Helper for custom commands — uses bash -c since commands are shell strings +# Custom commands come from user config (trusted source), not stdin +_run_custom_cmd() { + local cmd="$1" + bash -c "$cmd" +} + +render_custom() { + local section_id="$1" + local idx + idx="$(jq -r --arg id "$section_id" ' + .custom | to_entries[] | select(.value.id == $id) | .key + ' <<< "$CONFIG_JSON" 2>/dev/null)" || true + + if [[ -z "$idx" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + local cmd label ttl_val + cmd="$(jq -r ".custom[$idx].command" <<< "$CONFIG_JSON")" + label="$(jq -r ".custom[$idx].label // .custom[$idx].id" <<< "$CONFIG_JSON")" + ttl_val="$(jq -r ".custom[$idx].ttl // 30" <<< "$CONFIG_JSON")" + + # Cache key sanitization is handled by cached_value() + local result + result="$(cached_value "custom_${section_id}" "$ttl_val" _run_custom_cmd "$cmd")" + + if [[ -z "$result" ]]; then + SEC_RAW="" + SEC_ANSI="" + return + fi + + SEC_RAW="${label}:${result}" + + # Color matching + local color_name + color_name="$(jq -r --arg val "$result" ".custom[$idx].color.match[\$val] // empty" <<< "$CONFIG_JSON" 2>/dev/null)" || true + + if [[ -n "$color_name" ]]; then + local c + c="$(color_by_name "$color_name")" + SEC_ANSI="${c}${label}:${result}${C_RESET}" + else + SEC_ANSI="${C_DIM}${label}:${result}${C_RESET}" + fi +} + +# ── Section Dispatch ────────────────────────────────────────────────────────── + +BUILTIN_SECTIONS="model provider project vcs beads context_bar context_usage tokens_raw cache_efficiency cost cost_velocity token_velocity cost_trend context_trend lines_changed duration tools turns load version time output_style hostname" + +is_builtin() { + local id="$1" + [[ " $BUILTIN_SECTIONS " == *" $id "* ]] +} + +is_spacer() { + local id="$1" + [[ "$id" == "spacer" || "$id" == _spacer* ]] +} + +render_section() { + local id="$1" + SEC_RAW="" + SEC_ANSI="" + + if is_spacer "$id"; then + SEC_RAW=" " + SEC_ANSI=" " + return + fi + + if is_builtin "$id"; then + if ! section_enabled "$id"; then + return + fi + "render_${id}" + else + # Try custom command + render_custom "$id" + fi + + # Apply truncation and formatting if section produced output + if [[ -n "$SEC_RAW" ]]; then + apply_truncation "$id" + apply_formatting "$id" + fi +} + +# ── Per-Section Formatting ───────────────────────────────────────────────── +# Applies prefix, suffix, color override, and pad+align to any section. +# Uses a single jq call to batch-read all formatting properties. + +apply_formatting() { + local id="$1" + + # Single jq call using @sh to produce shell-safe variable assignments. + # Tab-split (IFS=$'\t' read) doesn't work: tab is whitespace in POSIX, + # so consecutive tabs collapse and leading empty fields are swallowed. + local prefix="" suffix="" pad="" align="" color_name="" + if is_builtin "$id"; then + eval "$(jq -r ' + .sections["'"$id"'"] as $s | + "prefix=" + (@sh "\($s.prefix // "")") + + " suffix=" + (@sh "\($s.suffix // "")") + + " pad=" + (@sh "\(($s.pad // "") | tostring)") + + " align=" + (@sh "\($s.align // "")") + + " color_name=" + (@sh "\($s.color // "")") + ' <<< "$CONFIG_JSON" 2>/dev/null)" || true + else + # Custom commands use default_color to avoid conflict with color.match + eval "$(jq -r --arg id "$id" ' + (.custom | to_entries[] | select(.value.id == $id) | .value) as $sec | + "prefix=" + (@sh "\($sec.prefix // "")") + + " suffix=" + (@sh "\($sec.suffix // "")") + + " pad=" + (@sh "\(($sec.pad // "") | tostring)") + + " align=" + (@sh "\($sec.align // "")") + + " color_name=" + (@sh "\($sec.default_color // "")") + ' <<< "$CONFIG_JSON" 2>/dev/null)" || true + fi + + # If nothing to do, return early + if [[ -z "$prefix" && -z "$suffix" && -z "$pad" && -z "$color_name" ]]; then + return + fi + + # 1. Apply prefix/suffix + if [[ -n "$prefix" ]]; then + SEC_RAW="${prefix}${SEC_RAW}" + SEC_ANSI="${prefix}${SEC_ANSI}" + fi + if [[ -n "$suffix" ]]; then + SEC_RAW="${SEC_RAW}${suffix}" + SEC_ANSI="${SEC_ANSI}${suffix}" + fi + + # 2. Apply color override (re-wrap entire content) + if [[ -n "$color_name" ]]; then + local c + c="$(color_by_name "$color_name")" + SEC_ANSI="${c}${SEC_RAW}${C_RESET}" + fi + + # 3. Apply pad + align (spaces stay uncolored) + if [[ -n "$pad" ]] && (( pad > ${#SEC_RAW} )); then + local pad_needed=$(( pad - ${#SEC_RAW} )) + local pad_str="" + local p + for (( p = 0; p < pad_needed; p++ )); do pad_str+=" "; done + + case "${align:-left}" in + right) + SEC_RAW="${pad_str}${SEC_RAW}" + SEC_ANSI="${pad_str}${SEC_ANSI}" + ;; + center) + local left_pad=$(( pad_needed / 2 )) + local right_pad=$(( pad_needed - left_pad )) + local lpad="" rpad="" + for (( p = 0; p < left_pad; p++ )); do lpad+=" "; done + for (( p = 0; p < right_pad; p++ )); do rpad+=" "; done + SEC_RAW="${lpad}${SEC_RAW}${rpad}" + SEC_ANSI="${lpad}${SEC_ANSI}${rpad}" + ;; + *) # left (default) + SEC_RAW="${SEC_RAW}${pad_str}" + SEC_ANSI="${SEC_ANSI}${pad_str}" + ;; + esac + fi +} + +get_section_priority() { + local id="$1" + if is_spacer "$id"; then + echo "1" + elif is_builtin "$id"; then + section_priority "$id" + else + local idx + idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true + if [[ -n "$idx" ]]; then + jq -r ".custom[$idx].priority // 2" <<< "$CONFIG_JSON" + else + echo "2" + fi + fi +} + +is_flex_section() { + local id="$1" + if is_spacer "$id"; then + return 0 + elif is_builtin "$id"; then + local val + val="$(cfg ".sections.${id}.flex" "false")" + [[ "$val" == "true" ]] + else + local idx + idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true + if [[ -n "$idx" ]]; then + local val + val="$(jq -r ".custom[$idx].flex // false" <<< "$CONFIG_JSON")" + [[ "$val" == "true" ]] + else + return 1 + fi + fi +} + +# ── Layout Resolution ───────────────────────────────────────────────────────── + +resolve_layout() { + local layout_val + layout_val="$(jq -r '.layout' <<< "$CONFIG_JSON" 2>/dev/null)" || true + + if [[ -z "$layout_val" || "$layout_val" == "null" ]]; then + layout_val="standard" + fi + + # Check if it's a string (preset name) or array + local layout_type + layout_type="$(jq -r '.layout | type' <<< "$CONFIG_JSON" 2>/dev/null)" || true + + if [[ "$layout_type" == "string" ]]; then + # When responsive mode is enabled and layout is a preset name (not explicit array), + # use the responsive layout instead of the configured preset + local effective_layout="$layout_val" + if [[ -n "$RESPONSIVE_LAYOUT" ]]; then + effective_layout="$RESPONSIVE_LAYOUT" + fi + # Look up preset + jq -c ".presets[\"$effective_layout\"] // [[\"model\",\"project\"]]" <<< "$CONFIG_JSON" + else + # Explicit layout array provided — use it directly (ignore responsive) + jq -c '.layout' <<< "$CONFIG_JSON" + fi +} + +# ── Layout Engine ───────────────────────────────────────────────────────────── + +SEPARATOR="$(cfg '.global.separator' ' | ')" +SEP_LEN="${#SEPARATOR}" +JUSTIFY="$(cfg '.global.justify' 'left')" + +# Extract the visible "core" of the separator (non-space chars, e.g. "|" from " | ") +# Used as the anchor when building justified gaps +SEP_CORE="${SEPARATOR#"${SEPARATOR%%[! ]*}"}" # trim leading spaces +SEP_CORE="${SEP_CORE%"${SEP_CORE##*[! ]}"}" # trim trailing spaces +SEP_CORE_LEN="${#SEP_CORE}" +# If separator is pure spaces, core is empty — gaps will be pure space +if [[ -z "$SEP_CORE" ]]; then + SEP_CORE_LEN=0 +fi + +# ── Dump State Mode ─────────────────────────────────────────────────────────── +# Output all computed internal state as JSON for debugging + +if [[ "$CLI_MODE" == "dump-state" ]]; then + jq -n \ + --argjson term_width "$TERM_WIDTH" \ + --arg width_tier "$WIDTH_TIER" \ + --arg detected_theme "$DETECTED_THEME" \ + --arg vcs_type "$VCS_TYPE" \ + --arg responsive_layout "${RESPONSIVE_LAYOUT:-}" \ + --arg responsive_enabled "$RESPONSIVE" \ + --arg justify "$JUSTIFY" \ + --arg separator "$SEPARATOR" \ + --arg project_dir "$PROJECT_DIR" \ + --arg session_id "$SESSION_ID" \ + --arg cache_dir "$CACHE_DIR" \ + --arg config_path "$USER_CONFIG_PATH" \ + --arg defaults_path "$DEFAULTS_PATH" \ + --argjson width_margin "$WIDTH_MARGIN" \ + --argjson narrow_bp "${NARROW_BP:-60}" \ + --argjson medium_bp "${MEDIUM_BP:-100}" \ + --arg glyphs_enabled "$GLYPHS_ENABLED" \ + '{ + terminal: { + detected_width: ($term_width + $width_margin), + effective_width: $term_width, + width_margin: $width_margin, + width_tier: $width_tier + }, + responsive: { + enabled: ($responsive_enabled == "true"), + layout: (if $responsive_layout == "" then null else $responsive_layout end), + breakpoints: {narrow: $narrow_bp, medium: $medium_bp} + }, + theme: $detected_theme, + vcs: $vcs_type, + layout: { + justify: $justify, + separator: $separator + }, + glyphs_enabled: ($glyphs_enabled == "true"), + paths: { + project_dir: $project_dir, + config: $config_path, + defaults: $defaults_path, + cache_dir: $cache_dir + }, + session_id: $session_id + }' + exit 0 +fi + +render_line() { + local line_json="$1" + local section_ids=() + local raw_texts=() + local ansi_texts=() + local priorities=() + local flex_idx=-1 + + # Parse section IDs from JSON array + local count + count="$(jq 'length' <<< "$line_json")" + + local i + for (( i = 0; i < count; i++ )); do + local sid + sid="$(jq -r ".[$i]" <<< "$line_json")" + + render_section "$sid" + if [[ -z "$SEC_RAW" ]]; then + continue + fi + + section_ids+=("$sid") + raw_texts+=("$SEC_RAW") + ansi_texts+=("$SEC_ANSI") + priorities+=("$(get_section_priority "$sid")") + + if is_flex_section "$sid"; then + if (( flex_idx == -1 )); then + # First flex section found + flex_idx=$(( ${#section_ids[@]} - 1 )) + elif is_spacer "$sid" && ! is_spacer "${section_ids[$flex_idx]}"; then + # Spacer overrides a non-spacer flex section, but not another spacer + flex_idx=$(( ${#section_ids[@]} - 1 )) + fi + fi + done + + local n=${#section_ids[@]} + if (( n == 0 )); then + return + fi + + # If the only sections are spacers, skip the line + local non_spacer=0 + for (( i = 0; i < n; i++ )); do + if ! is_spacer "${section_ids[$i]}"; then + non_spacer=1 + break + fi + done + if (( non_spacer == 0 )); then + return + fi + + # Calculate total width using minimum separators + # Skips separator width when either adjacent section is a spacer + calc_width() { + local total=0 + local active=("$@") + local prev_idx=-1 + local idx + for idx in "${active[@]}"; do + if (( prev_idx >= 0 )); then + if ! is_spacer "${section_ids[$prev_idx]}" && ! is_spacer "${section_ids[$idx]}"; then + total=$(( total + SEP_LEN )) + fi + fi + total=$(( total + ${#raw_texts[$idx]} )) + prev_idx=$idx + done + echo "$total" + } + + # Calculate content-only width (no separators) + calc_content_width() { + local total=0 + local active=("$@") + local idx + for idx in "${active[@]}"; do + total=$(( total + ${#raw_texts[$idx]} )) + done + echo "$total" + } + + # Build active indices + local active_indices=() + for (( i = 0; i < n; i++ )); do + active_indices+=("$i") + done + + local total_width + total_width="$(calc_width "${active_indices[@]}")" + + # Priority drop: remove priority 3 from right + if (( total_width > TERM_WIDTH )); then + local new_active=() + for idx in "${active_indices[@]}"; do + if [[ "${priorities[$idx]}" != "3" ]]; then + new_active+=("$idx") + fi + done + active_indices=("${new_active[@]}") + total_width="$(calc_width "${active_indices[@]}")" + fi + + # Priority drop: remove priority 2 from right + if (( total_width > TERM_WIDTH )); then + local new_active=() + for idx in "${active_indices[@]}"; do + if [[ "${priorities[$idx]}" != "2" ]]; then + new_active+=("$idx") + fi + done + active_indices=("${new_active[@]}") + total_width="$(calc_width "${active_indices[@]}")" + fi + + local active_count=${#active_indices[@]} + + # ── Justify: spread / space-between ────────────────────────────────────── + # Distributes extra space into gaps between sections. + # "spread" — equal gaps everywhere (like CSS space-evenly) + # "space-between" — first/last flush to edges, equal gaps in between + # When justify is active, flex on individual sections is ignored. + + # Check if any active section is a spacer — if so, bypass justify + local has_spacer=0 + for idx in "${active_indices[@]}"; do + if is_spacer "${section_ids[$idx]}"; then + has_spacer=1 + break + fi + done + + if [[ "$JUSTIFY" != "left" ]] && (( has_spacer == 0 && active_count > 1 && total_width < TERM_WIDTH )); then + local content_width + content_width="$(calc_content_width "${active_indices[@]}")" + local num_gaps=$(( active_count - 1 )) + local available=$(( TERM_WIDTH - content_width )) + + # For space-between: all available space goes into the gaps + # For spread: conceptually N+1 slots, but since we can't pad before first + # or after last in a terminal line, we treat it as N gaps with equal size + # (effectively the same as space-between for terminal output) + local gap_width=$(( available / num_gaps )) + local gap_remainder=$(( available % num_gaps )) + + # Build gap separators: center the separator core in each gap + # E.g., gap_width=8 with core "|": " | " (3 left + 1 core + 4 right) + # Remainder chars get distributed one per gap from the left + + local gap_separators=() + local g + for (( g = 0; g < num_gaps; g++ )); do + local this_gap=$gap_width + # Distribute remainder: first gaps get +1 + if (( g < gap_remainder )); then + this_gap=$(( this_gap + 1 )) + fi + + local sep_str="" + if (( SEP_CORE_LEN > 0 )); then + local pad_total=$(( this_gap - SEP_CORE_LEN )) + if (( pad_total < 0 )); then pad_total=0; fi + local pad_left=$(( pad_total / 2 )) + local pad_right=$(( pad_total - pad_left )) + local lpad="" rpad="" + for (( i = 0; i < pad_left; i++ )); do lpad+=" "; done + for (( i = 0; i < pad_right; i++ )); do rpad+=" "; done + sep_str="${lpad}${SEP_CORE}${rpad}" + else + # No core char — pure space gap + for (( i = 0; i < this_gap; i++ )); do sep_str+=" "; done + fi + gap_separators+=("$sep_str") + done + + # Assemble justified output + local output="" + local gap_idx=0 + local first=1 + for idx in "${active_indices[@]}"; do + if (( first )); then + first=0 + else + output+="${C_DIM}${gap_separators[$gap_idx]}${C_RESET}" + gap_idx=$(( gap_idx + 1 )) + fi + output+="${ansi_texts[$idx]}" + done + + printf '%b' "$output" + return + fi + + # ── Left-aligned layout with optional flex expansion ───────────────────── + + if (( flex_idx >= 0 && total_width < TERM_WIDTH )); then + # Check if flex section is still active + local flex_active=0 + for idx in "${active_indices[@]}"; do + if (( idx == flex_idx )); then + flex_active=1 + break + fi + done + + if (( flex_active )); then + local extra=$(( TERM_WIDTH - total_width )) + local old_raw="${raw_texts[$flex_idx]}" + local old_ansi="${ansi_texts[$flex_idx]}" + + # For spacers, replace placeholder with pure spaces + if is_spacer "${section_ids[$flex_idx]}"; then + local padding="" + for (( i = 0; i < extra + 1; i++ )); do padding+=" "; done + raw_texts[$flex_idx]="$padding" + ansi_texts[$flex_idx]="$padding" + elif [[ "${section_ids[$flex_idx]}" == "context_bar" ]]; then + local pct + pct="$(inp '.context_window.used_percentage' '0')" + local pct_int + pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)" + + # Use config bar_width directly (not derived from raw text length, + # which may include formatting prefix/suffix/pad) + local cur_bar_width + cur_bar_width="$(cfg '.sections.context_bar.bar_width' '10')" + local new_bar_width=$(( cur_bar_width + extra )) + (( new_bar_width < 3 )) && new_bar_width=3 + + local filled=$(( pct_int * new_bar_width / 100 )) + local empty_count=$(( new_bar_width - filled )) + + local bar="" + for (( i = 0; i < filled; i++ )); do bar+="="; done + for (( i = 0; i < empty_count; i++ )); do bar+="-"; done + + raw_texts[$flex_idx]="[${bar}] ${pct_int}%" + + local warn danger critical + warn="$(cfg '.sections.context_bar.thresholds.warn' '50')" + danger="$(cfg '.sections.context_bar.thresholds.danger' '70')" + critical="$(cfg '.sections.context_bar.thresholds.critical' '85')" + + local color="$C_GREEN" + if (( pct_int >= critical )); then + color="${C_RED}${C_BOLD}" + elif (( pct_int >= danger )); then + color="$C_RED" + elif (( pct_int >= warn )); then + color="$C_YELLOW" + fi + + ansi_texts[$flex_idx]="${color}[${bar}] ${pct_int}%${C_RESET}" + + # Re-apply per-section formatting (prefix/suffix/color/pad) since + # the flex rebuild replaced the formatted output from scratch + SEC_RAW="${raw_texts[$flex_idx]}" + SEC_ANSI="${ansi_texts[$flex_idx]}" + apply_formatting "context_bar" + raw_texts[$flex_idx]="$SEC_RAW" + ansi_texts[$flex_idx]="$SEC_ANSI" + else + # Generic flex: pad with spaces + local padding="" + for (( i = 0; i < extra; i++ )); do padding+=" "; done + raw_texts[$flex_idx]="${old_raw}${padding}" + ansi_texts[$flex_idx]="${old_ansi}${padding}" + fi + fi + fi + + # Assemble left-aligned output + local output="" + local prev_asm_idx=-1 + for idx in "${active_indices[@]}"; do + if (( prev_asm_idx >= 0 )); then + # Suppress separator when either side is a spacer + if ! is_spacer "${section_ids[$prev_asm_idx]}" && ! is_spacer "${section_ids[$idx]}"; then + output+="${C_DIM}${SEPARATOR}${C_RESET}" + fi + fi + output+="${ansi_texts[$idx]}" + prev_asm_idx=$idx + done + + printf '%b' "$output" +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +main() { + local layout_json + layout_json="$(resolve_layout)" + + local line_count + line_count="$(jq 'length' <<< "$layout_json")" + + local lines=() + local i + for (( i = 0; i < line_count; i++ )); do + local line_def + line_def="$(jq -c ".[$i]" <<< "$layout_json")" + local rendered + rendered="$(render_line "$line_def")" + if [[ -n "$rendered" ]]; then + lines+=("$rendered") + fi + done + + # Output lines separated by newlines + local first=1 + for line in "${lines[@]}"; do + if (( first )); then + first=0 + else + printf '\n' + fi + printf '%b' "$line" + done +} + +main