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
fi
echo "[update] $name: updating symlink"
ln -sf "$src" "$dst"
elif [[ -f "$dst" ]]; then
echo "[skip] $name: $dst exists as a regular file"
echo " To use the symlink, rename or remove the existing file first."
return
else
ln -s "$src" "$dst"
echo "[ok] $name linked"
fi
}
BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
create_link "$SCRIPT_DIR/statusline.sh" "$CLAUDE_DIR/statusline.sh" "statusline.sh"
# The binary runs in a non-TTY context, so force color on.
STATUSLINE_CMD="$BINARY_PATH --color=always"
# 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/"
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
# 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
# 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 " Print defaults: $BINARY_NAME --print-defaults"
echo " Print schema: $BINARY_NAME --config-schema"
fi
# ── Done ─────────────────────────────────────────────────────────────
echo ""
echo "Symlinks created. Now add the status line to your Claude Code settings."
echo "Done. Restart Claude Code to see the status line."
echo ""
echo "Add this to ~/.claude/settings.json:"
echo ""
echo ' "statusLine": "'$CLAUDE_DIR'/statusline.sh"'
echo ""
echo "If ~/.claude/settings.json doesn't exist yet, create it:"
echo ""
echo ' {'
echo ' "statusLine": "'$CLAUDE_DIR'/statusline.sh"'
echo ' }'
echo ""
echo "Then restart Claude Code to see the status line."
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 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;
SPARKLINE_CHARS[idx.min(7)]
})
.collect()
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