Compare commits

...

10 Commits

Author SHA1 Message Date
Taylor Eernisse
e5b18b17ff chore: remove completed PRD and superseded bash implementation
rust_prd.md (3,091 lines):
  The Rust port PRD has been fully implemented — all beads closed,
  all features delivered across 7 prior commits. Keeping the PRD
  in-repo adds confusion about what's aspirational vs actual.

statusline.sh (2,245 lines):
  The original bash implementation is fully superseded by the Rust
  binary (claude-statusline). The bash script had fundamental
  limitations: ~200ms render time, no caching, no gradient support,
  no per-tool breakdown, and fragile process-tree width detection.
  The Rust binary renders in <5ms with full feature parity and then
  some. install.sh now targets the Rust binary exclusively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:43:12 -05:00
Taylor Eernisse
8853afffa1 feat: update defaults, schema, and installer for new capabilities
Configuration and deployment updates to match the new feature set.

defaults.json:
  - Dark theme palette switched from named ANSI to Dracula-inspired hex:
    success=#50fa7b, warning=#f1fa8c, danger=#ff5555, accent=#8be9fd,
    info=#bd93f9. Light theme unchanged (named ANSI with bold).
  - Glyph characters normalized to Unicode escapes (clean/ahead/behind).
  - Verbose layout (3 lines) reorganized:
    Line 1: model, provider, spacer, lines_changed, project, vcs, beads
    Line 2: context_bar, context_usage, cache_efficiency, spacer, cost,
             cost_velocity, cost_trend, duration
    Line 3: context_trend, tokens_raw, spacer, tools, turns, load,
             cloud_profile, k8s_context, python_env, toolchain
  - context_usage, cost_trend, context_trend now enabled by default.
  - Trend widths increased from 8 to 12 characters.
  - Context bar: bar_style=block, gradient=true.
  - Tools: show_breakdown=true, top_n=7.
  - New sections enabled: cloud_profile (P2), k8s_context (P3, ttl=30),
    python_env (P3), toolchain (P3).

schema.json:
  - Added $schema self-reference field for editor autocomplete.
  - Expanded colorName from fixed enum to freeform string with docs
    covering hex, bg:, modifiers, and palette refs.
  - New section schemas: trendSection, contextTrendSection with width,
    gradient, and threshold properties.
  - context_bar: added bar_style, gradient, fill_char, empty_char.
  - tools: added show_breakdown, top_n, palette array.
  - All section types: added background and placeholder properties.
  - Width description updated to clarify it's a max cap + fallback,
    not an override.
  - colorMatch additionalProperties relaxed from enum to string.

install.sh:
  Complete rewrite for Rust binary workflow:
  - Checks cargo and jq prerequisites.
  - Builds release binary via cargo build --release.
  - Installs to ~/.local/bin/claude-statusline with PATH check.
  - Creates/updates ~/.claude/settings.json with statusLine command
    (type: "command", --color=always, padding: 0).
  - Symlinks statusline.json config if present in project.
  - Removed all bash-script-era logic (symlinks, bash version checks).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:42:58 -05:00
Taylor Eernisse
c03b0b1bd7 feat: add environment sections, visual enhancements, enhanced tools/beads, and /clear detection
The feature layer that builds on the new infrastructure modules. Adds
4 new environment-aware sections, rewrites the tools/beads/turns sections,
introduces gradient sparklines and block-style context bars, and wires
/clear detection into the main binary.

New sections (4):
  cloud_profile — Shows active cloud provider profile from env vars
    ($AWS_PROFILE, $CLOUDSDK_CORE_PROJECT, $AZURE_SUBSCRIPTION_ID).
    Provider-specific coloring (AWS orange, GCP blue, Azure blue).

  k8s_context — Parses kubeconfig for current-context and namespace.
    Minimal YAML scanning (no yaml dependency). 30s TTL cache.
    Shows "context/namespace" with split coloring.

  python_env — Detects active virtualenv ($VIRTUAL_ENV) or conda
    ($CONDA_DEFAULT_ENV, excluding "base"). Shows just the env name.

  toolchain — Detects Rust (rust-toolchain.toml) and Node.js (.nvmrc,
    .node-version) versions. Compares expected vs actual ($RUSTUP_TOOLCHAIN,
    $NODE_VERSION) and highlights mismatches in yellow.

Tools section rewrite:
  Progressive disclosure based on terminal width:
    - Narrow: just the count ("245")
    - Medium: count + last tool name ("245 tools (Bash)")
    - Wide: per-tool color-coded breakdown ("245 tools (Bash: 84/Read: 35/...)")
  Adaptive width budgeting: breakdown reduces tool count until it fits
  within 1/3 of terminal width. Color palette priority: config > terminal
  ANSI palette (via OSC 4) > built-in Dracula palette.

Beads section rewrite:
  Switched from `br ready --json` to `br stats --json` to show all
  statuses. Now renders multi-status breakdown: "3 ready 1 wip 2 open"
  with per-status visibility toggles in config.

Turns section:
  Falls back to transcript-derived turn count when cost.total_turns is
  absent. Requires at least one data source to render (vanishes when
  no session data exists at all).

Visual enhancements:
  trend.rs:
    - append_delta(): tracks rate-of-change (delta between cumulative
      samples) so sparklines show burn intensity, not monotonic growth
    - sparkline(): now renders exactly `width` chars with left-padding
      for missing data. Baseline (space) vs flatline (lowest bar) chars.
    - sparkline_colored(): per-character gradient coloring via colorgrad,
      returns (raw, ansi) tuple for layout compatibility.

  context_bar.rs:
    - Block style: Unicode full-block fill + light-shade empty chars
    - Per-character green->yellow->red gradient for block style
    - Classic style preserved (= and - chars) with single threshold color
    - Configurable fill_char/empty_char overrides

  context_trend + cost_trend:
    Switched to append_delta for rate-based sparklines. Gradient coloring
    with green->yellow->red via sparkline_colored().

  format.rs:
    Background color support via resolve_background(). Accepts named
    colors, hex, and palette refs. Applied as ANSI bg wrap around section
    output, preserving foreground colors.

  layout/mod.rs:
    - Separator styles: text (default), powerline (Nerd Font), arrow
      (Unicode), none (spaces). Powerline auto-falls-back to arrow when
      glyphs disabled.
    - Placeholder support: when an enabled section returns None (no data),
      substitutes a configurable placeholder character (default: box-draw)
      to maintain layout stability during justify/flex.

Section refinements:
  cost, cost_velocity, token_velocity, duration, tokens_raw — now show
  zero/baseline values instead of hiding entirely. This prevents layout
  jumps when sessions start or after /clear.

  context_usage — uses current_usage fields (input_tokens +
  cache_creation + cache_read) for precise token counts instead of
  percentage-derived estimates. Shows one decimal place on percentage.

  metrics.rs — prefers total_api_duration_ms over total_duration_ms for
  velocity calculations (active processing vs wall clock with idle time).
  Cache efficiency now divides by total input (not just cache tokens).

Config additions (config.rs):
  SeparatorStyle enum (text/powerline/arrow/none), BarStyle enum
  (classic/block), gradient toggle on trends + context_bar, background
  and placeholder on SectionBase, tools breakdown config (show_breakdown,
  top_n, palette), 4 new section structs.

Main binary (/clear detection + wiring):
  detect_clear() — watches for significant context usage drops (>15%
  to <5%, >20pp drop) to identify /clear. On detection: saves transcript
  offset so derived stats only count post-clear entries, flushes trend
  caches for fresh sparklines.

  resolve_transcript_stats() — cached transcript parsing with 5s TTL,
  respects clear offset, skipped when cost already has tool counts.

  resolve_terminal_palette() — cached palette detection with 1h TTL.

  Debug: CLAUDE_STATUSLINE_DEBUG env var dumps raw input JSON to
  /tmp/claude-statusline-input.json. dump-state now includes input data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:42:34 -05:00
Taylor Eernisse
e0c4a0fa9a feat: add colorgrad, transcript parser, terminal palette detection, and expanded color/input systems
Infrastructure layer for the TUI visual overhaul. Introduces foundational
modules and capabilities that the section-level features build on:

colorgrad (0.7) dependency:
  OKLab gradient interpolation for per-character color transitions in
  sparklines and context bars. Adds ~100K to binary (929K -> 1.0M).

color.rs expansion:
  - parse_hex(): #RRGGBB and #RGB -> (u8, u8, u8) conversion
  - fg_rgb()/bg_rgb(): 24-bit true-color ANSI escape generation
  - gradient_fg(): two-point interpolation via colorgrad
  - make_gradient()/sample_fg(): multi-stop gradient construction and sampling
  - resolve_color() now supports: hex (#FF6B35), bg:color, bg:#hex,
    italic, underline, strikethrough, and palette refs (p:success)
  - Named background constants (BG_RED through BG_WHITE)

transcript.rs (new module):
  Parses Claude Code transcript JSONL files to derive tool use counts,
  turn counts, and per-tool breakdowns. Claude Code doesn't include
  total_tool_uses or total_turns in its JSON — we compute them by scanning
  the transcript. Includes compact cache serialization format and
  skip_lines support for /clear offset handling.

terminal.rs (new module):
  Auto-detects the terminal's ANSI color palette for theme-aware tool
  coloring. Priority chain: WezTerm config > Kitty config > Alacritty
  config > OSC 4 escape sequence query. Parses Lua (WezTerm), key-value
  (Kitty), and TOML/YAML (Alacritty) config formats. OSC 4 queries
  use raw /dev/tty I/O with termios to avoid pipe interference. Includes
  cache serialization helpers for 1-hour TTL caching.

input.rs updates:
  - All structs now derive Serialize (for --dump-state diagnostics)
  - New fields: transcript_path, session_id, cwd, vim.mode, agent.name,
    exceeds_200k_tokens, cost.total_api_duration_ms
  - CurrentUsage: added input_tokens and output_tokens fields
  - #[serde(flatten)] extras on InputData and CostInfo for forward compat

cache.rs:
  Added flush_prefix() for /clear detection — removes all cache entries
  matching a key prefix (e.g., "trend_" to reset all sparkline history).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:41:50 -05:00
Taylor Eernisse
f46c3da69c fix: width detection prioritizes dynamic sources over config
Config `width` was position #3 in the detection chain, overriding all
dynamic detection (ioctl, process tree, stty, etc). This meant the
statusline couldn't adapt to terminal/pane resizes.

Now config `width` serves two roles:
- Max cap on dynamically detected widths (prevents absurd widths)
- Fallback when all dynamic detection methods fail

Also adds:
- ioctl on stderr (works when stdout is piped)
- stdin JSON `terminal_width` field for Claude Code to pass width
- Distinct diagnostic sources: config_cap, config_fallback, stdin_json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:18:51 -05:00
Taylor Eernisse
4e38b8259b fix: 6 bugs found during fresh-eyes code review
1. cache: record diagnostic miss before early-return on modified()/duration_since() failure
2. dump-state: resolve all cache_dir template vars ({cache_version}, {config_hash})
3. config: remove dead breakpoint_hysteresis field from GlobalConfig (breakpoints.hysteresis is used)
4. config: align Rust Default cache_dir with defaults.json template
5. vcs: apply branch truncation in render_stale_cache (--no-shell path)
6. vcs: fix jj prefetch retry on already-failed command (flatten→unwrap_or_else)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 16:11:02 -05:00
Taylor Eernisse
4c9139ec42 feat: implement remaining PRD features (10 beads)
Complete the PRD feature set with shell gating pipeline, cache
improvements, layout enhancements, and diagnostics:

- Shell: exec_gated with allowlist/denylist, circuit breaker, env merge
- Shell: parallel prefetch via std::thread::scope for cold renders
- Cache: TTL jitter (FNV-1a), config hash namespace, garbage collection
- Cache: diagnostic tracking (hit/miss, age) for dump-state
- Layout: gradual drop strategy (one-by-one vs tiered)
- Layout: render budget timer with graceful priority-based degradation
- Layout: breakpoint hysteresis to prevent preset toggling
- Width: detection source tracking for diagnostics
- CLI: --no-cache, --no-shell, --clear-cache, env var overrides
- Diagnostics: enhanced --dump-state with section timing and cache stats

Closes: bd-3oy, bd-62g, bd-khk, bd-3q1, bd-ywx, bd-3l2,
        bd-2vm, bd-1if, bd-2qr, bd-30o, bd-3ax, bd-3uw, bd-4b1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:59:15 -05:00
Taylor Eernisse
73401beb47 chore: enrich 6 beads with agent-ready descriptions
Revised bd-3uw, bd-ywx, bd-62g, bd-4b1, bd-1if, bd-3ax from
score 3/5 to 4+/5 with concrete approach steps, code snippets,
TDD loops, and edge cases sourced from rust_prd.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:25:03 -05:00
Taylor Eernisse
50bc2d989e fix: 3 bugs from peer code review
- justify: gate DIM/RESET separator coloring on color_enabled param;
  previously leaked ANSI escapes when --color=never or NO_COLOR was set
- vcs: apply branch name truncation per config (truncate.enabled,
  truncate.max, truncate.style) for both git and jj renderers;
  previously ignored truncate config causing long branch names to
  overflow and trigger unnecessary priority drops
- flex: account for prefix/suffix overhead when computing context_bar
  flex expansion width; previously double-applied apply_formatting
  causing line to overshoot term_width when prefix/suffix configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:52:23 -05:00
Taylor Eernisse
9c24617642 fix: 4 bugs found during code review against PRD
- output_style: use MAGENTA color per PRD (was incorrectly DIM)
- vcs: pass project dir to jj exec (was None, causing wrong cwd)
- tools: singular "tool" when count is 1 (was always "tools")
- layout: wire up apply_formatting() in render pipeline (prefix,
  suffix, color override, pad, align were completely dead code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:28:50 -05:00
47 changed files with 3931 additions and 5681 deletions

File diff suppressed because one or more lines are too long

82
Cargo.lock generated
View File

@@ -112,6 +112,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
name = "claude-statusline"
version = "0.1.0"
dependencies = [
"colorgrad",
"criterion",
"libc",
"md-5",
@@ -123,6 +124,15 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "colorgrad"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faedab4fd8670120c2be7f49225fbdb8b6db6d46f04ce4f864b1f1cdd55e6400"
dependencies = [
"csscolorparser",
]
[[package]]
name = "criterion"
version = "0.5.1"
@@ -200,6 +210,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "csscolorparser"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16"
dependencies = [
"phf",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -322,6 +341,48 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "plotters"
version = "0.3.7"
@@ -368,6 +429,21 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rayon"
version = "1.11.0"
@@ -496,6 +572,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]]
name = "syn"
version = "2.0.114"

View File

@@ -23,6 +23,7 @@ unicode-segmentation = "1"
libc = "0.2"
serde_path_to_error = "0.1"
serde_ignored = "0.1"
colorgrad = "0.7"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

View File

@@ -2,9 +2,10 @@
"version": 1,
"global": {
"separator": " | ",
"separator_style": "text",
"justify": "space-between",
"vcs": "auto",
"cache_dir": "/tmp/claude-sl-{session_id}",
"cache_dir": "/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}",
"responsive": true,
"breakpoints": {
"narrow": 60,
@@ -14,14 +15,14 @@
},
"colors": {
"dark": {
"success": "green",
"warning": "yellow",
"danger": "red",
"critical": "red bold",
"success": "#50fa7b",
"warning": "#f1fa8c",
"danger": "#ff5555",
"critical": "#ff5555 bold",
"muted": "dim",
"accent": "cyan",
"accent": "#8be9fd",
"highlight": "bold",
"info": "blue"
"info": "#bd93f9"
},
"light": {
"success": "green bold",
@@ -41,9 +42,9 @@
"separator_alt": "",
"branch": "",
"dirty": "*",
"clean": "",
"ahead": "",
"behind": "",
"clean": "\u2713",
"ahead": "\u2191",
"behind": "\u2193",
"folder": "",
"clock": "",
"dollar": ""
@@ -93,24 +94,33 @@
[
"model",
"provider",
"spacer",
"lines_changed",
"project",
"vcs",
"beads"
],
[
"context_bar",
"tokens_raw",
"context_usage",
"cache_efficiency",
"spacer",
"cost",
"cost_velocity"
"cost_velocity",
"cost_trend",
"duration"
],
[
"lines_changed",
"duration",
"context_trend",
"tokens_raw",
"spacer",
"tools",
"turns",
"load",
"version"
"cloud_profile",
"k8s_context",
"python_env",
"toolchain"
]
]
},
@@ -167,6 +177,8 @@
"flex": true,
"min_width": 15,
"bar_width": 10,
"bar_style": "block",
"gradient": true,
"thresholds": {
"warn": 50,
"danger": 70,
@@ -174,7 +186,7 @@
}
},
"context_usage": {
"enabled": false,
"enabled": true,
"priority": 2,
"capacity": 200000,
"thresholds": {
@@ -210,14 +222,16 @@
"priority": 3
},
"cost_trend": {
"enabled": false,
"enabled": true,
"priority": 3,
"width": 8
"width": 12,
"gradient": true
},
"context_trend": {
"enabled": false,
"enabled": true,
"priority": 3,
"width": 8,
"width": 12,
"gradient": true,
"thresholds": {
"warn": 50,
"danger": 70,
@@ -237,6 +251,8 @@
"priority": 2,
"min_width": 6,
"show_last_name": true,
"show_breakdown": true,
"top_n": 7,
"ttl": 2
},
"turns": {
@@ -265,6 +281,23 @@
"hostname": {
"enabled": true,
"priority": 3
},
"cloud_profile": {
"enabled": true,
"priority": 2
},
"k8s_context": {
"enabled": true,
"priority": 3,
"ttl": 30
},
"python_env": {
"enabled": true,
"priority": 3
},
"toolchain": {
"enabled": true,
"priority": 3
}
},
"custom": []

View File

@@ -1,75 +1,109 @@
#!/usr/bin/env bash
# install.sh — Set up claude-statusline symlinks
# install.sh — Build and install claude-statusline (Rust binary)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="$HOME/.local/bin"
BINARY_NAME="claude-statusline"
CLAUDE_DIR="$HOME/.claude"
SETTINGS="$CLAUDE_DIR/settings.json"
echo "claude-statusline installer"
echo "==========================="
echo ""
# Check dependencies
# ── Check toolchain ──────────────────────────────────────────────────
if ! command -v cargo &>/dev/null; then
echo "ERROR: cargo (Rust toolchain) is required but not installed."
echo " Install via: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
exit 1
fi
echo "[ok] cargo found ($(cargo --version))"
if ! command -v jq &>/dev/null; then
echo "ERROR: jq is required but not installed."
echo "ERROR: jq is required for settings.json updates."
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"
# ── Build release binary ─────────────────────────────────────────────
echo ""
echo "Building release binary..."
(cd "$SCRIPT_DIR" && cargo build --release --quiet)
echo "[ok] Built: $(ls -lh "$SCRIPT_DIR/target/release/$BINARY_NAME" | awk '{print $5}')"
# ── Install binary ───────────────────────────────────────────────────
mkdir -p "$INSTALL_DIR"
cp "$SCRIPT_DIR/target/release/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME"
chmod +x "$INSTALL_DIR/$BINARY_NAME"
echo "[ok] Installed to $INSTALL_DIR/$BINARY_NAME"
# Verify it's on PATH
if ! command -v "$BINARY_NAME" &>/dev/null; then
echo "[warn] $INSTALL_DIR is not on your PATH"
echo " Add to your shell config: export PATH=\"$INSTALL_DIR:\$PATH\""
fi
# Ensure ~/.claude exists
# ── Configure Claude Code settings.json ──────────────────────────────
echo ""
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
BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
# The binary runs in a non-TTY context, so force color on.
STATUSLINE_CMD="$BINARY_PATH --color=always"
if [[ -f "$SETTINGS" ]]; then
# Update existing settings.json
CURRENT_CMD=$(jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true)
if [[ -n "$CURRENT_CMD" ]]; then
echo "[info] Current statusLine command: $CURRENT_CMD"
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
# Write updated settings
TMP="$SETTINGS.tmp.$$"
jq --arg cmd "$STATUSLINE_CMD" '.statusLine = {"type": "command", "command": $cmd, "padding": 0}' "$SETTINGS" > "$TMP"
mv "$TMP" "$SETTINGS"
echo "[ok] Updated statusLine in $SETTINGS"
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/"
# Create minimal settings.json
jq -n --arg cmd "$STATUSLINE_CMD" '{"statusLine": {"type": "command", "command": $cmd, "padding": 0}}' > "$SETTINGS"
echo "[ok] Created $SETTINGS"
fi
# ── Symlink config ───────────────────────────────────────────────────
CONFIG_SRC="$SCRIPT_DIR/statusline.json"
CONFIG_DST="$CLAUDE_DIR/statusline.json"
if [[ -f "$CONFIG_SRC" ]]; then
if [[ -L "$CONFIG_DST" ]]; then
EXISTING="$(readlink "$CONFIG_DST")"
if [[ "$EXISTING" == "$CONFIG_SRC" ]]; then
echo "[ok] Config already linked"
else
ln -sf "$CONFIG_SRC" "$CONFIG_DST"
echo "[ok] Config symlink updated (was: $EXISTING)"
fi
elif [[ -f "$CONFIG_DST" ]]; then
echo "[skip] $CONFIG_DST exists as a regular file"
echo " To use the symlink, remove it first: rm $CONFIG_DST"
else
ln -s "$CONFIG_SRC" "$CONFIG_DST"
echo "[ok] Config linked: $CONFIG_DST -> $CONFIG_SRC"
fi
else
echo "[info] No statusline.json in project. To customize, create:"
echo " $CONFIG_SRC"
echo ""
echo "Symlinks created. Now add the status line to your Claude Code settings."
echo " Print defaults: $BINARY_NAME --print-defaults"
echo " Print schema: $BINARY_NAME --config-schema"
fi
# ── Done ─────────────────────────────────────────────────────────────
echo ""
echo "Add this to ~/.claude/settings.json:"
echo "Done. Restart Claude Code to see the status line."
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."
echo "Quick test: $BINARY_NAME --test --color=always"
echo "Debug: $BINARY_NAME --test --dump-state=json"

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,12 @@
"title": "Claude Code Status Line Configuration",
"description": "Configuration for the claude-statusline status line script",
"type": "object",
"required": [
"version"
],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for editor support"
},
"version": {
"type": "integer",
"const": 1,
@@ -77,6 +78,15 @@
"token_velocity": {
"$ref": "#/$defs/basicSection"
},
"cost_trend": {
"$ref": "#/$defs/trendSection"
},
"context_trend": {
"$ref": "#/$defs/contextTrendSection"
},
"context_remaining": {
"$ref": "#/$defs/basicSection"
},
"lines_changed": {
"$ref": "#/$defs/basicSection"
},
@@ -154,9 +164,15 @@
"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.",
"description": "Separator text between sections (used when separator_style is 'text'). When justify is 'spread' or 'space-between', the non-space characters are kept as visual anchors.",
"default": " | "
},
"separator_style": {
"type": "string",
"enum": ["text", "powerline", "arrow", "none"],
"description": "Separator style between sections. 'text': use the separator string. 'powerline': Nerd Font triangle (auto-falls-back to arrow if glyphs disabled). 'arrow': Unicode heavy angle quotation mark. 'none': spaces only.",
"default": "text"
},
"justify": {
"type": "string",
"enum": [
@@ -181,7 +197,7 @@
"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."
"description": "Maximum terminal width cap and fallback. Dynamic sources (ioctl, process tree, stty) take priority when available."
},
"width_margin": {
"type": "integer",
@@ -227,22 +243,11 @@
},
"colorName": {
"type": "string",
"enum": [
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"dim",
"bold"
],
"description": "Named ANSI color"
"description": "Color specifier. Supports: named colors (red, green, yellow, blue, magenta, cyan, white), modifiers (dim, bold, italic, underline, strikethrough), hex (#FF6B35, #F00), backgrounds (bg:red, bg:#FF6B35), palette refs (p:success). Multiple can be space-separated (e.g., '#FF6B35 bold')."
},
"colorPalette": {
"type": "object",
"description": "Semantic color palette for a theme",
"description": "Semantic color palette for a theme. Values support all colorName formats.",
"properties": {
"success": { "type": "string" },
"warning": { "type": "string" },
@@ -343,6 +348,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -415,6 +428,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -475,6 +496,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -503,6 +532,25 @@
"minimum": 3,
"default": 10
},
"bar_style": {
"type": "string",
"enum": ["classic", "block"],
"default": "block",
"description": "Bar rendering style. 'classic': = and - characters. 'block': Unicode block characters with optional gradient."
},
"gradient": {
"type": "boolean",
"default": true,
"description": "Enable per-character gradient coloring (green to yellow to red). Only applies when bar_style is 'block'."
},
"fill_char": {
"type": "string",
"description": "Override character for filled portion. Default: '=' (classic) or full block (block)."
},
"empty_char": {
"type": "string",
"description": "Override character for empty portion. Default: '-' (classic) or light shade (block)."
},
"thresholds": {
"$ref": "#/$defs/thresholds"
},
@@ -523,6 +571,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -563,6 +619,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -599,6 +663,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -633,6 +705,135 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
},
"trendSection": {
"type": "object",
"description": "Sparkline trend visualization",
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"priority": {
"$ref": "#/$defs/priority"
},
"width": {
"type": "integer",
"minimum": 3,
"default": 8,
"description": "Number of sparkline characters"
},
"gradient": {
"type": "boolean",
"default": true,
"description": "Enable per-character gradient coloring based on value"
},
"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"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
},
"contextTrendSection": {
"type": "object",
"description": "Context usage sparkline trend with threshold coloring",
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"priority": {
"$ref": "#/$defs/priority"
},
"width": {
"type": "integer",
"minimum": 3,
"default": 8,
"description": "Number of sparkline characters"
},
"gradient": {
"type": "boolean",
"default": true,
"description": "Enable per-character gradient coloring based on value"
},
"thresholds": {
"$ref": "#/$defs/thresholds"
},
"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"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -656,6 +857,26 @@
"type": "boolean",
"default": true
},
"show_breakdown": {
"type": "boolean",
"description": "Show per-tool breakdown in wide terminals (e.g. Bash: 84/Read: 35/Edit: 34)",
"default": true
},
"top_n": {
"type": "integer",
"minimum": 0,
"description": "Max number of tools to show in breakdown (0 = all). Adaptively reduced to fit terminal width.",
"default": 7
},
"palette": {
"type": "array",
"items": {
"type": "string",
"description": "Hex color string (#RRGGBB or #RGB)"
},
"description": "Rotating color palette for tool names. Override to customize; leave empty (default) to auto-detect from terminal config (WezTerm/Kitty/Alacritty) or fall back to built-in Dracula palette.",
"default": []
},
"ttl": {
"type": "number",
"default": 2
@@ -677,6 +898,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -711,6 +940,14 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
@@ -747,26 +984,23 @@
},
"color": {
"$ref": "#/$defs/colorName"
},
"background": {
"$ref": "#/$defs/colorName",
"description": "Background color for the section. Accepts named colors (green, red), hex (#50fa7b), or palette refs (p:success). Applied as a background wrap around the section output, preserving foreground colors."
},
"placeholder": {
"type": ["string", "null"],
"description": "Text shown when the section has no data (e.g. after /clear). Set to empty string or null to hide the section instead. Default: '--'."
}
},
"additionalProperties": false
},
"colorMatch": {
"type": "object",
"description": "Map of output value to color name",
"description": "Map of output value to color specifier",
"additionalProperties": {
"type": "string",
"enum": [
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"dim",
"bold"
]
"type": "string"
}
},
"customCommand": {

View File

@@ -1,6 +1,13 @@
use claude_statusline::section::RenderContext;
use claude_statusline::{cache, color, config, input, layout, metrics, section, theme, width};
use claude_statusline::shell::{self, ShellConfig};
use claude_statusline::transcript;
use claude_statusline::{
cache, color, config, format as sl_format, input, layout, metrics, section, terminal, theme,
width,
};
use std::collections::HashMap;
use std::io::Read;
use std::time::Duration;
fn main() {
let args: Vec<String> = std::env::args().collect();
@@ -18,12 +25,27 @@ fn main() {
return;
}
if args.iter().any(|a| a == "--list-sections") {
for (id, _) in section::registry() {
println!("{id}");
println!("{:<22} pri flex shell est_w", "ID");
println!("{}", "-".repeat(58));
for desc in section::registry() {
println!(
"{:<22} {:<4} {:<5} {:<6} {}",
desc.id,
desc.priority,
if desc.is_flex { "yes" } else { "-" },
if desc.shell_out { "yes" } else { "-" },
desc.estimated_width,
);
}
return;
}
let no_cache = args.iter().any(|a| a == "--no-cache")
|| std::env::var("CLAUDE_STATUSLINE_NO_CACHE").is_ok();
let no_shell = args.iter().any(|a| a == "--no-shell")
|| std::env::var("CLAUDE_STATUSLINE_NO_SHELL").is_ok();
let clear_cache = args.iter().any(|a| a == "--clear-cache");
let cli_color = args
.iter()
.find_map(|a| a.strip_prefix("--color="))
@@ -57,7 +79,7 @@ fn main() {
});
// Load config
let (config, warnings) = match config::load_config(config_path) {
let (config, warnings, config_hash) = match config::load_config(config_path) {
Ok(v) => v,
Err(e) => {
eprintln!("claude-statusline: {e}");
@@ -91,6 +113,9 @@ fn main() {
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() {
return;
}
if std::env::var("CLAUDE_STATUSLINE_DEBUG").is_ok() {
let _ = std::fs::write("/tmp/claude-statusline-input.json", &buf);
}
match serde_json::from_str(&buf) {
Ok(v) => v,
Err(e) => {
@@ -102,8 +127,14 @@ fn main() {
// Detect environment
let detected_theme = theme::detect_theme(&config);
let term_width =
width::detect_width(cli_width, config.global.width, config.global.width_margin);
let stdin_width = input_data.workspace.as_ref().and_then(|w| w.terminal_width);
let (term_width, width_source) = width::detect_width_with_source(
cli_width,
config.global.width,
config.global.width_margin,
stdin_width,
);
let tier = width::width_tier(
term_width,
config.global.breakpoints.narrow,
@@ -117,31 +148,73 @@ fn main() {
.unwrap_or(".");
let session = cache::session_id(project_dir);
let cache = cache::Cache::new(&config.global.cache_dir, &session);
// --clear-cache: remove cache directory and exit
if clear_cache {
let dir_str = config
.global
.cache_dir
.replace("{session_id}", &session)
.replace("{cache_version}", &config.global.cache_version.to_string())
.replace("{config_hash}", &config_hash);
let dir = std::path::Path::new(&dir_str);
if dir.exists() {
if let Err(e) = std::fs::remove_dir_all(dir) {
eprintln!("claude-statusline: clear-cache: {e}");
std::process::exit(1);
}
}
std::process::exit(0);
}
let cache = if no_cache {
cache::Cache::disabled()
} else {
cache::Cache::new(
&config.global.cache_dir,
&session,
config.global.cache_version,
&config_hash,
config.global.cache_ttl_jitter_pct,
)
};
// Detect /clear: context usage drops dramatically while session continues.
// When detected, flush trends and save transcript offset so derived stats reset.
detect_clear(&input_data, &cache);
let shell_config = ShellConfig {
enabled: config.global.shell_enabled && !no_shell,
allowlist: config.global.shell_allowlist.clone(),
denylist: config.global.shell_denylist.clone(),
timeout: Duration::from_millis(config.global.shell_timeout_ms),
max_output_bytes: config.global.shell_max_output_bytes,
env: config.global.shell_env.clone(),
failure_threshold: config.global.shell_failure_threshold,
cooldown_ms: config.global.shell_cooldown_ms,
};
let color_enabled = color::should_use_color(cli_color.as_deref(), &config.global.color);
let vcs_type = detect_vcs(project_dir, &config);
// Handle --dump-state
if let Some(format) = dump_state {
dump_state_output(
format,
&config,
term_width,
tier,
detected_theme,
vcs_type,
project_dir,
&session,
);
return;
}
// Build render context
let project_path = std::path::Path::new(project_dir);
let computed_metrics = metrics::ComputedMetrics::from_input(&input_data);
// Prefetch shell-outs in parallel (only for cache-miss commands)
let shell_results = if !no_shell && shell_config.enabled {
prefetch_shell_outs(&shell_config, &cache, vcs_type, project_dir, &config)
} else {
std::collections::HashMap::new()
};
// Parse transcript for tool/turn counts (cached)
let transcript_stats = resolve_transcript_stats(&input_data, &cache);
// Query terminal ANSI palette for theme-aware tool colors (cached 1h)
let terminal_palette = resolve_terminal_palette(&cache);
let ctx = RenderContext {
input: &input_data,
config: &config,
@@ -153,11 +226,261 @@ fn main() {
cache: &cache,
glyphs_enabled: config.glyphs.enabled,
color_enabled,
no_shell,
shell_config: &shell_config,
metrics: computed_metrics,
budget_start: if config.global.render_budget_ms > 0 {
Some(std::time::Instant::now())
} else {
None
},
budget_ms: config.global.render_budget_ms,
shell_results,
transcript_stats,
terminal_palette,
};
// Handle --dump-state (after building ctx so we can collect diagnostics)
if let Some(format) = dump_state {
dump_state_output(
format,
&config,
term_width,
width_source,
tier,
detected_theme,
vcs_type,
project_dir,
&session,
&config_hash,
&ctx,
);
return;
}
let output = layout::render_all(&ctx);
print!("{output}");
// Cache GC: run after output, never blocks status line
if !no_cache {
cache::gc(
config.global.cache_gc_days,
config.global.cache_gc_interval_hours,
);
}
}
/// Prefetch shell-out results in parallel using std::thread::scope.
/// Only spawns threads for commands whose cache has expired.
/// Returns results keyed by section name.
fn prefetch_shell_outs(
shell_config: &ShellConfig,
cache: &cache::Cache,
vcs_type: section::VcsType,
project_dir: &str,
config: &config::Config,
) -> HashMap<String, Option<String>> {
let mut results = HashMap::new();
// Check which shell-outs need refreshing
let vcs_ttl = Duration::from_secs(config.sections.vcs.ttl.branch);
let needs_vcs = vcs_type != section::VcsType::None
&& config.sections.vcs.base.enabled
&& cache.get("vcs_branch", vcs_ttl).is_none();
let load_ttl = Duration::from_secs(config.sections.load.ttl);
let needs_load = config.sections.load.base.enabled && cache.get("load_avg", load_ttl).is_none();
let beads_ttl = Duration::from_secs(config.sections.beads.ttl);
let needs_beads = config.sections.beads.base.enabled
&& std::path::Path::new(project_dir).join(".beads").is_dir()
&& cache.get("beads_stats", beads_ttl).is_none();
// If nothing needs refreshing, skip thread::scope entirely
if !needs_vcs && !needs_load && !needs_beads {
return results;
}
std::thread::scope(|s| {
let vcs_handle = if needs_vcs {
let args: Vec<String> = match vcs_type {
section::VcsType::Git => vec![
"git".into(),
"-C".into(),
project_dir.into(),
"status".into(),
"--porcelain=v2".into(),
"--branch".into(),
],
section::VcsType::Jj => vec![
"jj".into(),
"log".into(),
"-r".into(),
"@".into(),
"--no-graph".into(),
"-T".into(),
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))".into(),
"--color=never".into(),
],
section::VcsType::None => vec![],
};
if args.is_empty() {
None
} else {
let dir = if vcs_type == section::VcsType::Jj {
Some(project_dir.to_string())
} else {
None
};
Some(s.spawn(move || {
let prog = &args[0];
let str_args: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect();
shell::exec_gated(shell_config, prog, &str_args, dir.as_deref())
}))
}
} else {
None
};
let load_handle = if needs_load {
Some(s.spawn(|| {
#[cfg(target_os = "macos")]
{
shell::exec_gated(shell_config, "sysctl", &["-n", "vm.loadavg"], None)
}
#[cfg(target_os = "linux")]
{
std::fs::read_to_string("/proc/loadavg")
.ok()
.and_then(|c| c.split_whitespace().next().map(|s| s.to_string()))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}))
} else {
None
};
let beads_handle = if needs_beads {
let dir = project_dir.to_string();
Some(s.spawn(move || {
shell::exec_gated(shell_config, "br", &["stats", "--json"], Some(&dir))
}))
} else {
None
};
if let Some(h) = vcs_handle {
results.insert("vcs".into(), h.join().ok().flatten());
}
if let Some(h) = load_handle {
results.insert("load".into(), h.join().ok().flatten());
}
if let Some(h) = beads_handle {
results.insert("beads".into(), h.join().ok().flatten());
}
});
results
}
/// Detect /clear by watching for a significant context usage drop.
/// When detected: flush trend caches and save transcript line offset
/// so derived stats (tools, turns) only count post-clear entries.
fn detect_clear(input: &input::InputData, cache: &cache::Cache) {
// Only run detection when context_window data is actually present.
// Missing data (e.g. during slash-command menu or transient redraws)
// must NOT be treated as "context dropped to 0" — that false-positive
// flushes all trends and transcript stats.
let current_pct = match input
.context_window
.as_ref()
.and_then(|cw| cw.used_percentage)
{
Some(pct) => pct,
None => return,
};
let prev_pct: f64 = cache
.get_stale("last_context_pct")
.and_then(|s| s.parse().ok())
.unwrap_or(0.0);
cache.set("last_context_pct", &format!("{current_pct:.1}"));
// Detect clear: previous context was significant (>15%) and current is near-zero (<5%),
// with a drop of at least 20 percentage points.
let drop = prev_pct - current_pct;
if prev_pct > 15.0 && current_pct < 5.0 && drop > 20.0 {
// Save current transcript line count as the clear offset
if let Some(path_str) = input.transcript_path.as_deref() {
let path = std::path::Path::new(path_str);
if path.exists() {
if let Ok(file) = std::fs::File::open(path) {
use std::io::BufRead;
let line_count = std::io::BufReader::new(file).lines().count();
cache.set("clear_transcript_offset", &line_count.to_string());
}
}
}
// Flush trends and cached transcript stats
cache.flush_prefix("trend_");
cache.flush_prefix("transcript_");
}
}
/// Resolve transcript stats: check cache first, then parse the transcript JSONL.
/// Uses a 5-second TTL so we re-parse at most every 5 seconds.
/// Respects the clear_transcript_offset: only counts entries after the offset line.
fn resolve_transcript_stats(
input: &input::InputData,
cache: &cache::Cache,
) -> Option<transcript::TranscriptStats> {
// If cost already has tool counts, skip transcript parsing entirely
if input
.cost
.as_ref()
.and_then(|c| c.total_tool_uses)
.is_some()
{
return None;
}
let path_str = input.transcript_path.as_deref()?;
let path = std::path::Path::new(path_str);
if !path.exists() {
return None;
}
let ttl = Duration::from_secs(5);
if let Some(cached) = cache.get("transcript_stats", ttl) {
return transcript::TranscriptStats::from_cache_string(&cached);
}
let skip_lines: usize = cache
.get_stale("clear_transcript_offset")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let stats = transcript::parse_transcript(path, skip_lines)?;
cache.set("transcript_stats", &stats.to_cache_string());
Some(stats)
}
/// Resolve the terminal's color palette from config file or OSC 4 queries.
/// Cached for 1 hour — terminal colors don't change mid-session.
fn resolve_terminal_palette(cache: &cache::Cache) -> Option<Vec<(u8, u8, u8)>> {
let ttl = Duration::from_secs(3600);
if let Some(cached) = cache.get("terminal_palette", ttl) {
return terminal::palette_from_cache(&cached);
}
let palette = terminal::detect_palette()?;
cache.set("terminal_palette", &terminal::palette_to_cache(&palette));
Some(palette)
}
fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType {
@@ -196,29 +519,84 @@ fn dump_state_output(
format: &str,
config: &config::Config,
term_width: u16,
width_source: &str,
tier: width::WidthTier,
theme: theme::Theme,
vcs: section::VcsType,
project_dir: &str,
session_id: &str,
config_hash: &str,
ctx: &RenderContext,
) {
// Render all sections with per-section timing
let layout_lines = layout::resolve_layout(ctx.config, ctx.term_width);
let registry = section::registry();
let mut section_timings: Vec<serde_json::Value> = Vec::new();
for line_ids in &layout_lines {
for id in line_ids {
if section::is_spacer(id) {
continue;
}
let start = std::time::Instant::now();
let output = section::render_section(id, ctx);
let elapsed_us = start.elapsed().as_micros() as u64;
let priority = registry
.iter()
.find(|d| d.id == id)
.map_or(2, |d| d.priority);
section_timings.push(serde_json::json!({
"id": id,
"render_us": elapsed_us,
"rendered": output.is_some(),
"priority": priority,
"raw_width": output.as_ref().map(|o| sl_format::display_width(&o.raw)).unwrap_or(0),
}));
}
}
// Collect cache diagnostics
let cache_diags: Vec<serde_json::Value> = ctx
.cache
.diagnostics()
.into_iter()
.map(|d| {
serde_json::json!({
"key": d.key,
"hit": d.hit,
"age_ms": d.age_ms,
})
})
.collect();
let json = serde_json::json!({
"terminal": {
"effective_width": term_width,
"width_margin": config.global.width_margin,
"width_tier": format!("{tier:?}"),
"source": width_source,
},
"theme": theme.as_str(),
"vcs": format!("{vcs:?}"),
"layout": {
"justify": format!("{:?}", config.global.justify),
"separator": &config.global.separator,
"drop_strategy": &config.global.drop_strategy,
"render_budget_ms": config.global.render_budget_ms,
},
"sections": section_timings,
"cache": cache_diags,
"paths": {
"project_dir": project_dir,
"cache_dir": config.global.cache_dir.replace("{session_id}", session_id),
"cache_dir": config.global.cache_dir
.replace("{session_id}", session_id)
.replace("{cache_version}", &config.global.cache_version.to_string())
.replace("{config_hash}", config_hash),
},
"session_id": session_id,
"input": &ctx.input,
});
match format {

View File

@@ -1,21 +1,56 @@
use std::cell::RefCell;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// Diagnostic entry for a single cache lookup.
#[derive(Debug, Clone)]
pub struct CacheDiag {
pub key: String,
pub hit: bool,
pub age_ms: Option<u64>,
}
pub struct Cache {
dir: Option<PathBuf>,
jitter_pct: u8,
diagnostics: RefCell<Vec<CacheDiag>>,
}
impl Cache {
/// Create a disabled cache where all operations are no-ops.
/// Used for --no-cache mode.
pub fn disabled() -> Self {
Self {
dir: None,
jitter_pct: 0,
diagnostics: RefCell::new(Vec::new()),
}
}
/// Create cache with secure directory. Returns disabled cache on failure.
pub fn new(template: &str, session_id: &str) -> Self {
let dir_str = template.replace("{session_id}", session_id);
/// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
pub fn new(
template: &str,
session_id: &str,
cache_version: u32,
config_hash: &str,
jitter_pct: u8,
) -> Self {
let dir_str = template
.replace("{session_id}", session_id)
.replace("{cache_version}", &cache_version.to_string())
.replace("{config_hash}", config_hash);
let dir = PathBuf::from(&dir_str);
if !dir.exists() {
if fs::create_dir_all(&dir).is_err() {
return Self { dir: None };
return Self {
dir: None,
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
};
}
#[cfg(unix)]
{
@@ -26,29 +61,105 @@ impl Cache {
// Security: verify ownership, not a symlink, not world-writable
if !verify_cache_dir(&dir) {
return Self { dir: None };
return Self {
dir: None,
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
};
}
Self { dir: Some(dir) }
Self {
dir: Some(dir),
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
}
}
pub fn dir(&self) -> Option<&Path> {
self.dir.as_deref()
}
/// Get cached value if fresher than TTL.
/// Get cached value if fresher than TTL (with per-key jitter applied).
pub fn get(&self, key: &str, ttl: Duration) -> Option<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()
let path = match self.key_path(key) {
Some(p) => p,
None => {
self.record_diag(key, false, None);
return None;
}
};
let meta = match fs::metadata(&path).ok() {
Some(m) => m,
None => {
self.record_diag(key, false, None);
return None;
}
};
let modified = match meta.modified().ok() {
Some(m) => m,
None => {
self.record_diag(key, false, None);
return None;
}
};
let age = match SystemTime::now().duration_since(modified).ok() {
Some(a) => a,
None => {
self.record_diag(key, false, None);
return None;
}
};
let age_ms = age.as_millis() as u64;
let effective_ttl = self.jittered_ttl(key, ttl);
if age < effective_ttl {
let value = fs::read_to_string(&path).ok();
self.record_diag(key, value.is_some(), Some(age_ms));
value
} else {
self.record_diag(key, false, Some(age_ms));
None
}
}
fn record_diag(&self, key: &str, hit: bool, age_ms: Option<u64>) {
if let Ok(mut diags) = self.diagnostics.try_borrow_mut() {
diags.push(CacheDiag {
key: key.to_string(),
hit,
age_ms,
});
}
}
/// Return collected cache diagnostics (for --dump-state).
pub fn diagnostics(&self) -> Vec<CacheDiag> {
self.diagnostics.borrow().clone()
}
/// Apply deterministic per-key jitter to TTL.
/// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time).
fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {
if self.jitter_pct == 0 {
return base_ttl;
}
// FNV-1a hash of key for deterministic per-key jitter
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in key.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
// Map hash to range [-jitter_pct, +jitter_pct]
let jitter_range = f64::from(self.jitter_pct) / 100.0;
let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]
let multiplier = 1.0 + (normalized * jitter_range);
let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;
// Clamp: minimum 100ms to avoid zero TTL
Duration::from_millis(jittered_ms.max(100))
}
/// Get stale cached value (ignores TTL). Used as fallback on command failure.
pub fn get_stale(&self, key: &str) -> Option<String> {
let path = self.key_path(key)?;
@@ -76,6 +187,23 @@ impl Cache {
Some(())
}
/// Remove cache entries matching a prefix (e.g., "trend_" to flush all trend data).
pub fn flush_prefix(&self, prefix: &str) {
let dir = match &self.dir {
Some(d) => d,
None => return,
};
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(prefix) {
let _ = fs::remove_file(entry.path());
}
}
}
}
fn key_path(&self, key: &str) -> Option<PathBuf> {
let dir = self.dir.as_ref()?;
let safe_key: String = key
@@ -152,3 +280,94 @@ pub fn session_id(project_dir: &str) -> String {
let hash = Md5::digest(project_dir.as_bytes());
format!("{:x}", hash)[..12].to_string()
}
/// Garbage-collect old cache directories.
/// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days`
/// that match /tmp/claude-sl-* and are owned by the current user.
/// Never blocks: uses non-blocking flock on a sentinel file.
pub fn gc(gc_days: u16, gc_interval_hours: u16) {
let lock_path = Path::new("/tmp/claude-sl-gc.lock");
// Check interval: if lock file exists and is younger than gc_interval, skip
if let Ok(meta) = fs::metadata(lock_path) {
if let Ok(modified) = meta.modified() {
if let Ok(age) = SystemTime::now().duration_since(modified) {
if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) {
return;
}
}
}
}
// Try non-blocking lock
let lock_file = match fs::File::create(lock_path) {
Ok(f) => f,
Err(_) => return,
};
if !try_flock(&lock_file) {
return; // another process is GC-ing
}
// Touch the lock file (create already set mtime to now)
let max_age = Duration::from_secs(u64::from(gc_days) * 86400);
let entries = match fs::read_dir("/tmp") {
Ok(e) => e,
Err(_) => {
unlock(&lock_file);
return;
}
};
let uid = current_uid();
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("claude-sl-") {
continue;
}
let path = entry.path();
// Safety: skip symlinks
let meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_dir() || meta.file_type().is_symlink() {
continue;
}
// Only delete dirs owned by current user
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.uid() != uid {
continue;
}
}
// Check age
if let Ok(modified) = meta.modified() {
if let Ok(age) = SystemTime::now().duration_since(modified) {
if age > max_age {
let _ = fs::remove_dir_all(&path);
}
}
}
}
unlock(&lock_file);
}
fn current_uid() -> u32 {
#[cfg(unix)]
{
unsafe { libc::getuid() }
}
#[cfg(not(unix))]
{
0
}
}

View File

@@ -4,6 +4,9 @@ use crate::theme::Theme;
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const ITALIC: &str = "\x1b[3m";
pub const UNDERLINE: &str = "\x1b[4m";
pub const STRIKETHROUGH: &str = "\x1b[9m";
pub const RED: &str = "\x1b[31m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
@@ -12,7 +15,92 @@ pub const MAGENTA: &str = "\x1b[35m";
pub const CYAN: &str = "\x1b[36m";
pub const WHITE: &str = "\x1b[37m";
// Named background colors
const BG_RED: &str = "\x1b[41m";
const BG_GREEN: &str = "\x1b[42m";
const BG_YELLOW: &str = "\x1b[43m";
const BG_BLUE: &str = "\x1b[44m";
const BG_MAGENTA: &str = "\x1b[45m";
const BG_CYAN: &str = "\x1b[46m";
const BG_WHITE: &str = "\x1b[47m";
/// Parse a hex color string (#RRGGBB or #RGB) into (R, G, B).
pub fn parse_hex(s: &str) -> Option<(u8, u8, u8)> {
let hex = s.strip_prefix('#')?;
match hex.len() {
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((r, g, b))
}
3 => {
let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
Some((r, g, b))
}
_ => None,
}
}
/// Emit a 24-bit foreground ANSI escape for an (R, G, B) tuple.
pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
format!("\x1b[38;2;{r};{g};{b}m")
}
/// Emit a 24-bit background ANSI escape for an (R, G, B) tuple.
pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
format!("\x1b[48;2;{r};{g};{b}m")
}
/// Interpolate a gradient between two hex colors at position `t` (0.0..=1.0).
/// Returns a 24-bit foreground ANSI escape.
pub fn gradient_fg(from_hex: &str, to_hex: &str, t: f32) -> String {
use colorgrad::Gradient;
let grad = colorgrad::GradientBuilder::new()
.html_colors(&[from_hex, to_hex])
.build::<colorgrad::LinearGradient>()
.unwrap_or_else(|_| {
colorgrad::GradientBuilder::new()
.html_colors(&["#00ff00", "#ff0000"])
.build::<colorgrad::LinearGradient>()
.expect("fallback gradient must build")
});
let c = grad.at(t.clamp(0.0, 1.0));
let [r, g, b, _] = c.to_rgba8();
fg_rgb(r, g, b)
}
/// Build a multi-stop gradient from hex color strings (e.g., ["#50fa7b", "#f1fa8c", "#ff5555"]).
pub fn make_gradient(colors: &[&str]) -> colorgrad::LinearGradient {
colorgrad::GradientBuilder::new()
.html_colors(colors)
.build::<colorgrad::LinearGradient>()
.unwrap_or_else(|_| {
colorgrad::GradientBuilder::new()
.html_colors(&["#00ff00", "#ffff00", "#ff0000"])
.build::<colorgrad::LinearGradient>()
.expect("fallback gradient must build")
})
}
/// Sample a gradient at position `t` and return a 24-bit foreground ANSI escape.
pub fn sample_fg(grad: &colorgrad::LinearGradient, t: f32) -> String {
use colorgrad::Gradient;
let c = grad.at(t.clamp(0.0, 1.0));
let [r, g, b, _] = c.to_rgba8();
fg_rgb(r, g, b)
}
/// Resolve a color name to ANSI escape sequence(s).
///
/// Supported formats (space-separated, combinable):
/// - Named: red, green, yellow, blue, magenta, cyan, white
/// - Modifiers: dim, bold, italic, underline, strikethrough
/// - Hex: #FF6B35, #F00
/// - Background: bg:red, bg:#FF6B35
/// - Palette: p:success (resolved through theme palette)
pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String {
if let Some(key) = name.strip_prefix("p:") {
let map = match theme {
@@ -27,18 +115,53 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> 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,
_ => "",
});
let resolved = match part {
// Named foreground colors
"red" => RED.to_string(),
"green" => GREEN.to_string(),
"yellow" => YELLOW.to_string(),
"blue" => BLUE.to_string(),
"magenta" => MAGENTA.to_string(),
"cyan" => CYAN.to_string(),
"white" => WHITE.to_string(),
// Modifiers
"dim" => DIM.to_string(),
"bold" => BOLD.to_string(),
"italic" => ITALIC.to_string(),
"underline" => UNDERLINE.to_string(),
"strikethrough" => STRIKETHROUGH.to_string(),
// Hex foreground
s if s.starts_with('#') => {
if let Some((r, g, b)) = parse_hex(s) {
fg_rgb(r, g, b)
} else {
String::new()
}
}
// Background colors
s if s.starts_with("bg:") => {
let bg_val = &s[3..];
match bg_val {
"red" => BG_RED.to_string(),
"green" => BG_GREEN.to_string(),
"yellow" => BG_YELLOW.to_string(),
"blue" => BG_BLUE.to_string(),
"magenta" => BG_MAGENTA.to_string(),
"cyan" => BG_CYAN.to_string(),
"white" => BG_WHITE.to_string(),
hex if hex.starts_with('#') => {
if let Some((r, g, b)) = parse_hex(hex) {
bg_rgb(r, g, b)
} else {
String::new()
}
}
_ => String::new(),
}
}
_ => String::new(),
};
result.push_str(&resolved);
}
if result.is_empty() {
@@ -49,6 +172,7 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String
}
/// Determine whether color output should be used.
/// Precedence: NO_COLOR > --color= CLI flag > CLAUDE_STATUSLINE_COLOR env > config
pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool {
if std::env::var("NO_COLOR").is_ok() {
return false;
@@ -62,6 +186,14 @@ pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::C
};
}
if let Ok(env_color) = std::env::var("CLAUDE_STATUSLINE_COLOR") {
return match env_color.as_str() {
"always" => true,
"never" => false,
_ => atty_stdout(),
};
}
match config_color {
crate::config::ColorMode::Always => true,
crate::config::ColorMode::Never => false,

View File

@@ -45,6 +45,7 @@ impl Default for LayoutValue {
#[serde(default)]
pub struct GlobalConfig {
pub separator: String,
pub separator_style: SeparatorStyle,
pub justify: JustifyMode,
pub vcs: String,
pub width: Option<u16>,
@@ -69,18 +70,18 @@ pub struct GlobalConfig {
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(),
separator_style: SeparatorStyle::Text,
justify: JustifyMode::Left,
vcs: "auto".into(),
width: None,
width_margin: 4,
cache_dir: "/tmp/claude-sl-{session_id}".into(),
cache_dir: "/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}".into(),
cache_gc_days: 7,
cache_gc_interval_hours: 24,
cache_ttl_jitter_pct: 10,
@@ -100,7 +101,6 @@ impl Default for GlobalConfig {
shell_env: HashMap::new(),
cache_version: 1,
drop_strategy: "tiered".into(),
breakpoint_hysteresis: 2,
}
}
}
@@ -123,11 +123,30 @@ pub enum ColorMode {
Never,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SeparatorStyle {
#[default]
Text,
Powerline,
Arrow,
None,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BarStyle {
Classic,
#[default]
Block,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Breakpoints {
pub narrow: u16,
pub medium: u16,
pub hysteresis: u16,
}
impl Default for Breakpoints {
@@ -135,6 +154,7 @@ impl Default for Breakpoints {
Self {
narrow: 60,
medium: 100,
hysteresis: 2,
}
}
}
@@ -172,6 +192,8 @@ pub struct SectionBase {
pub pad: Option<u16>,
pub align: Option<String>,
pub color: Option<String>,
pub background: Option<String>,
pub placeholder: Option<String>,
}
impl Default for SectionBase {
@@ -186,6 +208,8 @@ impl Default for SectionBase {
pad: None,
align: None,
color: None,
background: None,
placeholder: Some("\u{2500}".into()),
}
}
}
@@ -219,6 +243,10 @@ pub struct Sections {
pub time: TimeSection,
pub output_style: SectionBase,
pub hostname: SectionBase,
pub cloud_profile: SectionBase,
pub k8s_context: CachedSection,
pub python_env: SectionBase,
pub toolchain: SectionBase,
}
#[derive(Debug, Deserialize)]
@@ -356,6 +384,10 @@ pub struct ContextBarSection {
#[serde(flatten)]
pub base: SectionBase,
pub bar_width: u16,
pub bar_style: BarStyle,
pub gradient: bool,
pub fill_char: Option<String>,
pub empty_char: Option<String>,
pub thresholds: Thresholds,
}
@@ -369,6 +401,10 @@ impl Default for ContextBarSection {
..Default::default()
},
bar_width: 10,
bar_style: BarStyle::Block,
gradient: true,
fill_char: None,
empty_char: None,
thresholds: Thresholds {
warn: 50.0,
danger: 70.0,
@@ -492,6 +528,7 @@ pub struct TrendSection {
#[serde(flatten)]
pub base: SectionBase,
pub width: u8,
pub gradient: bool,
}
impl Default for TrendSection {
@@ -502,6 +539,7 @@ impl Default for TrendSection {
..Default::default()
},
width: 8,
gradient: true,
}
}
}
@@ -512,6 +550,7 @@ pub struct ContextTrendSection {
#[serde(flatten)]
pub base: SectionBase,
pub width: u8,
pub gradient: bool,
pub thresholds: Thresholds,
}
@@ -523,6 +562,7 @@ impl Default for ContextTrendSection {
..Default::default()
},
width: 8,
gradient: true,
thresholds: Thresholds::default(),
}
}
@@ -534,6 +574,13 @@ pub struct ToolsSection {
#[serde(flatten)]
pub base: SectionBase,
pub show_last_name: bool,
/// Show per-tool breakdown (e.g., "Bash:84 Read:35 Edit:34").
pub show_breakdown: bool,
/// Max number of tools to show in breakdown (0 = all).
pub top_n: usize,
/// Rotating color palette for tool names (hex strings, e.g. "#8be9fd").
/// Falls back to built-in Dracula palette when empty.
pub palette: Vec<String>,
pub ttl: u64,
}
@@ -546,6 +593,9 @@ impl Default for ToolsSection {
..Default::default()
},
show_last_name: true,
show_breakdown: true,
top_n: 7,
palette: Vec::new(),
ttl: 2,
}
}
@@ -660,7 +710,11 @@ pub fn deep_merge(base: &mut Value, patch: &Value) {
// ── Config loading ──────────────────────────────────────────────────────
/// Load config: embedded defaults deep-merged with user overrides.
pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec<String>), crate::Error> {
/// Returns (Config, warnings, config_hash) where config_hash is 8-char hex MD5
/// of the merged JSON (for cache namespace invalidation on config change).
pub fn load_config(
explicit_path: Option<&str>,
) -> Result<(Config, Vec<String>, String), crate::Error> {
let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?;
let user_path = explicit_path
@@ -687,12 +741,24 @@ pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec<String>),
));
}
// Compute config hash from merged JSON before deserialize consumes it
let config_hash = compute_config_hash(&base);
let mut warnings = Vec::new();
let config: Config = serde_ignored::deserialize(base, |path| {
warnings.push(format!("unknown config key: {path}"));
})?;
Ok((config, warnings))
Ok((config, warnings, config_hash))
}
/// MD5 of the merged JSON value, truncated to 8 hex chars.
/// Deterministic: serde_json produces stable output for the same Value.
fn compute_config_hash(merged: &Value) -> String {
use md5::{Digest, Md5};
let json_bytes = serde_json::to_string(merged).unwrap_or_default();
let hash = Md5::digest(json_bytes.as_bytes());
format!("{:x}", hash)[..8].to_string()
}
fn xdg_config_path() -> Option<std::path::PathBuf> {

View File

@@ -149,6 +149,13 @@ pub fn apply_formatting(
*ansi = format!("{c}{raw}{}", color::RESET);
}
if let Some(ref bg_name) = base.background {
let bg = resolve_background(bg_name, theme, palette);
if !bg.is_empty() {
*ansi = format!("{bg}{ansi}{}", color::RESET);
}
}
if let Some(pad) = base.pad {
let pad = pad as usize;
let raw_w = display_width(raw);
@@ -175,3 +182,30 @@ pub fn apply_formatting(
}
}
}
/// Resolve a background color specifier to an ANSI background escape.
/// Accepts: named colors (green, red), hex (#50fa7b), or palette refs (p:success).
/// The value is auto-prefixed with "bg:" if not already a background specifier.
fn resolve_background(name: &str, theme: Theme, palette: &crate::config::ThemeColors) -> String {
// If already a bg: specifier, resolve directly
if name.starts_with("bg:") {
return color::resolve_color(name, theme, palette);
}
// Palette references: resolve first, then convert to bg
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_background(resolved, theme, palette);
}
return String::new();
}
// Hex color -> bg:hex
if name.starts_with('#') {
return color::resolve_color(&format!("bg:{name}"), theme, palette);
}
// Named color -> bg:name
color::resolve_color(&format!("bg:{name}"), theme, palette)
}

View File

@@ -1,6 +1,6 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct InputData {
pub model: Option<ModelInfo>,
@@ -9,28 +9,52 @@ pub struct InputData {
pub workspace: Option<Workspace>,
pub version: Option<String>,
pub output_style: Option<OutputStyle>,
pub transcript_path: Option<String>,
pub session_id: Option<String>,
pub cwd: Option<String>,
pub vim: Option<VimInfo>,
pub agent: Option<AgentInfo>,
pub exceeds_200k_tokens: Option<bool>,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct VimInfo {
pub mode: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AgentInfo {
pub name: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ModelInfo {
pub id: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct CostInfo {
pub total_cost_usd: Option<f64>,
pub total_duration_ms: Option<u64>,
pub total_api_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>,
/// Captures any fields we don't explicitly model (for debugging via --dump-state).
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ContextWindow {
pub used_percentage: Option<f64>,
@@ -40,20 +64,23 @@ pub struct ContextWindow {
pub current_usage: Option<CurrentUsage>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct CurrentUsage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Workspace {
pub project_dir: Option<String>,
pub terminal_width: Option<u16>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct OutputStyle {
pub name: Option<String>,

View File

@@ -44,12 +44,14 @@ pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator:
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
// Rebuild context_bar with wider bar_width.
// Account for prefix/suffix that apply_formatting will add,
// so the final width doesn't overshoot term_width.
let base = &ctx.config.sections.context_bar.base;
let fmt_overhead = formatting_overhead(base);
let cur_bar_width = ctx.config.sections.context_bar.bar_width;
let new_bar_width = cur_bar_width + extra.saturating_sub(fmt_overhead) as u16;
if let Some(mut output) = section::context_bar::render_at_width(ctx, new_bar_width) {
format::apply_formatting(
&mut output.raw,
&mut output.ansi,
@@ -66,6 +68,13 @@ pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator:
}
}
/// Calculate display width of prefix + suffix that apply_formatting will add.
fn formatting_overhead(base: &crate::config::SectionBase) -> usize {
let pfx = base.prefix.as_deref().map_or(0, format::display_width);
let sfx = base.suffix.as_deref().map_or(0, format::display_width);
pfx + sfx
}
fn line_width(active: &[ActiveSection], separator: &str) -> usize {
let sep_w = format::display_width(separator);
let mut total = 0;

View File

@@ -10,6 +10,7 @@ pub fn justify(
term_width: u16,
separator: &str,
_mode: JustifyMode,
color_enabled: bool,
) -> String {
let content_width: usize = active
.iter()
@@ -37,11 +38,15 @@ pub fn justify(
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);
if color_enabled {
output.push_str(&format!(
"{}{gap_str}{}",
crate::color::DIM,
crate::color::RESET
));
} else {
output.push_str(&gap_str);
}
}
output.push_str(&sec.output.ansi);
}

View File

@@ -2,7 +2,9 @@ pub mod flex;
pub mod justify;
pub mod priority;
use crate::config::{Config, JustifyMode, LayoutValue};
use crate::color;
use crate::config::{Config, JustifyMode, LayoutValue, SectionBase, SeparatorStyle};
use crate::format;
use crate::section::{self, RenderContext, SectionOutput};
/// A section that survived priority drops and has rendered output.
@@ -35,23 +37,90 @@ pub fn resolve_layout(config: &Config, term_width: u16) -> Vec<Vec<String>> {
}
fn responsive_preset(width: u16, bp: &crate::config::Breakpoints) -> &'static str {
if width < bp.narrow {
use std::sync::Mutex;
static LAST_PRESET: Mutex<Option<&'static str>> = Mutex::new(None);
let simple = if width < bp.narrow {
"dense"
} else if width < bp.medium {
"standard"
} else {
"verbose"
};
let hysteresis = bp.hysteresis;
if hysteresis == 0 {
return simple;
}
let guard = LAST_PRESET.lock().unwrap_or_else(|e| e.into_inner());
let prev = *guard;
drop(guard);
let result = match prev {
Some(prev) => {
// Check if width is within hysteresis zone of a breakpoint
let in_narrow_zone =
width >= bp.narrow.saturating_sub(hysteresis) && width < bp.narrow + hysteresis;
let in_medium_zone =
width >= bp.medium.saturating_sub(hysteresis) && width < bp.medium + hysteresis;
if (in_narrow_zone || in_medium_zone) && is_valid_preset(prev, width, bp, hysteresis) {
prev
} else {
simple
}
}
None => simple,
};
let mut guard = LAST_PRESET.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(result);
result
}
/// Check if a previous preset is still valid given the current width.
fn is_valid_preset(
preset: &str,
width: u16,
bp: &crate::config::Breakpoints,
hysteresis: u16,
) -> bool {
match preset {
"dense" => width < bp.narrow + hysteresis,
"standard" => {
width >= bp.narrow.saturating_sub(hysteresis) && width < bp.medium + hysteresis
}
"verbose" => width >= bp.medium.saturating_sub(hysteresis),
_ => false,
}
}
/// Resolve the effective separator string based on separator_style config.
/// Powerline auto-falls-back to arrow when glyphs are disabled.
fn resolve_separator(config: &Config) -> String {
let style = match config.global.separator_style {
SeparatorStyle::Powerline if !config.glyphs.enabled => SeparatorStyle::Arrow,
other => other,
};
match style {
SeparatorStyle::Text => config.global.separator.clone(),
SeparatorStyle::Powerline => " \u{E0B0} ".into(), // Nerd Font triangle
SeparatorStyle::Arrow => " \u{276F} ".into(), // Heavy right-pointing angle quotation
SeparatorStyle::None => " ".into(), // Double space
}
}
/// 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 separator = resolve_separator(ctx.config);
let lines: Vec<String> = layout
.iter()
.filter_map(|line_ids| render_line(line_ids, ctx, separator))
.filter_map(|line_ids| render_line(line_ids, ctx, &separator))
.collect();
lines.join("\n")
@@ -64,11 +133,30 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
let mut active: Vec<ActiveSection> = Vec::new();
for id in section_ids {
if let Some(output) = section::render_section(id, ctx) {
// Budget check: skip non-priority-1 sections when over budget
if ctx.budget_exceeded() {
let (prio, _) = section_meta(id, ctx.config);
if prio > 1 {
continue;
}
}
if let Some(mut output) = section::render_section(id, ctx) {
if output.raw.is_empty() && !section::is_spacer(id) {
continue;
}
// Apply per-section formatting (prefix, suffix, color override, pad, align)
if let Some(base) = section_base(id, ctx.config) {
format::apply_formatting(
&mut output.raw,
&mut output.ansi,
base,
ctx.theme,
&ctx.config.colors,
);
}
let (prio, is_flex) = section_meta(id, ctx.config);
active.push(ActiveSection {
id: id.clone(),
@@ -77,6 +165,54 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
is_spacer: section::is_spacer(id),
is_flex,
});
} else if !section::is_spacer(id) {
// Enabled section returned None (no data) — substitute placeholder
// to preserve section count for justify/flex layout stability.
if let Some(base) = section_base(id, ctx.config) {
if base.enabled {
if let Some(ref ph) = base.placeholder {
if !ph.is_empty() {
// Repeat the placeholder char to fill min_width
// (minus prefix/suffix overhead). This produces a
// seamless line like "───────" instead of "─ ".
let fill_char = ph.chars().next().unwrap_or('\u{2500}');
let pfx_w =
base.prefix.as_ref().map_or(0, |p| format::display_width(p));
let sfx_w =
base.suffix.as_ref().map_or(0, |s| format::display_width(s));
let overhead = pfx_w + sfx_w;
let inner_w = base.min_width.map_or(ph.len(), |mw| {
(mw as usize).saturating_sub(overhead).max(1)
});
let fill: String = std::iter::repeat_n(fill_char, inner_w).collect();
let mut output = SectionOutput {
raw: fill.clone(),
ansi: if ctx.color_enabled {
format!("{}{fill}{}", color::DIM, color::RESET)
} else {
fill
},
};
format::apply_formatting(
&mut output.raw,
&mut output.ansi,
base,
ctx.theme,
&ctx.config.colors,
);
let (prio, is_flex) = section_meta(id, ctx.config);
active.push(ActiveSection {
id: id.clone(),
output,
priority: prio,
is_spacer: false,
is_flex,
});
}
}
}
}
}
}
@@ -85,7 +221,12 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
}
// Phase 2: Priority drop if overflowing
let mut active = priority::priority_drop(active, ctx.term_width, separator);
let mut active = priority::drop_sections(
active,
ctx.term_width,
separator,
&ctx.config.global.drop_strategy,
);
// Phase 3: Flex expand or justify
let line = if ctx.config.global.justify != JustifyMode::Left
@@ -97,7 +238,11 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
ctx.term_width,
separator,
ctx.config.global.justify,
ctx.color_enabled,
)
} else if ctx.budget_exceeded() {
// Over budget: skip flex expansion, emit as-is
assemble_left(&active, separator, ctx.color_enabled)
} else {
flex::flex_expand(&mut active, ctx, separator);
assemble_left(&active, separator, ctx.color_enabled)
@@ -175,3 +320,35 @@ fn section_meta(id: &str, config: &Config) -> (u8, bool) {
_ => (2, false), // custom sections default
}
}
/// Look up the SectionBase for a given section ID.
/// Returns None for spacers and unknown custom sections.
fn section_base<'a>(id: &str, config: &'a Config) -> Option<&'a SectionBase> {
match id {
"model" => Some(&config.sections.model),
"provider" => Some(&config.sections.provider),
"project" => Some(&config.sections.project.base),
"vcs" => Some(&config.sections.vcs.base),
"beads" => Some(&config.sections.beads.base),
"context_bar" => Some(&config.sections.context_bar.base),
"context_usage" => Some(&config.sections.context_usage.base),
"context_remaining" => Some(&config.sections.context_remaining.base),
"tokens_raw" => Some(&config.sections.tokens_raw.base),
"cache_efficiency" => Some(&config.sections.cache_efficiency),
"cost" => Some(&config.sections.cost.base),
"cost_velocity" => Some(&config.sections.cost_velocity),
"token_velocity" => Some(&config.sections.token_velocity),
"cost_trend" => Some(&config.sections.cost_trend.base),
"context_trend" => Some(&config.sections.context_trend.base),
"lines_changed" => Some(&config.sections.lines_changed),
"duration" => Some(&config.sections.duration),
"tools" => Some(&config.sections.tools.base),
"turns" => Some(&config.sections.turns.base),
"load" => Some(&config.sections.load.base),
"version" => Some(&config.sections.version),
"time" => Some(&config.sections.time.base),
"output_style" => Some(&config.sections.output_style),
"hostname" => Some(&config.sections.hostname),
_ => None,
}
}

View File

@@ -1,9 +1,22 @@
use crate::format;
use crate::layout::ActiveSection;
/// Drop priority 3 sections (all at once), then priority 2, until line fits.
/// Dispatch: select drop strategy by name.
pub fn drop_sections(
active: Vec<ActiveSection>,
term_width: u16,
separator: &str,
strategy: &str,
) -> Vec<ActiveSection> {
match strategy {
"gradual" => gradual_drop(active, term_width, separator),
_ => tiered_drop(active, term_width, separator),
}
}
/// Tiered: drop all priority 3 sections at once, then all priority 2.
/// Priority 1 sections never drop.
pub fn priority_drop(
fn tiered_drop(
mut active: Vec<ActiveSection>,
term_width: u16,
separator: &str,
@@ -23,9 +36,42 @@ pub fn priority_drop(
active
}
/// Gradual: drop sections one-by-one.
/// Order: highest priority number first, then widest, then rightmost.
/// Priority 1 sections never drop.
fn gradual_drop(
mut active: Vec<ActiveSection>,
term_width: u16,
separator: &str,
) -> Vec<ActiveSection> {
while line_width(&active, separator) > term_width as usize {
// Find the best candidate to drop
let candidate = active
.iter()
.enumerate()
.filter(|(_, s)| s.priority > 1) // never drop priority 1
.max_by_key(|(idx, s)| {
(
s.priority,
format::display_width(&s.output.raw),
*idx, // rightmost wins ties
)
})
.map(|(idx, _)| idx);
match candidate {
Some(idx) => {
active.remove(idx);
}
None => break, // only priority 1 left
}
}
active
}
/// Calculate total display width including separators.
/// Spacers suppress adjacent separators on both sides.
fn line_width(active: &[ActiveSection], separator: &str) -> usize {
pub fn line_width(active: &[ActiveSection], separator: &str) -> usize {
let sep_w = format::display_width(separator);
let mut total = 0;

View File

@@ -10,6 +10,8 @@ pub mod layout;
pub mod metrics;
pub mod section;
pub mod shell;
pub mod terminal;
pub mod theme;
pub mod transcript;
pub mod trend;
pub mod width;

View File

@@ -21,19 +21,21 @@ impl ComputedMetrics {
m.usage_pct = cw.used_percentage.unwrap_or(0.0);
if let Some(ref usage) = cw.current_usage {
let input = usage.input_tokens.unwrap_or(0);
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);
let total_input = input + cache_create + cache_read;
if total_input > 0 {
m.cache_efficiency_pct = Some(cache_read as f64 / total_input 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)
{
// Prefer API duration (active processing time) over wall-clock
// duration, which includes all idle time between turns.
let active_ms = cost.total_api_duration_ms.or(cost.total_duration_ms);
if let (Some(cost_usd), Some(duration_ms)) = (cost.total_cost_usd, active_ms) {
if duration_ms > 0 {
let minutes = duration_ms as f64 / 60_000.0;
m.cost_velocity = Some(cost_usd / minutes);

View File

@@ -3,6 +3,7 @@ use crate::section::{RenderContext, SectionOutput};
use crate::shell;
use std::time::Duration;
/// Cached format: "open:N,wip:N,ready:N,closed:N"
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.beads.base.enabled {
return None;
@@ -13,31 +14,84 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let ttl = Duration::from_secs(ctx.config.sections.beads.ttl);
let timeout = Duration::from_millis(200);
// --no-shell: serve stale cache only
if ctx.no_shell {
return render_from_summary(ctx, &ctx.cache.get_stale("beads_stats")?);
}
let cached = ctx.cache.get("beads_summary", ttl);
let ttl = Duration::from_secs(ctx.config.sections.beads.ttl);
let cached = ctx.cache.get("beads_stats", ttl);
let summary = cached.or_else(|| {
// Run br ready to get count of ready items
let out = shell::exec_with_timeout(
let out = ctx.shell_results.get("beads").cloned().unwrap_or_else(|| {
shell::exec_gated(
ctx.shell_config,
"br",
&["ready", "--json"],
&["stats", "--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);
)
})?;
let summary = parse_stats(&out)?;
ctx.cache.set("beads_stats", &summary);
Some(summary)
})?;
let count: usize = summary.trim().parse().unwrap_or(0);
if count == 0 {
render_from_summary(ctx, &summary)
}
/// Parse `br stats --json` output into "open:N,wip:N,ready:N,closed:N"
fn parse_stats(json: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(json).ok()?;
let s = v.get("summary")?;
let open = s.get("open_issues")?.as_u64().unwrap_or(0);
let wip = s.get("in_progress_issues")?.as_u64().unwrap_or(0);
let ready = s.get("ready_issues")?.as_u64().unwrap_or(0);
let closed = s.get("closed_issues")?.as_u64().unwrap_or(0);
Some(format!(
"open:{open},wip:{wip},ready:{ready},closed:{closed}"
))
}
fn render_from_summary(ctx: &RenderContext, summary: &str) -> Option<SectionOutput> {
let mut open = 0u64;
let mut wip = 0u64;
let mut ready = 0u64;
let mut closed = 0u64;
for part in summary.split(',') {
if let Some((key, val)) = part.split_once(':') {
let n: u64 = val.parse().unwrap_or(0);
match key {
"open" => open = n,
"wip" => wip = n,
"ready" => ready = n,
"closed" => closed = n,
_ => {}
}
}
}
let cfg = &ctx.config.sections.beads;
let mut parts: Vec<String> = Vec::new();
if cfg.show_ready_count && ready > 0 {
parts.push(format!("{ready} ready"));
}
if cfg.show_wip_count && wip > 0 {
parts.push(format!("{wip} wip"));
}
if cfg.show_open_count && open > 0 {
parts.push(format!("{open} open"));
}
if cfg.show_closed_count && closed > 0 {
parts.push(format!("{closed} done"));
}
if parts.is_empty() {
return None;
}
let raw = format!("{count} ready");
let raw = parts.join(" ");
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
} else {

View File

@@ -0,0 +1,59 @@
use crate::color;
use crate::section::{RenderContext, SectionOutput};
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.cloud_profile.enabled {
return None;
}
// AWS: $AWS_PROFILE (most common)
if let Ok(profile) = std::env::var("AWS_PROFILE") {
if !profile.is_empty() {
return render_cloud(ctx, "aws", &profile);
}
}
// GCP: $CLOUDSDK_CORE_PROJECT or $GCLOUD_PROJECT
for var in &["CLOUDSDK_CORE_PROJECT", "GCLOUD_PROJECT"] {
if let Ok(project) = std::env::var(var) {
if !project.is_empty() {
return render_cloud(ctx, "gcp", &project);
}
}
}
// Azure: $AZURE_SUBSCRIPTION_ID (lightweight check)
if let Ok(sub) = std::env::var("AZURE_SUBSCRIPTION_ID") {
if !sub.is_empty() {
// Truncate subscription ID — they're long UUIDs
let display = if sub.len() > 12 {
format!("{}...", &sub[..8])
} else {
sub
};
return render_cloud(ctx, "az", &display);
}
}
None
}
fn render_cloud(ctx: &RenderContext, provider: &str, name: &str) -> Option<SectionOutput> {
let raw = format!("{provider}:{name}");
let ansi = if ctx.color_enabled {
let provider_color = match provider {
"aws" => "\x1b[38;2;255;153;0m", // AWS orange
"gcp" => "\x1b[38;2;66;133;244m", // GCP blue
"az" => "\x1b[38;2;0;120;212m", // Azure blue
_ => color::DIM,
};
let reset = color::RESET;
let dim = color::DIM;
format!("{provider_color}{provider}{reset}{dim}:{reset}{name}")
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}

View File

@@ -1,23 +1,51 @@
use crate::color;
use crate::config::BarStyle;
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 = ctx
.input
.context_window
.as_ref()?
.used_percentage
.unwrap_or(0.0);
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 cfg = &ctx.config.sections.context_bar;
let thresh = &cfg.thresholds;
let thresh = &ctx.config.sections.context_bar.thresholds;
let color_code = threshold_color(pct, thresh);
// Determine characters based on bar_style
let (fill_ch, empty_ch) = match cfg.bar_style {
BarStyle::Block => {
let f = cfg.fill_char.as_deref().unwrap_or("\u{2588}");
let e = cfg.empty_char.as_deref().unwrap_or("\u{2591}");
(f, e)
}
BarStyle::Classic => {
let f = cfg.fill_char.as_deref().unwrap_or("=");
let e = cfg.empty_char.as_deref().unwrap_or("-");
(f, e)
}
};
// Build raw string (always plain, no ANSI)
let bar_raw = fill_ch.repeat(filled) + &empty_ch.repeat(empty);
let raw = format!("[{bar_raw}] {pct_int}%");
let ansi = if ctx.color_enabled {
format!("{color_code}[{bar}] {pct_int}%{}", color::RESET)
if cfg.gradient && matches!(cfg.bar_style, BarStyle::Block) {
// Per-character gradient coloring
render_gradient_bar(filled, empty, bar_width, fill_ch, empty_ch, pct_int)
} else {
// Single threshold color for entire bar
let color_code = threshold_color(pct, thresh);
format!("{color_code}[{bar_raw}] {pct_int}%{}", color::RESET)
}
} else {
raw.clone()
};
@@ -25,6 +53,41 @@ pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOut
Some(SectionOutput { raw, ansi })
}
/// Build a gradient-colored context bar. Each filled char gets a color
/// from a green→yellow→red gradient based on its position.
fn render_gradient_bar(
filled: usize,
empty: usize,
bar_width: u16,
fill_ch: &str,
empty_ch: &str,
pct_int: u16,
) -> String {
let grad = color::make_gradient(&["#50fa7b", "#f1fa8c", "#ff5555"]);
let bw = bar_width as f32;
let mut result = String::with_capacity(filled * 20 + empty * 10 + 20);
result.push('[');
for i in 0..filled {
let t = if bw > 1.0 { i as f32 / (bw - 1.0) } else { 0.0 };
result.push_str(&color::sample_fg(&grad, t));
result.push_str(fill_ch);
}
if empty > 0 {
result.push_str(color::DIM);
for _ in 0..empty {
result.push_str(empty_ch);
}
}
result.push_str(color::RESET);
result.push_str(&format!("] {pct_int}%"));
result
}
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.context_bar.base.enabled {
return None;

View File

@@ -8,19 +8,38 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let pct = ctx.input.context_window.as_ref()?.used_percentage?;
let pct = ctx
.input
.context_window
.as_ref()?
.used_percentage
.unwrap_or(0.0);
let pct_int = pct.round() as i64;
let width = ctx.config.sections.context_trend.width as usize;
let csv = trend::append(
// Track context consumption *rate* (delta between samples) so the
// sparkline shows how fast context is being eaten, not just "it goes up".
let csv = trend::append_delta(
ctx.cache,
"context",
pct_int,
width,
Duration::from_secs(30),
)?;
let spark = trend::sparkline(&csv, width);
// Try gradient sparkline first (green→yellow→red for context consumption rate)
if ctx.color_enabled && ctx.config.sections.context_trend.gradient {
let grad = color::make_gradient(&["#50fa7b", "#f1fa8c", "#ff5555"]);
if let Some((raw, ansi)) = trend::sparkline_colored(&csv, width, &grad) {
if !raw.is_empty() {
return Some(SectionOutput { raw, ansi });
}
}
}
// Fallback: plain sparkline with single threshold color
let spark = trend::sparkline(&csv, width);
if spark.is_empty() {
return None;
}

View File

@@ -8,18 +8,32 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
}
let cw = ctx.input.context_window.as_ref()?;
let pct = cw.used_percentage?;
let pct_int = pct.round() as u64;
let pct = cw.used_percentage.unwrap_or(0.0);
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);
// Prefer current_usage fields (input + cache tokens = actual context size).
// Fall back to percentage-derived estimate when current_usage is absent.
let used = cw
.current_usage
.as_ref()
.and_then(|cu| {
let input = cu.input_tokens.unwrap_or(0);
let cache_create = cu.cache_creation_input_tokens.unwrap_or(0);
let cache_read = cu.cache_read_input_tokens.unwrap_or(0);
let sum = input + cache_create + cache_read;
if sum > 0 {
Some(sum)
} else {
None
}
})
.unwrap_or_else(|| (pct / 100.0 * capacity as f64).round() as u64);
let raw = format!(
"{}/{} ({pct_int}%)",
"{}/{} ({pct:.1}%)",
format::human_tokens(used),
format::human_tokens(capacity),
);

View File

@@ -7,7 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?;
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd.unwrap_or(0.0);
let decimals = match ctx.width_tier {
WidthTier::Narrow => 0,

View File

@@ -8,19 +8,35 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?;
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd.unwrap_or(0.0);
let cost_cents = (cost_val * 100.0) as i64;
let width = ctx.config.sections.cost_trend.width as usize;
let csv = trend::append(
// Track cost *rate* (delta between samples) so the sparkline shows
// burn intensity over time, not just "cost goes up".
let csv = trend::append_delta(
ctx.cache,
"cost",
cost_cents,
width,
Duration::from_secs(30),
)?;
let spark = trend::sparkline(&csv, width);
// Try gradient sparkline (green→yellow→red — higher burn = more red)
if ctx.color_enabled && ctx.config.sections.cost_trend.gradient {
let grad = color::make_gradient(&["#50fa7b", "#f1fa8c", "#ff5555"]);
if let Some((spark_raw, spark_ansi)) = trend::sparkline_colored(&csv, width, &grad) {
if !spark_raw.is_empty() {
let raw = format!("${spark_raw}");
let ansi = format!("${spark_ansi}");
return Some(SectionOutput { raw, ansi });
}
}
}
// Fallback: plain sparkline with DIM
let spark = trend::sparkline(&csv, width);
if spark.is_empty() {
return None;
}

View File

@@ -6,7 +6,8 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let velocity = ctx.metrics.cost_velocity?;
ctx.input.cost.as_ref()?;
let velocity = ctx.metrics.cost_velocity.unwrap_or(0.0);
let raw = format!("${velocity:.2}/min");
let ansi = if ctx.color_enabled {

View File

@@ -1,4 +1,5 @@
use crate::color;
use crate::config::CustomCommand;
use crate::section::{RenderContext, SectionOutput};
use crate::shell;
use std::time::Duration;
@@ -7,10 +8,16 @@ use std::time::Duration;
pub fn render(id: &str, ctx: &RenderContext) -> Option<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}");
// --no-shell: serve stale cache only
if ctx.no_shell {
let output_str = ctx.cache.get_stale(&cache_key)?;
return render_output(ctx, cmd_cfg, &output_str);
}
let ttl = Duration::from_secs(cmd_cfg.ttl);
let cached = ctx.cache.get(&cache_key, ttl);
let output_str = cached.or_else(|| {
let result = if let Some(ref exec) = cmd_cfg.exec {
@@ -18,9 +25,9 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let args: Vec<&str> = exec[1..].iter().map(|s| s.as_str()).collect();
shell::exec_with_timeout(&exec[0], &args, None, timeout)
shell::exec_gated(ctx.shell_config, &exec[0], &args, None)
} else if let Some(ref command) = cmd_cfg.command {
shell::exec_with_timeout("sh", &["-c", command], None, timeout)
shell::exec_gated(ctx.shell_config, "sh", &["-c", command], None)
} else {
None
};
@@ -34,16 +41,29 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
render_output(ctx, cmd_cfg, &output_str)
}
/// Shared rendering: label + color logic used by both live and stale-cache paths.
fn render_output(
ctx: &RenderContext,
cmd_cfg: &CustomCommand,
output_str: &str,
) -> Option<SectionOutput> {
if output_str.is_empty() {
return None;
}
let label = cmd_cfg.label.as_deref().unwrap_or("");
let raw = if label.is_empty() {
output_str.clone()
output_str.to_string()
} else {
format!("{label}: {output_str}")
};
let ansi = if ctx.color_enabled {
if let Some(ref color_cfg) = cmd_cfg.color {
if let Some(matched_color) = color_cfg.match_map.get(&output_str) {
if let Some(matched_color) = color_cfg.match_map.get(output_str) {
let c = color::resolve_color(matched_color, ctx.theme, &ctx.config.colors);
format!("{c}{raw}{}", color::RESET)
} else if let Some(ref default_c) = cmd_cfg.default_color {

View File

@@ -7,10 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let ms = ctx.input.cost.as_ref()?.total_duration_ms?;
if ms == 0 {
return None;
}
let ms = ctx.input.cost.as_ref()?.total_duration_ms.unwrap_or(0);
let raw = format::human_duration(ms);
let ansi = if ctx.color_enabled {

134
src/section/k8s_context.rs Normal file
View File

@@ -0,0 +1,134 @@
use crate::color;
use crate::section::{RenderContext, SectionOutput};
use std::time::Duration;
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.k8s_context.base.enabled {
return None;
}
let ttl = Duration::from_secs(ctx.config.sections.k8s_context.ttl);
let cached = ctx.cache.get("k8s_context", ttl);
let context_str = cached.or_else(|| {
let val = read_kubeconfig_context()?;
ctx.cache.set("k8s_context", &val);
Some(val)
})?;
// context_str format: "context" or "context/namespace"
if context_str.is_empty() {
return None;
}
let raw = context_str.clone();
let ansi = if ctx.color_enabled {
// Split context and namespace for different coloring
if let Some((context, ns)) = context_str.split_once('/') {
let reset = color::RESET;
let dim = color::DIM;
format!("\x1b[38;2;50;150;250m{context}{reset}{dim}/{ns}{reset}")
} else {
format!("\x1b[38;2;50;150;250m{context_str}{}", color::RESET)
}
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}
/// Read current-context (and optionally namespace) from kubeconfig.
/// Parses YAML minimally without a full YAML parser dependency.
fn read_kubeconfig_context() -> Option<String> {
let home = std::env::var("HOME").ok()?;
let kubeconfig = std::env::var("KUBECONFIG")
.ok()
.unwrap_or_else(|| format!("{home}/.kube/config"));
// Only read the first kubeconfig file if multiple are specified
let path = kubeconfig.split(':').next()?;
let content = std::fs::read_to_string(path).ok()?;
// Extract current-context with simple line scanning (no YAML dep)
let current_context = content
.lines()
.find(|line| line.starts_with("current-context:"))?
.trim_start_matches("current-context:")
.trim()
.trim_matches('"')
.to_string();
if current_context.is_empty() {
return None;
}
// Try to find the namespace for this context in the contexts list.
// Look for the context entry and its namespace field.
let ns = find_context_namespace(&content, &current_context);
if let Some(ns) = ns {
if !ns.is_empty() && ns != "default" {
return Some(format!("{current_context}/{ns}"));
}
}
Some(current_context)
}
/// Minimal YAML scanning to find namespace for a given context name.
fn find_context_namespace(content: &str, context_name: &str) -> Option<String> {
let mut in_contexts = false;
let mut found_context = false;
let mut in_context_block = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "contexts:" {
in_contexts = true;
continue;
}
if in_contexts && !line.starts_with(' ') && !line.starts_with('-') && !trimmed.is_empty() {
// Left the contexts block
break;
}
if in_contexts {
// Look for "- name: <context_name>"
if trimmed.starts_with("- name:") || trimmed == "- name:" {
let name = trimmed
.trim_start_matches("- name:")
.trim()
.trim_matches('"');
found_context = name == context_name;
in_context_block = false;
continue;
}
if found_context && trimmed.starts_with("context:") {
in_context_block = true;
continue;
}
if found_context && in_context_block && trimmed.starts_with("namespace:") {
return Some(
trimmed
.trim_start_matches("namespace:")
.trim()
.trim_matches('"')
.to_string(),
);
}
// New entry starts
if trimmed.starts_with("- ") && found_context {
break;
}
}
}
None
}

View File

@@ -7,6 +7,18 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
// --no-shell: serve stale cache only
if ctx.no_shell {
let load_str = ctx.cache.get_stale("load_avg")?;
let raw = format!("load {load_str}");
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
} else {
raw.clone()
};
return Some(SectionOutput { raw, ansi });
}
let ttl = Duration::from_secs(ctx.config.sections.load.ttl);
let cached = ctx.cache.get("load_avg", ttl);
@@ -21,12 +33,10 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
}
#[cfg(target_os = "macos")]
{
let out = crate::shell::exec_with_timeout(
"sysctl",
&["-n", "vm.loadavg"],
None,
Duration::from_millis(100),
)?;
// Use prefetched result if available, otherwise exec
let out = ctx.shell_results.get("load").cloned().unwrap_or_else(|| {
crate::shell::exec_gated(ctx.shell_config, "sysctl", &["-n", "vm.loadavg"], None)
})?;
// sysctl output: "{ 1.23 4.56 7.89 }"
let load1 = out
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.')

View File

@@ -2,13 +2,16 @@ use crate::cache::Cache;
use crate::config::Config;
use crate::input::InputData;
use crate::metrics::ComputedMetrics;
use crate::shell::ShellConfig;
use crate::theme::Theme;
use crate::transcript::TranscriptStats;
use crate::width::WidthTier;
use std::path::Path;
pub mod beads;
pub mod cache_efficiency;
pub mod cloud_profile;
pub mod context_bar;
pub mod context_remaining;
pub mod context_trend;
@@ -19,15 +22,18 @@ pub mod cost_velocity;
pub mod custom;
pub mod duration;
pub mod hostname;
pub mod k8s_context;
pub mod lines_changed;
pub mod load;
pub mod model;
pub mod output_style;
pub mod project;
pub mod provider;
pub mod python_env;
pub mod time;
pub mod token_velocity;
pub mod tokens_raw;
pub mod toolchain;
pub mod tools;
pub mod turns;
pub mod vcs;
@@ -62,36 +68,297 @@ pub struct RenderContext<'a> {
pub cache: &'a Cache,
pub glyphs_enabled: bool,
pub color_enabled: bool,
pub no_shell: bool,
pub shell_config: &'a ShellConfig,
pub metrics: ComputedMetrics,
pub budget_start: Option<std::time::Instant>,
pub budget_ms: u64,
pub shell_results: std::collections::HashMap<String, Option<String>>,
pub transcript_stats: Option<TranscriptStats>,
pub terminal_palette: Option<Vec<(u8, u8, u8)>>,
}
/// Build the registry of all built-in sections.
pub fn registry() -> Vec<(&'static str, RenderFn)> {
impl RenderContext<'_> {
/// Check if the render budget has been exceeded.
/// Returns false if budget_ms == 0 (unlimited) or budget_start is None.
pub fn budget_exceeded(&self) -> bool {
if self.budget_ms == 0 {
return false;
}
if let Some(start) = self.budget_start {
start.elapsed().as_millis() as u64 >= self.budget_ms
} else {
false
}
}
}
/// Metadata for layout planning, CLI introspection, and render budgeting.
pub struct SectionDescriptor {
pub id: &'static str,
pub render: RenderFn,
pub priority: u8,
pub is_spacer: bool,
pub is_flex: bool,
pub estimated_width: u16,
pub shell_out: bool,
}
/// Build the registry of all built-in sections with metadata.
pub fn registry() -> Vec<SectionDescriptor> {
vec![
("model", model::render),
("provider", provider::render),
("project", project::render),
("vcs", vcs::render),
("beads", beads::render),
("context_bar", context_bar::render),
("context_usage", context_usage::render),
("context_remaining", context_remaining::render),
("tokens_raw", tokens_raw::render),
("cache_efficiency", cache_efficiency::render),
("cost", cost::render),
("cost_velocity", cost_velocity::render),
("token_velocity", token_velocity::render),
("cost_trend", cost_trend::render),
("context_trend", context_trend::render),
("lines_changed", lines_changed::render),
("duration", duration::render),
("tools", tools::render),
("turns", turns::render),
("load", load::render),
("version", version::render),
("time", time::render),
("output_style", output_style::render),
("hostname", hostname::render),
SectionDescriptor {
id: "model",
render: model::render,
priority: 1,
is_spacer: false,
is_flex: false,
estimated_width: 12,
shell_out: false,
},
SectionDescriptor {
id: "provider",
render: provider::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: false,
},
SectionDescriptor {
id: "project",
render: project::render,
priority: 1,
is_spacer: false,
is_flex: false,
estimated_width: 20,
shell_out: false,
},
SectionDescriptor {
id: "vcs",
render: vcs::render,
priority: 1,
is_spacer: false,
is_flex: false,
estimated_width: 15,
shell_out: true,
},
SectionDescriptor {
id: "beads",
render: beads::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 25,
shell_out: true,
},
SectionDescriptor {
id: "context_bar",
render: context_bar::render,
priority: 1,
is_spacer: false,
is_flex: true,
estimated_width: 18,
shell_out: false,
},
SectionDescriptor {
id: "context_usage",
render: context_usage::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 12,
shell_out: false,
},
SectionDescriptor {
id: "context_remaining",
render: context_remaining::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: false,
},
SectionDescriptor {
id: "tokens_raw",
render: tokens_raw::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 18,
shell_out: false,
},
SectionDescriptor {
id: "cache_efficiency",
render: cache_efficiency::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: false,
},
SectionDescriptor {
id: "cost",
render: cost::render,
priority: 1,
is_spacer: false,
is_flex: false,
estimated_width: 8,
shell_out: false,
},
SectionDescriptor {
id: "cost_velocity",
render: cost_velocity::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: false,
},
SectionDescriptor {
id: "token_velocity",
render: token_velocity::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 14,
shell_out: false,
},
SectionDescriptor {
id: "cost_trend",
render: cost_trend::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 8,
shell_out: false,
},
SectionDescriptor {
id: "context_trend",
render: context_trend::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 8,
shell_out: false,
},
SectionDescriptor {
id: "lines_changed",
render: lines_changed::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: false,
},
SectionDescriptor {
id: "duration",
render: duration::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 5,
shell_out: false,
},
SectionDescriptor {
id: "tools",
render: tools::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 15,
shell_out: false,
},
SectionDescriptor {
id: "turns",
render: turns::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 8,
shell_out: false,
},
SectionDescriptor {
id: "load",
render: load::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: true,
},
SectionDescriptor {
id: "version",
render: version::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 8,
shell_out: false,
},
SectionDescriptor {
id: "time",
render: time::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 5,
shell_out: false,
},
SectionDescriptor {
id: "output_style",
render: output_style::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: false,
},
SectionDescriptor {
id: "hostname",
render: hostname::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 10,
shell_out: false,
},
SectionDescriptor {
id: "cloud_profile",
render: cloud_profile::render,
priority: 2,
is_spacer: false,
is_flex: false,
estimated_width: 15,
shell_out: false,
},
SectionDescriptor {
id: "k8s_context",
render: k8s_context::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 15,
shell_out: false,
},
SectionDescriptor {
id: "python_env",
render: python_env::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 12,
shell_out: false,
},
SectionDescriptor {
id: "toolchain",
render: toolchain::render,
priority: 3,
is_spacer: false,
is_flex: false,
estimated_width: 12,
shell_out: false,
},
]
}
@@ -104,9 +371,9 @@ pub fn render_section(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
});
}
for (name, render_fn) in registry() {
if name == id {
return render_fn(ctx);
for desc in registry() {
if desc.id == id {
return (desc.render)(ctx);
}
}

View File

@@ -10,7 +10,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
let raw = style_name.to_string();
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
format!("{}{raw}{}", color::MAGENTA, color::RESET)
} else {
raw.clone()
};

40
src/section/python_env.rs Normal file
View File

@@ -0,0 +1,40 @@
use crate::color;
use crate::section::{RenderContext, SectionOutput};
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.python_env.enabled {
return None;
}
// $VIRTUAL_ENV — standard venv/virtualenv/pipenv
if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
if !venv.is_empty() {
let name = std::path::Path::new(&venv)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("venv");
return render_env(ctx, name);
}
}
// $CONDA_DEFAULT_ENV — conda environments
if let Ok(conda) = std::env::var("CONDA_DEFAULT_ENV") {
if !conda.is_empty() && conda != "base" {
return render_env(ctx, &conda);
}
}
None
}
fn render_env(ctx: &RenderContext, name: &str) -> Option<SectionOutput> {
let raw = format!("({name})");
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}

View File

@@ -7,7 +7,8 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let velocity = ctx.metrics.token_velocity?;
ctx.input.cost.as_ref()?;
let velocity = ctx.metrics.token_velocity.unwrap_or(0.0);
let raw = format!("{} tok/min", format::human_tokens(velocity as u64));
let ansi = if ctx.color_enabled {

View File

@@ -11,10 +11,6 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
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

127
src/section/toolchain.rs Normal file
View File

@@ -0,0 +1,127 @@
use crate::color;
use crate::section::{RenderContext, SectionOutput};
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.toolchain.enabled {
return None;
}
let dir = ctx.project_dir;
// Try Rust toolchain detection
if let Some(out) = detect_rust(dir) {
return Some(out.render(ctx));
}
// Try Node.js version detection
if let Some(out) = detect_node(dir) {
return Some(out.render(ctx));
}
None
}
struct ToolchainInfo {
lang: &'static str,
expected: String,
actual: Option<String>,
}
impl ToolchainInfo {
fn render(&self, ctx: &RenderContext) -> SectionOutput {
let mismatch = self
.actual
.as_ref()
.is_some_and(|a| !self.expected.contains(a.as_str()));
let raw = if mismatch {
format!(
"{} {} (want {})",
self.lang,
self.actual.as_deref().unwrap_or("?"),
self.expected
)
} else {
format!("{} {}", self.lang, self.expected)
};
let ansi = if ctx.color_enabled {
if mismatch {
format!("{}{raw}{}", color::YELLOW, color::RESET)
} else {
format!("{}{raw}{}", color::DIM, color::RESET)
}
} else {
raw.clone()
};
SectionOutput { raw, ansi }
}
}
fn detect_rust(dir: &std::path::Path) -> Option<ToolchainInfo> {
// Check rust-toolchain.toml first, then rust-toolchain
let toml_path = dir.join("rust-toolchain.toml");
let legacy_path = dir.join("rust-toolchain");
let expected = if toml_path.is_file() {
parse_rust_toolchain_toml(&toml_path)?
} else if legacy_path.is_file() {
std::fs::read_to_string(&legacy_path)
.ok()?
.trim()
.to_string()
} else {
return None;
};
let actual = std::env::var("RUSTUP_TOOLCHAIN").ok();
Some(ToolchainInfo {
lang: "rs",
expected,
actual,
})
}
/// Parse channel from rust-toolchain.toml (e.g., `channel = "nightly-2025-01-01"`)
fn parse_rust_toolchain_toml(path: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("channel") {
let val = trimmed.split('=').nth(1)?.trim().trim_matches('"');
return Some(val.to_string());
}
}
None
}
fn detect_node(dir: &std::path::Path) -> Option<ToolchainInfo> {
// Check .nvmrc, .node-version, or package.json engines.node
let expected = read_file_trimmed(&dir.join(".nvmrc"))
.or_else(|| read_file_trimmed(&dir.join(".node-version")))?;
// Strip leading 'v' for comparison
let expected_clean = expected.strip_prefix('v').unwrap_or(&expected).to_string();
let actual = std::env::var("NODE_VERSION")
.ok()
.map(|v| v.strip_prefix('v').unwrap_or(&v).to_string());
Some(ToolchainInfo {
lang: "node",
expected: expected_clean,
actual,
})
}
fn read_file_trimmed(path: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
let trimmed = content.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}

View File

@@ -1,33 +1,229 @@
use crate::color;
use crate::format;
use crate::section::{RenderContext, SectionOutput};
use crate::width::WidthTier;
const DEFAULT_PALETTE: &[(u8, u8, u8)] = &[
(139, 233, 253), // cyan
(80, 250, 123), // green
(189, 147, 249), // purple
(255, 121, 198), // pink
(255, 184, 108), // orange
(241, 250, 140), // yellow
(255, 85, 85), // red
(98, 173, 255), // blue
];
/// Resolve the tool color palette.
/// Priority: config palette (explicit user choice) > terminal ANSI colors > built-in default.
fn resolve_palette(
cfg: &crate::config::ToolsSection,
terminal_palette: &Option<Vec<(u8, u8, u8)>>,
) -> Vec<(u8, u8, u8)> {
// Explicit config palette takes priority (user chose these colors)
if !cfg.palette.is_empty() {
let parsed: Vec<(u8, u8, u8)> = cfg
.palette
.iter()
.filter_map(|s| color::parse_hex(s))
.collect();
if !parsed.is_empty() {
return parsed;
}
}
// Terminal-queried ANSI palette (auto-matches theme)
if let Some(tp) = terminal_palette {
if !tp.is_empty() {
return tp.clone();
}
}
DEFAULT_PALETTE.to_vec()
}
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);
let cfg = &ctx.config.sections.tools;
// Prefer cost.total_tool_uses (forward compat if Claude Code adds it),
// fall back to transcript-derived stats.
let (count, last_name, tool_counts) = if let Some(cost) = ctx.input.cost.as_ref() {
if let Some(n) = cost.total_tool_uses {
(n, cost.last_tool_name.clone(), Vec::new())
} else if let Some(ts) = &ctx.transcript_stats {
(
ts.total_tool_uses,
ts.last_tool_name.clone(),
ts.tool_counts.clone(),
)
} else {
(0, None, Vec::new())
}
} else if let Some(ts) = &ctx.transcript_stats {
(
ts.total_tool_uses,
ts.last_tool_name.clone(),
ts.tool_counts.clone(),
)
} else {
return None;
};
let label = if count == 1 { "tool" } else { "tools" };
if count == 0 {
return None;
let raw = "0 tools".to_string();
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
} else {
raw.clone()
};
return Some(SectionOutput { raw, ansi });
}
let last = if ctx.config.sections.tools.show_last_name {
cost.last_tool_name
// Progressive disclosure based on terminal width:
// narrow: "245"
// medium: "245 tools (Bash)" — last tool only
// wide: "245 tools (Bash: 93/Read: 45/...)" — full breakdown, adaptive count
match ctx.width_tier {
WidthTier::Narrow => {
let raw = count.to_string();
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}
WidthTier::Medium => {
let detail = if cfg.show_last_name {
last_name
.as_deref()
.map(|n| format!(" ({n})"))
.unwrap_or_default()
} else {
String::new()
};
let raw = format!("{count} tools{last}");
let raw = format!("{count} {label}{detail}");
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}
WidthTier::Wide => {
if cfg.show_breakdown && !tool_counts.is_empty() {
render_breakdown(ctx, count, label, &tool_counts, cfg)
} else {
let detail = if cfg.show_last_name {
last_name
.as_deref()
.map(|n| format!(" ({n})"))
.unwrap_or_default()
} else {
String::new()
};
let raw = format!("{count} {label}{detail}");
let ansi = if ctx.color_enabled {
format!("{}{raw}{}", color::DIM, color::RESET)
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}
}
}
}
/// Render the full breakdown, adaptively reducing the number of tools shown
/// until it fits within a width budget (1/3 of terminal width).
fn render_breakdown(
ctx: &RenderContext,
count: u64,
label: &str,
tool_counts: &[(String, u64)],
cfg: &crate::config::ToolsSection,
) -> Option<SectionOutput> {
let max_limit = if cfg.top_n == 0 {
tool_counts.len()
} else {
cfg.top_n.min(tool_counts.len())
};
// Width budget: tools section shouldn't dominate the line
let budget = (ctx.term_width as usize) / 3;
// Try from max_limit down to 1, find the largest that fits the budget
let mut limit = max_limit;
loop {
let raw = build_raw(count, label, tool_counts, limit);
let width = format::display_width(&raw);
if width <= budget || limit <= 1 {
break;
}
limit -= 1;
}
let raw = build_raw(count, label, tool_counts, limit);
let ansi = if ctx.color_enabled {
let palette = resolve_palette(cfg, &ctx.terminal_palette);
build_ansi(count, label, tool_counts, limit, &palette)
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}
fn build_raw(count: u64, label: &str, tool_counts: &[(String, u64)], limit: usize) -> String {
let parts: Vec<String> = tool_counts[..limit]
.iter()
.map(|(name, c)| format!("{name}: {c}"))
.collect();
let shown: u64 = tool_counts[..limit].iter().map(|(_, c)| *c).sum();
let rest = count.saturating_sub(shown);
let mut detail = parts.join("/");
if rest > 0 {
detail.push_str(&format!(" +{rest}"));
}
format!("{count} {label} ({detail})")
}
fn build_ansi(
count: u64,
label: &str,
tool_counts: &[(String, u64)],
limit: usize,
palette: &[(u8, u8, u8)],
) -> String {
let ansi_parts: Vec<String> = tool_counts[..limit]
.iter()
.enumerate()
.map(|(i, (name, c))| {
let (r, g, b) = palette[i % palette.len()];
format!("\x1b[38;2;{r};{g};{b}m{name}{}: {c}", color::RESET)
})
.collect();
let sep = format!("{}/{}", color::DIM, color::RESET);
let mut ansi_detail = ansi_parts.join(&sep);
let shown: u64 = tool_counts[..limit].iter().map(|(_, c)| *c).sum();
let rest = count.saturating_sub(shown);
if rest > 0 {
ansi_detail.push_str(&format!("{} +{rest}{}", color::DIM, color::RESET));
}
format!(
"{}{count} {label}{} ({ansi_detail}{}){}",
color::DIM,
color::RESET,
color::DIM,
color::RESET
)
}

View File

@@ -6,11 +6,22 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
let count = ctx.input.cost.as_ref()?.total_turns?;
if count == 0 {
// Prefer cost.total_turns (forward compat if Claude Code adds it),
// fall back to transcript-derived stats.
// Require at least one data source to exist (cost or transcript).
// When neither exists (no session data at all), vanish.
if ctx.input.cost.is_none() && ctx.transcript_stats.is_none() {
return None;
}
let count = ctx
.input
.cost
.as_ref()
.and_then(|c| c.total_turns)
.or_else(|| ctx.transcript_stats.as_ref().map(|ts| ts.total_turns))
.unwrap_or(0);
let label = if count == 1 { "turn" } else { "turns" };
let raw = format!("{count} {label}");

View File

@@ -3,7 +3,6 @@ use crate::glyph;
use crate::section::{RenderContext, SectionOutput, VcsType};
use crate::shell::{self, GitStatusV2};
use crate::width::WidthTier;
use std::time::Duration;
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.vcs.base.enabled {
@@ -13,6 +12,11 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None;
}
// --no-shell: serve stale cache only, skip all git/jj commands
if ctx.no_shell {
return render_stale_cache(ctx);
}
let dir = ctx.project_dir.to_str()?;
let ttl = &ctx.config.sections.vcs.ttl;
let glyphs = &ctx.config.glyphs;
@@ -24,28 +28,51 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
}
}
/// Serve stale cached VCS data without running any commands.
fn render_stale_cache(ctx: &RenderContext) -> Option<SectionOutput> {
let branch_raw = ctx.cache.get_stale("vcs_branch")?;
let trunc = &ctx.config.sections.vcs.truncate;
let branch = if trunc.enabled && trunc.max > 0 {
crate::format::truncate(&branch_raw, trunc.max, &trunc.style)
} else {
branch_raw
};
let branch_glyph = glyph::glyph("branch", &ctx.config.glyphs);
let raw = format!("{branch_glyph}{branch}");
let ansi = if ctx.color_enabled {
format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET)
} else {
raw.clone()
};
Some(SectionOutput { raw, ansi })
}
fn render_git(
ctx: &RenderContext,
dir: &str,
ttl: &crate::config::VcsTtl,
glyphs: &crate::config::GlyphConfig,
) -> Option<SectionOutput> {
use std::time::Duration;
let branch_ttl = Duration::from_secs(ttl.branch);
let dirty_ttl = Duration::from_secs(ttl.dirty);
let ab_ttl = Duration::from_secs(ttl.ahead_behind);
let timeout = Duration::from_millis(200);
let branch_cached = ctx.cache.get("vcs_branch", branch_ttl);
let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl);
let ab_cached = ctx.cache.get("vcs_ab", ab_ttl);
let status = if branch_cached.is_none() || dirty_cached.is_none() || ab_cached.is_none() {
let output = shell::exec_with_timeout(
// Use prefetched result if available, otherwise exec
let output = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
shell::exec_gated(
ctx.shell_config,
"git",
&["-C", dir, "status", "--porcelain=v2", "--branch"],
None,
timeout,
);
)
});
match output {
Some(ref out) => {
let s = shell::parse_git_status_v2(out);
@@ -82,7 +109,13 @@ fn render_git(
}
};
let branch = status.branch.as_deref().unwrap_or("?");
let branch_raw = status.branch.as_deref().unwrap_or("?");
let trunc = &ctx.config.sections.vcs.truncate;
let branch = if trunc.enabled && trunc.max > 0 {
crate::format::truncate(branch_raw, trunc.max, &trunc.style)
} else {
branch_raw.to_string()
};
let branch_glyph = glyph::glyph("branch", glyphs);
let dirty_glyph = if status.is_dirty && ctx.config.sections.vcs.show_dirty {
glyph::glyph("dirty", glyphs)
@@ -128,15 +161,19 @@ fn render_git(
fn render_jj(
ctx: &RenderContext,
_dir: &str,
dir: &str,
ttl: &crate::config::VcsTtl,
glyphs: &crate::config::GlyphConfig,
) -> Option<SectionOutput> {
use std::time::Duration;
let branch_ttl = Duration::from_secs(ttl.branch);
let timeout = Duration::from_millis(200);
let branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| {
let out = shell::exec_with_timeout(
// Use prefetched result if available, otherwise exec
let out = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
shell::exec_gated(
ctx.shell_config,
"jj",
&[
"log",
@@ -147,13 +184,20 @@ fn render_jj(
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
"--color=never",
],
None,
timeout,
)?;
Some(dir),
)
})?;
ctx.cache.set("vcs_branch", &out);
Some(out)
})?;
let trunc = &ctx.config.sections.vcs.truncate;
let branch = if trunc.enabled && trunc.max > 0 {
crate::format::truncate(&branch, trunc.max, &trunc.style)
} else {
branch
};
let branch_glyph = glyph::glyph("branch", glyphs);
let raw = format!("{branch_glyph}{branch}");
let ansi = if ctx.color_enabled {

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
@@ -8,6 +9,166 @@ const GIT_ENV: &[(&str, &str)] = &[
("LC_ALL", "C"),
];
/// Shell execution configuration for gated access.
pub struct ShellConfig {
pub enabled: bool,
pub allowlist: Vec<String>,
pub denylist: Vec<String>,
pub timeout: Duration,
pub max_output_bytes: usize,
pub env: HashMap<String, String>,
pub failure_threshold: u8,
pub cooldown_ms: u64,
}
// ── Circuit breaker ─────────────────────────────────────────────────────
use std::sync::Mutex;
struct CircuitState {
failures: u8,
cooldown_until: Option<Instant>,
}
static BREAKER: Mutex<Option<HashMap<String, CircuitState>>> = Mutex::new(None);
fn circuit_check(program: &str, threshold: u8) -> bool {
if threshold == 0 {
return true; // disabled
}
let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner());
let map = guard.get_or_insert_with(HashMap::new);
if let Some(state) = map.get(program) {
if let Some(until) = state.cooldown_until {
if Instant::now() < until {
return false; // in cooldown
}
// Cooldown expired: allow retry
map.remove(program);
}
}
true
}
fn circuit_record_success(program: &str) {
let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner());
if let Some(map) = guard.as_mut() {
map.remove(program);
}
}
fn circuit_record_failure(program: &str, threshold: u8, cooldown_ms: u64) {
if threshold == 0 {
return;
}
let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner());
let map = guard.get_or_insert_with(HashMap::new);
let state = map.entry(program.to_string()).or_insert(CircuitState {
failures: 0,
cooldown_until: None,
});
state.failures = state.failures.saturating_add(1);
if state.failures >= threshold {
state.cooldown_until = Some(Instant::now() + Duration::from_millis(cooldown_ms));
}
}
/// Execute a command through the gated shell pipeline.
/// Checks: enabled -> denylist -> allowlist -> execute with env merge -> truncate.
/// Returns None if gated out (caller falls back to stale cache).
pub fn exec_gated(
config: &ShellConfig,
program: &str,
args: &[&str],
dir: Option<&str>,
) -> Option<String> {
// 1. Global kill switch
if !config.enabled {
return None;
}
// 2. Denylist (wins over allowlist)
if config.denylist.iter().any(|d| d == program) {
return None;
}
// 3. Allowlist (empty = all allowed)
if !config.allowlist.is_empty() && !config.allowlist.iter().any(|a| a == program) {
return None;
}
// 4. Circuit breaker check
if !circuit_check(program, config.failure_threshold) {
return None;
}
// 5. Execute with merged env
let result = exec_with_timeout_env(program, args, dir, config.timeout, &config.env);
match result {
Some(ref output) => {
circuit_record_success(program);
// 6. Truncate output
if config.max_output_bytes > 0 && output.len() > config.max_output_bytes {
let truncated = &output.as_bytes()[..config.max_output_bytes];
Some(String::from_utf8_lossy(truncated).into_owned())
} else {
Some(output.clone())
}
}
None => {
circuit_record_failure(program, config.failure_threshold, config.cooldown_ms);
None
}
}
}
/// Execute with timeout and optional extra env vars.
fn exec_with_timeout_env(
program: &str,
args: &[&str],
dir: Option<&str>,
timeout: Duration,
extra_env: &HashMap<String, String>,
) -> 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);
}
}
// shell_env overrides GIT_ENV for same key (intentional: user override)
for (k, v) in extra_env {
cmd.env(k, v);
}
let mut child = cmd.spawn().ok()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return None;
}
let output = child.wait_with_output().ok()?;
return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
return None;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(_) => return None,
}
}
}
/// Execute a command with a polling timeout. Returns None on timeout or error.
pub fn exec_with_timeout(
program: &str,

476
src/terminal.rs Normal file
View File

@@ -0,0 +1,476 @@
use crate::color;
use std::fs::OpenOptions;
use std::io::{Read, Write};
use std::os::unix::io::AsRawFd;
/// Detect the terminal's color palette automatically.
/// Priority: WezTerm config > Kitty config > Alacritty config > OSC 4 query.
pub fn detect_palette() -> Option<Vec<(u8, u8, u8)>> {
if let Some(p) = parse_wezterm_config() {
return Some(p);
}
if let Some(p) = parse_kitty_config() {
return Some(p);
}
if let Some(p) = parse_alacritty_config() {
return Some(p);
}
query_ansi_palette()
}
// ── WezTerm config parsing ──────────────────────────────────────────────
/// Parse WezTerm's Lua config for ANSI color definitions.
/// Checks `WEZTERM_CONFIG_FILE` env var, then common paths.
fn parse_wezterm_config() -> Option<Vec<(u8, u8, u8)>> {
let path = std::env::var("WEZTERM_CONFIG_FILE").ok().or_else(|| {
// Only probe filesystem if WezTerm is the active terminal
if std::env::var("TERM_PROGRAM").ok().as_deref() != Some("WezTerm") {
return None;
}
let home = std::env::var("HOME").ok()?;
let candidates = [
format!("{home}/.wezterm.lua"),
format!("{home}/.config/wezterm/wezterm.lua"),
];
candidates
.into_iter()
.find(|p| std::path::Path::new(p).exists())
})?;
let content = std::fs::read_to_string(&path).ok()?;
extract_wezterm_colors(&content)
}
/// Extract ANSI + bright colors from WezTerm Lua config text.
/// Looks for `ansi = { "#hex", ... }` and `brights = { "#hex", ... }` blocks.
fn extract_wezterm_colors(lua: &str) -> Option<Vec<(u8, u8, u8)>> {
let ansi = extract_lua_color_array(lua, "ansi")?;
let brights = extract_lua_color_array(lua, "brights").unwrap_or_default();
// Skip index 0 (black) and 7 (white) — they're usually bg/fg adjacent.
// Order: cyan, green, purple, red, yellow, blue, then brights.
let mut palette = Vec::new();
// ANSI indices: 1=red, 2=green, 3=yellow, 4=blue, 5=purple, 6=cyan
let ansi_order = [6, 2, 5, 1, 3, 4]; // cyan, green, purple, red, yellow, blue
for &idx in &ansi_order {
if let Some(c) = ansi.get(idx) {
palette.push(*c);
}
}
// Bright variants for additional colors
let bright_order = [6, 2]; // bright cyan, bright green
for &idx in &bright_order {
if let Some(c) = brights.get(idx) {
palette.push(*c);
}
}
if palette.is_empty() {
None
} else {
Some(palette)
}
}
/// Find a Lua key name as a whole word (not matching substrings like "brightness" for "brights").
fn find_lua_key(lua: &str, name: &str) -> Option<usize> {
let bytes = lua.as_bytes();
let mut search_from = 0;
while let Some(rel_pos) = lua[search_from..].find(name) {
let pos = search_from + rel_pos;
let before_ok = pos == 0 || !bytes[pos - 1].is_ascii_alphanumeric();
let after_ok = bytes
.get(pos + name.len())
.is_none_or(|c| !c.is_ascii_alphanumeric());
if before_ok && after_ok {
return Some(pos);
}
search_from = pos + 1;
}
None
}
/// Extract a Lua array of hex color strings: `name = { "#hex", "#hex", ... }`
fn extract_lua_color_array(lua: &str, name: &str) -> Option<Vec<(u8, u8, u8)>> {
// Find `name` as a whole word (not "brightness" matching "brights")
let start = find_lua_key(lua, name)?;
let after_name = &lua[start + name.len()..];
let brace_start = after_name.find('{')?;
let brace_content = &after_name[brace_start + 1..];
let brace_end = brace_content.find('}')?;
let block = &brace_content[..brace_end];
let colors: Vec<(u8, u8, u8)> = block
.split('"')
.filter(|s| s.starts_with('#'))
.filter_map(color::parse_hex)
.collect();
if colors.is_empty() {
None
} else {
Some(colors)
}
}
// ── Kitty config parsing ────────────────────────────────────────────────
/// Parse Kitty's config for ANSI color definitions.
fn parse_kitty_config() -> Option<Vec<(u8, u8, u8)>> {
if std::env::var("TERM_PROGRAM").ok().as_deref() != Some("kitty") {
return None;
}
let home = std::env::var("HOME").ok()?;
let path = format!("{home}/.config/kitty/kitty.conf");
let content = std::fs::read_to_string(path).ok()?;
extract_kitty_colors(&content)
}
/// Extract colors from Kitty config format: `color1 #hex`
fn extract_kitty_colors(conf: &str) -> Option<Vec<(u8, u8, u8)>> {
let mut ansi = [None; 16];
for line in conf.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("color") {
let mut parts = rest.splitn(2, char::is_whitespace);
if let (Some(idx_str), Some(hex)) = (parts.next(), parts.next()) {
if let Ok(idx) = idx_str.parse::<usize>() {
if idx < 16 {
if let Some(rgb) = color::parse_hex(hex.trim()) {
ansi[idx] = Some(rgb);
}
}
}
}
}
}
// Same ordering: cyan(6), green(2), purple(5), red(1), yellow(3), blue(4),
// bright cyan(14), bright green(10)
let order = [6, 2, 5, 1, 3, 4, 14, 10];
let palette: Vec<(u8, u8, u8)> = order.iter().filter_map(|&i| ansi[i]).collect();
if palette.is_empty() {
None
} else {
Some(palette)
}
}
// ── Alacritty config parsing ────────────────────────────────────────────
/// Parse Alacritty's TOML/YAML config for ANSI color definitions.
fn parse_alacritty_config() -> Option<Vec<(u8, u8, u8)>> {
if std::env::var("TERM_PROGRAM").ok().as_deref() != Some("Alacritty") {
return None;
}
let home = std::env::var("HOME").ok()?;
let candidates = [
format!("{home}/.config/alacritty/alacritty.toml"),
format!("{home}/.config/alacritty/alacritty.yml"),
format!("{home}/.alacritty.toml"),
format!("{home}/.alacritty.yml"),
];
let path = candidates
.iter()
.find(|p| std::path::Path::new(p.as_str()).exists())?;
let content = std::fs::read_to_string(path).ok()?;
extract_alacritty_colors(&content)
}
/// Extract hex colors from Alacritty config (simple line-based parsing).
/// Looks for lines like `red = "#hex"` or `red: '#hex'` within color sections.
fn extract_alacritty_colors(conf: &str) -> Option<Vec<(u8, u8, u8)>> {
let color_names = ["cyan", "green", "magenta", "red", "yellow", "blue"];
let mut colors = Vec::new();
for line in conf.lines() {
let trimmed = line.trim();
for &name in &color_names {
if trimmed.starts_with(name) {
// Extract hex from: `red = "#FF0000"` or `red: '#FF0000'`
if let Some(hex_start) = trimmed.find('#') {
let hex_str: String = trimmed[hex_start..]
.chars()
.take_while(|c| *c == '#' || c.is_ascii_hexdigit())
.collect();
if let Some(rgb) = color::parse_hex(&hex_str) {
colors.push(rgb);
}
}
}
}
}
if colors.is_empty() {
None
} else {
// Deduplicate (normal + bright sections may both match)
colors.dedup();
Some(colors)
}
}
// ── OSC 4 terminal query ────────────────────────────────────────────────
/// ANSI color indices to query for the tool palette.
const QUERY_INDICES: &[u8] = &[6, 2, 5, 1, 3, 4, 14, 10];
/// Query the terminal's ANSI color palette via OSC 4 escape sequences.
/// Opens `/dev/tty` directly (bypassing stdin/stdout pipes) to communicate
/// with the terminal emulator. Returns `None` if the terminal doesn't respond
/// or `/dev/tty` isn't available.
fn query_ansi_palette() -> Option<Vec<(u8, u8, u8)>> {
let mut tty = OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
let fd = tty.as_raw_fd();
// Save terminal state
let mut old_termios: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut old_termios) } != 0 {
return None;
}
// Set raw mode with short read timeout
let mut raw = old_termios;
unsafe { libc::cfmakeraw(&mut raw) };
raw.c_cc[libc::VMIN] = 0;
raw.c_cc[libc::VTIME] = 1; // 100ms timeout per read
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
return None;
}
// Send all queries at once for speed
let mut query = String::new();
for &idx in QUERY_INDICES {
query.push_str(&format!("\x1b]4;{idx};?\x07"));
}
let write_ok = tty.write_all(query.as_bytes()).is_ok() && tty.flush().is_ok();
// Read all responses
let mut buf = vec![0u8; 1024];
let mut total = 0;
if write_ok {
loop {
match tty.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => {
total += n;
if total >= buf.len() - 64 {
break;
}
}
Err(_) => break,
}
}
}
// Restore terminal state (MUST happen even on error)
unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old_termios) };
if total == 0 {
return None;
}
let colors = parse_osc4_responses(&buf[..total]);
if colors.is_empty() {
None
} else {
Some(colors)
}
}
// ── Cache helpers ───────────────────────────────────────────────────────
/// Serialize a palette to a cache-friendly string: "r,g,b;r,g,b;..."
pub fn palette_to_cache(palette: &[(u8, u8, u8)]) -> String {
palette
.iter()
.map(|(r, g, b)| format!("{r},{g},{b}"))
.collect::<Vec<_>>()
.join(";")
}
/// Deserialize a palette from a cache string.
pub fn palette_from_cache(s: &str) -> Option<Vec<(u8, u8, u8)>> {
let colors: Vec<(u8, u8, u8)> = s
.split(';')
.filter_map(|triplet| {
let parts: Vec<&str> = triplet.split(',').collect();
if parts.len() == 3 {
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
))
} else {
None
}
})
.collect();
if colors.is_empty() {
None
} else {
Some(colors)
}
}
// ── Shared parsing helpers ──────────────────────────────────────────────
/// Parse OSC 4 responses from a byte buffer.
fn parse_osc4_responses(buf: &[u8]) -> Vec<(u8, u8, u8)> {
let s = String::from_utf8_lossy(buf);
let mut colors = Vec::new();
for chunk in s.split('\x1b') {
if let Some(rgb_pos) = chunk.find("rgb:") {
let rgb_str = &chunk[rgb_pos + 4..];
if let Some(parsed) = parse_rgb_triplet(rgb_str) {
colors.push(parsed);
}
}
}
colors
}
fn parse_rgb_triplet(s: &str) -> Option<(u8, u8, u8)> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() < 3 {
return None;
}
let r = parse_osc_component(parts[0])?;
let g = parse_osc_component(parts[1])?;
let b_str: String = parts[2]
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
let b = parse_osc_component(&b_str)?;
Some((r, g, b))
}
fn parse_osc_component(hex: &str) -> Option<u8> {
let clean: String = hex.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
match clean.len() {
4 => u8::from_str_radix(&clean[0..2], 16).ok(),
2 => u8::from_str_radix(&clean, 16).ok(),
1 => u8::from_str_radix(&clean, 16).ok().map(|v| v * 17),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── OSC 4 parsing ───────────────────────────────────────────────────
#[test]
fn parse_osc4_response_4digit() {
let response = b"\x1b]4;6;rgb:8b8b/e9e9/fdfd\x07";
let colors = parse_osc4_responses(response);
assert_eq!(colors, vec![(0x8b, 0xe9, 0xfd)]);
}
#[test]
fn parse_osc4_response_2digit() {
let response = b"\x1b]4;1;rgb:ff/00/55\x07";
let colors = parse_osc4_responses(response);
assert_eq!(colors, vec![(0xff, 0x00, 0x55)]);
}
#[test]
fn parse_multiple_responses() {
let response = b"\x1b]4;6;rgb:8b8b/e9e9/fdfd\x07\x1b]4;2;rgb:5050/fafa/7b7b\x07";
let colors = parse_osc4_responses(response);
assert_eq!(colors, vec![(0x8b, 0xe9, 0xfd), (0x50, 0xfa, 0x7b)]);
}
#[test]
fn parse_st_terminated() {
let response = b"\x1b]4;1;rgb:ffff/0000/5555\x1b\\";
let colors = parse_osc4_responses(response);
assert_eq!(colors, vec![(0xff, 0x00, 0x55)]);
}
// ── Cache roundtrip ─────────────────────────────────────────────────
#[test]
fn cache_roundtrip() {
let palette = vec![(139, 233, 253), (80, 250, 123)];
let cached = palette_to_cache(&palette);
let restored = palette_from_cache(&cached).unwrap();
assert_eq!(palette, restored);
}
// ── WezTerm config parsing ──────────────────────────────────────────
#[test]
fn wezterm_explicit_colors() {
let lua = r##"
config.colors = {
ansi = {
"#100F0F", -- Black
"#AF3029", -- Red
"#66800B", -- Green
"#AD8301", -- Yellow
"#205EA6", -- Blue
"#5E409D", -- Purple
"#24837B", -- Cyan
"#CECDC3", -- White
},
brights = {
"#575653", -- Black
"#D14D41", -- Red
"#879A39", -- Green
"#D0A215", -- Yellow
"#4385BE", -- Blue
"#8B7EC8", -- Purple
"#3AA99F", -- Cyan
"#FFFCF0", -- White
},
}
"##;
let palette = extract_wezterm_colors(lua).unwrap();
// Order: cyan(6), green(2), purple(5), red(1), yellow(3), blue(4),
// bright cyan(14→6), bright green(10→2)
assert_eq!(palette.len(), 8);
assert_eq!(palette[0], (0x24, 0x83, 0x7B)); // cyan
assert_eq!(palette[1], (0x66, 0x80, 0x0B)); // green
assert_eq!(palette[2], (0x5E, 0x40, 0x9D)); // purple
assert_eq!(palette[3], (0xAF, 0x30, 0x29)); // red
assert_eq!(palette[4], (0xAD, 0x83, 0x01)); // yellow
assert_eq!(palette[5], (0x20, 0x5E, 0xA6)); // blue
assert_eq!(palette[6], (0x3A, 0xA9, 0x9F)); // bright cyan
assert_eq!(palette[7], (0x87, 0x9A, 0x39)); // bright green
}
// ── Kitty config parsing ────────────────────────────────────────────
#[test]
fn kitty_colors() {
let conf = r##"
color0 #1d2021
color1 #cc241d
color2 #98971a
color3 #d79921
color4 #458588
color5 #b16286
color6 #689d6a
color7 #a89984
color10 #b8bb26
color14 #8ec07c
"##;
let palette = extract_kitty_colors(conf).unwrap();
assert_eq!(palette[0], (0x68, 0x9d, 0x6a)); // color6 cyan
assert_eq!(palette[1], (0x98, 0x97, 0x1a)); // color2 green
assert_eq!(palette[2], (0xb1, 0x62, 0x86)); // color5 magenta
assert_eq!(palette[3], (0xcc, 0x24, 0x1d)); // color1 red
}
}

132
src/transcript.rs Normal file
View File

@@ -0,0 +1,132 @@
use serde::Serialize;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::Path;
/// Statistics derived from a Claude Code transcript JSONL file.
#[derive(Debug, Default, Clone, Serialize)]
pub struct TranscriptStats {
pub total_tool_uses: u64,
pub total_turns: u64,
pub last_tool_name: Option<String>,
/// Per-tool counts sorted by count descending.
pub tool_counts: Vec<(String, u64)>,
}
/// Cached format: "tools:N,turns:N,last:Name;ToolA=C,ToolB=C,..."
impl TranscriptStats {
pub fn to_cache_string(&self) -> String {
let mut s = format!("tools:{},turns:{}", self.total_tool_uses, self.total_turns);
if let Some(name) = &self.last_tool_name {
s.push_str(&format!(",last:{name}"));
}
if !self.tool_counts.is_empty() {
s.push(';');
let parts: Vec<String> = self
.tool_counts
.iter()
.map(|(name, count)| format!("{name}={count}"))
.collect();
s.push_str(&parts.join(","));
}
s
}
pub fn from_cache_string(s: &str) -> Option<Self> {
let mut stats = Self::default();
// Split on ';' — first part is summary, second is per-tool counts
let (summary, breakdown) = s.split_once(';').unwrap_or((s, ""));
for part in summary.split(',') {
if let Some((key, val)) = part.split_once(':') {
match key {
"tools" => stats.total_tool_uses = val.parse().unwrap_or(0),
"turns" => stats.total_turns = val.parse().unwrap_or(0),
"last" => {
if !val.is_empty() {
stats.last_tool_name = Some(val.to_string());
}
}
_ => {}
}
}
}
if !breakdown.is_empty() {
for part in breakdown.split(',') {
if let Some((name, count_str)) = part.split_once('=') {
let count: u64 = count_str.parse().unwrap_or(0);
if count > 0 {
stats.tool_counts.push((name.to_string(), count));
}
}
}
}
Some(stats)
}
}
/// Parse a Claude Code transcript JSONL file and extract tool use and turn counts.
/// `skip_lines` skips that many lines from the start (used after /clear to ignore
/// pre-clear entries in the same transcript file).
///
/// Transcript format (one JSON object per line):
/// - `{"type": "user", ...}` — a user turn
/// - `{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}, ...]}}` — tool uses
pub fn parse_transcript(path: &Path, skip_lines: usize) -> Option<TranscriptStats> {
let file = std::fs::File::open(path).ok()?;
let reader = std::io::BufReader::new(file);
let mut stats = TranscriptStats::default();
let mut counts: HashMap<String, u64> = HashMap::new();
for line in reader.lines().skip(skip_lines) {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.is_empty() {
continue;
}
let v: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let msg_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match msg_type {
"user" => {
stats.total_turns += 1;
}
"assistant" => {
if let Some(content) = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
for block in content {
if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
stats.total_tool_uses += 1;
if let Some(name) = block.get("name").and_then(|n| n.as_str()) {
stats.last_tool_name = Some(name.to_string());
*counts.entry(name.to_string()).or_insert(0) += 1;
}
}
}
}
}
_ => {}
}
}
// Sort by count descending
let mut sorted: Vec<(String, u64)> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
stats.tool_counts = sorted;
Some(stats)
}

View File

@@ -1,10 +1,19 @@
use crate::cache::Cache;
use crate::color;
use std::time::Duration;
const SPARKLINE_CHARS: &[char] = &[
'\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}',
];
/// Baseline sparkline placeholder (space — "no data yet").
const BASELINE_CHAR: &str = " ";
const BASELINE_CHAR_CH: char = ' ';
/// Flatline sparkline character (lowest bar — "data exists but is flat/zero").
const FLATLINE_CHAR: char = '\u{2581}';
const FLATLINE_STR: &str = "\u{2581}";
/// 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(
@@ -57,7 +66,62 @@ pub fn append(
Some(csv)
}
/// Append a **delta** (difference from previous cumulative value) to a trend.
/// Useful for rate-of-change sparklines (e.g., cost burn rate).
/// Stores the previous cumulative value in a separate cache key for delta computation.
pub fn append_delta(
cache: &Cache,
key: &str,
cumulative_value: i64,
max_points: usize,
interval: Duration,
) -> Option<String> {
let trend_key = format!("trend_{key}");
let prev_key = format!("trend_{key}_prev");
// Check throttle: skip if last write was within interval
if let Some(existing) = cache.get(&trend_key, interval) {
return Some(existing);
}
// Get previous cumulative value for delta computation
let prev = cache
.get_stale(&prev_key)
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(cumulative_value);
let delta = cumulative_value - prev;
// Store current cumulative for next delta
cache.set(&prev_key, &cumulative_value.to_string());
// Read existing 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();
series.push(delta);
// 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.
/// Always renders exactly `width` characters — pads with baseline chars
/// on the left when fewer data points exist.
pub fn sparkline(csv: &str, width: usize) -> String {
let vals: Vec<i64> = csv
.split(',')
@@ -65,23 +129,116 @@ pub fn sparkline(csv: &str, width: usize) -> String {
.collect();
if vals.is_empty() {
return String::new();
return BASELINE_CHAR.repeat(width);
}
let min = *vals.iter().min().unwrap();
let max = *vals.iter().max().unwrap();
let count = vals.len().min(width);
let data_count = vals.len().min(width);
let pad_count = width.saturating_sub(data_count);
if max == min {
return "\u{2584}".repeat(count);
// Data exists but is flat — show visible lowest bars (not invisible spaces)
let mut result = String::with_capacity(width * 3);
for _ in 0..pad_count {
result.push(BASELINE_CHAR_CH);
}
for _ in 0..data_count {
result.push(FLATLINE_CHAR);
}
return result;
}
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()
let mut result = String::with_capacity(width * 3);
// Left-pad with baseline chars
for _ in 0..pad_count {
result.push(BASELINE_CHAR_CH);
}
// Render data points (take the last `data_count` from available values,
// capped to `width`)
let skip = vals.len().saturating_sub(width);
for &v in vals.iter().skip(skip) {
let idx = (((v - min) as f64 / range) * 7.0) as usize;
result.push(SPARKLINE_CHARS[idx.min(7)]);
}
result
}
/// Render a sparkline with per-character gradient coloring.
/// Each character is colored based on its normalized value (0.0=min, 1.0=max).
/// Always renders exactly `width` characters — pads with DIM baseline chars on the left.
/// Returns (raw, ansi) — raw is plain sparkline, ansi has gradient colors.
pub fn sparkline_colored(
csv: &str,
width: usize,
grad: &colorgrad::LinearGradient,
) -> Option<(String, String)> {
let vals: Vec<i64> = csv
.split(',')
.filter_map(|s| s.trim().parse().ok())
.collect();
if vals.is_empty() {
let raw = BASELINE_CHAR.repeat(width);
let ansi = format!("{}{raw}{}", color::DIM, color::RESET);
return Some((raw, ansi));
}
let min = *vals.iter().min().unwrap();
let max = *vals.iter().max().unwrap();
let data_count = vals.len().min(width);
let pad_count = width.saturating_sub(data_count);
if max == min {
// Data exists but is flat — show visible lowest bars with DIM styling
let mut raw = String::with_capacity(width * 3);
let mut ansi = String::with_capacity(width * 20);
for _ in 0..pad_count {
raw.push(BASELINE_CHAR_CH);
}
if pad_count > 0 {
ansi.push_str(color::DIM);
ansi.push_str(&BASELINE_CHAR.repeat(pad_count));
ansi.push_str(color::RESET);
}
let flatline = FLATLINE_STR.repeat(data_count);
raw.push_str(&flatline);
ansi.push_str(color::DIM);
ansi.push_str(&flatline);
ansi.push_str(color::RESET);
return Some((raw, ansi));
}
let range = (max - min) as f32;
let mut raw = String::with_capacity(width * 3);
let mut ansi = String::with_capacity(width * 20);
// Left-pad with DIM baseline chars
if pad_count > 0 {
let pad_str = BASELINE_CHAR.repeat(pad_count);
raw.push_str(&pad_str);
ansi.push_str(color::DIM);
ansi.push_str(&pad_str);
ansi.push_str(color::RESET);
}
// Render data points (take the last `width` values)
let skip = vals.len().saturating_sub(width);
for &v in vals.iter().skip(skip) {
let norm = (v - min) as f32 / range;
let idx = (norm * 7.0) as usize;
let ch = SPARKLINE_CHARS[idx.min(7)];
raw.push(ch);
ansi.push_str(&color::sample_fg(grad, norm));
ansi.push(ch);
}
ansi.push_str(color::RESET);
Some((raw, ansi))
}

View File

@@ -22,18 +22,43 @@ pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier {
}
/// Detect terminal width. Memoized for 1 second across renders.
/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120
pub fn detect_width(cli_width: Option<u16>, config_width: Option<u16>, config_margin: u16) -> u16 {
///
/// Priority chain:
/// 1. `--width` CLI flag (absolute override)
/// 2. `CLAUDE_STATUSLINE_WIDTH` env var (absolute override)
/// 3. stdin JSON `terminal_width` (from Claude Code)
/// 4. ioctl stdout, 5. ioctl stderr, 6. process tree, 7. stty, 8. COLUMNS, 9. tput
/// 10. Config `width` (fallback only)
/// 11. Hardcoded 120
///
/// Config `width` also acts as a max cap on dynamic results (items 3-9).
/// CLI flag and env var are NOT capped — they're explicit user overrides.
pub fn detect_width(
cli_width: Option<u16>,
config_width: Option<u16>,
config_margin: u16,
stdin_width: Option<u16>,
) -> u16 {
detect_width_with_source(cli_width, config_width, config_margin, stdin_width).0
}
/// Like detect_width but also returns the detection source name (for --dump-state).
pub fn detect_width_with_source(
cli_width: Option<u16>,
config_width: Option<u16>,
config_margin: u16,
stdin_width: Option<u16>,
) -> (u16, &'static str) {
// Check memo first
if let Ok(guard) = CACHED_WIDTH.lock() {
if let Some((w, ts)) = *guard {
if ts.elapsed() < WIDTH_TTL {
return w;
return (w, "cached");
}
}
}
let raw = detect_raw(cli_width, config_width);
let (raw, source) = detect_raw_with_source(cli_width, config_width, stdin_width);
let effective = raw.saturating_sub(config_margin).max(40);
// Store in memo
@@ -41,72 +66,116 @@ pub fn detect_width(cli_width: Option<u16>, config_width: Option<u16>, config_ma
*guard = Some((effective, Instant::now()));
}
effective
(effective, source)
}
fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
// 1. --width CLI flag
fn detect_raw_with_source(
cli_width: Option<u16>,
config_width: Option<u16>,
stdin_width: Option<u16>,
) -> (u16, &'static str) {
// 1. --width CLI flag (absolute, not capped)
if let Some(w) = cli_width {
if w > 0 {
return w;
return (w, "cli_flag");
}
}
// 2. CLAUDE_STATUSLINE_WIDTH env var
// 2. CLAUDE_STATUSLINE_WIDTH env var (absolute, not capped)
if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") {
if let Ok(w) = val.parse::<u16>() {
if w > 0 {
return w;
return (w, "env_var");
}
}
}
// 3. Config override
// 3-9: Dynamic detection, subject to config max cap
if let Some((w, source)) = detect_dynamic(stdin_width) {
return apply_config_cap(w, source, config_width);
}
// 10. Config width as fallback
if let Some(w) = config_width {
if w > 0 {
return w;
return (w, "config_fallback");
}
}
// 11. Hardcoded fallback
(120, "fallback")
}
/// Try all dynamic detection methods in priority order.
/// Returns the first successful result.
fn detect_dynamic(stdin_width: Option<u16>) -> Option<(u16, &'static str)> {
// 3. stdin JSON terminal_width (Claude Code passes this)
if let Some(w) = stdin_width {
if w > 0 {
return Some((w, "stdin_json"));
}
}
// 4. ioctl(TIOCGWINSZ) on stdout
if let Some(w) = ioctl_width(libc::STDOUT_FILENO) {
if w > 0 {
return w;
return Some((w, "ioctl_stdout"));
}
}
// 5. Process tree walk: find ancestor with real TTY
// 5. ioctl(TIOCGWINSZ) on stderr (often still a TTY when stdout is piped)
if let Some(w) = ioctl_width(libc::STDERR_FILENO) {
if w > 0 {
return Some((w, "ioctl_stderr"));
}
}
// 6. Process tree walk: find ancestor with real TTY
if let Some(w) = process_tree_width() {
if w > 0 {
return w;
return Some((w, "process_tree"));
}
}
// 6. stty size < /dev/tty
// 7. stty size < /dev/tty
if let Some(w) = stty_dev_tty() {
if w > 0 {
return w;
return Some((w, "stty"));
}
}
// 7. COLUMNS env var
// 8. COLUMNS env var
if let Ok(val) = std::env::var("COLUMNS") {
if let Ok(w) = val.parse::<u16>() {
if w > 0 {
return w;
return Some((w, "columns_env"));
}
}
}
// 8. tput cols
// 9. tput cols
if let Some(w) = tput_cols() {
if w > 0 {
return w;
return Some((w, "tput"));
}
}
// 9. Fallback
120
None
}
/// Apply config width as a max cap on dynamically detected width.
/// If the detected width exceeds config, clamp it down and report the cap.
fn apply_config_cap(
detected: u16,
source: &'static str,
config_width: Option<u16>,
) -> (u16, &'static str) {
if let Some(max_w) = config_width {
if max_w > 0 && detected > max_w {
return (max_w, "config_cap");
}
}
(detected, source)
}
fn ioctl_width(fd: i32) -> Option<u16> {

28
statusline.json Normal file
View File

@@ -0,0 +1,28 @@
{
"$schema": "./schema.json",
"global": {
"justify": "spread",
"theme": "dark",
"responsive": true,
"width": 500,
"width_margin": 20
},
"glyphs": {
"enabled": true
},
"layout": "verbose",
"sections": {
"context_usage": {
"enabled": true
},
"context_trend": {
"enabled": true
},
"cost_trend": {
"enabled": true
},
"load": {
"enabled": false
}
}
}

File diff suppressed because it is too large Load Diff