feat: complete Rust port of claude-statusline
Port the entire 2236-line bash statusline script to Rust. Implements all 25 sections, 3-phase layout engine (render, priority drop, flex/justify), file-based caching with flock, 9-level terminal width detection, trend sparklines, and deep-merge JSON config. Release binary: 864K with LTO. Render time: <1ms warm. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
.beads/.gitignore
vendored
Normal file
11
.beads/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Lock files
|
||||||
|
*.lock
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
last-touched
|
||||||
|
*.tmp
|
||||||
4
.beads/config.yaml
Normal file
4
.beads/config.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Beads Project Configuration
|
||||||
|
# issue_prefix: bd
|
||||||
|
# default_priority: 2
|
||||||
|
# default_type: task
|
||||||
23
.beads/issues.jsonl
Normal file
23
.beads/issues.jsonl
Normal file
@@ -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<io::Error> and From<serde_json::Error>.","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<SectionOutput>.","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<Option<(u16, Instant)>>. 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"}]}
|
||||||
4
.beads/metadata.json
Normal file
4
.beads/metadata.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"database": "beads.db",
|
||||||
|
"jsonl_export": "issues.jsonl"
|
||||||
|
}
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# bv (beads viewer) local config and caches
|
||||||
|
.bv/
|
||||||
|
target/
|
||||||
722
AGENTS.md
Normal file
722
AGENTS.md
Normal file
@@ -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=<abs-path>)
|
||||||
|
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=<abs-path>&limit=20
|
||||||
|
resource://thread/{id}?project=<abs-path>&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: <title>", 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)
|
||||||
663
Cargo.lock
generated
Normal file
663
Cargo.lock
generated
Normal file
@@ -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"
|
||||||
37
Cargo.toml
Normal file
37
Cargo.toml
Normal file
@@ -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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||||
801
README.md
Normal file
801
README.md
Normal file
@@ -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
|
||||||
8
benches/render.rs
Normal file
8
benches/render.rs
Normal file
@@ -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);
|
||||||
4
build.rs
Normal file
4
build.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=defaults.json");
|
||||||
|
println!("cargo:rerun-if-changed=schema.json");
|
||||||
|
}
|
||||||
271
defaults.json
Normal file
271
defaults.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
31
examples/custom-commands.json
Normal file
31
examples/custom-commands.json
Normal file
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
examples/dense.json
Normal file
10
examples/dense.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"layout": "dense",
|
||||||
|
"sections": {
|
||||||
|
"context_bar": {
|
||||||
|
"enabled": true, "priority": 1, "flex": true, "min_width": 10,
|
||||||
|
"bar_width": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
examples/user-config.json
Normal file
23
examples/user-config.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
examples/verbose.json
Normal file
12
examples/verbose.json
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
75
install.sh
Executable file
75
install.sh
Executable file
@@ -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."
|
||||||
3091
rust_prd.md
Normal file
3091
rust_prd.md
Normal file
File diff suppressed because it is too large
Load Diff
841
schema.json
Normal file
841
schema.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
288
src/bin/claude-statusline.rs
Normal file
288
src/bin/claude-statusline.rs
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/cache.rs
Normal file
154
src/cache.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
76
src/color.rs
Normal file
76
src/color.rs
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
721
src/config.rs
Normal file
721
src/config.rs
Normal file
@@ -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)
|
||||||
|
}
|
||||||
35
src/error.rs
Normal file
35
src/error.rs
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/format.rs
Normal file
177
src/format.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/glyph.rs
Normal file
13
src/glyph.rs
Normal file
@@ -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("")
|
||||||
|
}
|
||||||
60
src/input.rs
Normal file
60
src/input.rs
Normal file
@@ -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>,
|
||||||
|
}
|
||||||
82
src/layout/flex.rs
Normal file
82
src/layout/flex.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
63
src/layout/justify.rs
Normal file
63
src/layout/justify.rs
Normal file
@@ -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))
|
||||||
|
}
|
||||||
177
src/layout/mod.rs
Normal file
177
src/layout/mod.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/layout/priority.rs
Normal file
43
src/layout/priority.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
15
src/lib.rs
Normal file
15
src/lib.rs
Normal file
@@ -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;
|
||||||
47
src/metrics.rs
Normal file
47
src/metrics.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/section/beads.rs
Normal file
48
src/section/beads.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
29
src/section/cache_efficiency.rs
Normal file
29
src/section/cache_efficiency.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
45
src/section/context_bar.rs
Normal file
45
src/section/context_bar.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/section/context_remaining.rs
Normal file
43
src/section/context_remaining.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
47
src/section/context_trend.rs
Normal file
47
src/section/context_trend.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
49
src/section/context_usage.rs
Normal file
49
src/section/context_usage.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/section/cost.rs
Normal file
39
src/section/cost.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
36
src/section/cost_trend.rs
Normal file
36
src/section/cost_trend.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
19
src/section/cost_velocity.rs
Normal file
19
src/section/cost_velocity.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
66
src/section/custom.rs
Normal file
66
src/section/custom.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
23
src/section/duration.rs
Normal file
23
src/section/duration.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
34
src/section/hostname.rs
Normal file
34
src/section/hostname.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
32
src/section/lines_changed.rs
Normal file
32
src/section/lines_changed.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
53
src/section/load.rs
Normal file
53
src/section/load.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
118
src/section/mod.rs
Normal file
118
src/section/mod.rs
Normal file
@@ -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")
|
||||||
|
}
|
||||||
68
src/section/model.rs
Normal file
68
src/section/model.rs
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
19
src/section/output_style.rs
Normal file
19
src/section/output_style.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
36
src/section/project.rs
Normal file
36
src/section/project.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
35
src/section/provider.rs
Normal file
35
src/section/provider.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
37
src/section/time.rs
Normal file
37
src/section/time.rs
Normal file
@@ -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))
|
||||||
|
}
|
||||||
20
src/section/token_velocity.rs
Normal file
20
src/section/token_velocity.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
33
src/section/tokens_raw.rs
Normal file
33
src/section/tokens_raw.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
33
src/section/tools.rs
Normal file
33
src/section/tools.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
24
src/section/turns.rs
Normal file
24
src/section/turns.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
166
src/section/vcs.rs
Normal file
166
src/section/vcs.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
19
src/section/version.rs
Normal file
19
src/section/version.rs
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
89
src/shell.rs
Normal file
89
src/shell.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
42
src/theme.rs
Normal file
42
src/theme.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
87
src/trend.rs
Normal file
87
src/trend.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
199
src/width.rs
Normal file
199
src/width.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
2245
statusline.sh
Executable file
2245
statusline.sh
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user