Compare commits
10 Commits
b55d1aefd1
...
e5b18b17ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b18b17ff | ||
|
|
8853afffa1 | ||
|
|
c03b0b1bd7 | ||
|
|
e0c4a0fa9a | ||
|
|
f46c3da69c | ||
|
|
4e38b8259b | ||
|
|
4c9139ec42 | ||
|
|
73401beb47 | ||
|
|
50bc2d989e | ||
|
|
9c24617642 |
File diff suppressed because one or more lines are too long
82
Cargo.lock
generated
82
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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": []
|
||||
|
||||
130
install.sh
130
install.sh
@@ -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"
|
||||
BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
|
||||
|
||||
# The binary runs in a non-TTY context, so force color on.
|
||||
STATUSLINE_CMD="$BINARY_PATH --color=always"
|
||||
|
||||
if [[ -f "$SETTINGS" ]]; then
|
||||
# Update existing settings.json
|
||||
CURRENT_CMD=$(jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true)
|
||||
if [[ -n "$CURRENT_CMD" ]]; then
|
||||
echo "[info] Current statusLine command: $CURRENT_CMD"
|
||||
fi
|
||||
}
|
||||
|
||||
create_link "$SCRIPT_DIR/statusline.sh" "$CLAUDE_DIR/statusline.sh" "statusline.sh"
|
||||
|
||||
# Optionally link user config if they want to start from an example
|
||||
if [[ ! -f "$CLAUDE_DIR/statusline.json" ]]; then
|
||||
echo "[info] No statusline.json found. You can copy an example from:"
|
||||
echo " $SCRIPT_DIR/examples/"
|
||||
# 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"
|
||||
|
||||
3091
rust_prd.md
3091
rust_prd.md
File diff suppressed because it is too large
Load Diff
296
schema.json
296
schema.json
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
243
src/cache.rs
243
src/cache.rs
@@ -1,21 +1,56 @@
|
||||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Diagnostic entry for a single cache lookup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheDiag {
|
||||
pub key: String,
|
||||
pub hit: bool,
|
||||
pub age_ms: Option<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
|
||||
}
|
||||
}
|
||||
|
||||
156
src/color.rs
156
src/color.rs
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
43
src/input.rs
43
src/input.rs
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
// 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 as u16;
|
||||
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) {
|
||||
// Re-apply formatting after flex rebuild
|
||||
let base = &ctx.config.sections.context_bar.base;
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
output.push_str(&format!(
|
||||
"{}{gap_str}{}",
|
||||
crate::color::DIM,
|
||||
crate::color::RESET
|
||||
));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
"br",
|
||||
&["ready", "--json"],
|
||||
Some(ctx.project_dir.to_str()?),
|
||||
timeout,
|
||||
)?;
|
||||
// Count JSON array items (simple: count opening braces at indent level 1)
|
||||
let count = out.matches("\"id\"").count();
|
||||
let summary = format!("{count}");
|
||||
ctx.cache.set("beads_summary", &summary);
|
||||
let out = ctx.shell_results.get("beads").cloned().unwrap_or_else(|| {
|
||||
shell::exec_gated(
|
||||
ctx.shell_config,
|
||||
"br",
|
||||
&["stats", "--json"],
|
||||
Some(ctx.project_dir.to_str()?),
|
||||
)
|
||||
})?;
|
||||
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 {
|
||||
|
||||
59
src/section/cloud_profile.rs
Normal file
59
src/section/cloud_profile.rs
Normal 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 })
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
134
src/section/k8s_context.rs
Normal 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, ¤t_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
|
||||
}
|
||||
@@ -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 != '.')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
40
src/section/python_env.rs
Normal 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 })
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
127
src/section/toolchain.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let last = if ctx.config.sections.tools.show_last_name {
|
||||
cost.last_tool_name
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default()
|
||||
// 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 {
|
||||
String::new()
|
||||
return None;
|
||||
};
|
||||
|
||||
let raw = format!("{count} tools{last}");
|
||||
let label = if count == 1 { "tool" } else { "tools" };
|
||||
|
||||
if count == 0 {
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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} {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 {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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(
|
||||
"git",
|
||||
&["-C", dir, "status", "--porcelain=v2", "--branch"],
|
||||
None,
|
||||
timeout,
|
||||
);
|
||||
// Use prefetched result if available, otherwise exec
|
||||
let output = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
|
||||
shell::exec_gated(
|
||||
ctx.shell_config,
|
||||
"git",
|
||||
&["-C", dir, "status", "--porcelain=v2", "--branch"],
|
||||
None,
|
||||
)
|
||||
});
|
||||
match output {
|
||||
Some(ref out) => {
|
||||
let s = shell::parse_git_status_v2(out);
|
||||
@@ -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,32 +161,43 @@ 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(
|
||||
"jj",
|
||||
&[
|
||||
"log",
|
||||
"-r",
|
||||
"@",
|
||||
"--no-graph",
|
||||
"-T",
|
||||
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
|
||||
"--color=never",
|
||||
],
|
||||
None,
|
||||
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",
|
||||
"-r",
|
||||
"@",
|
||||
"--no-graph",
|
||||
"-T",
|
||||
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
|
||||
"--color=never",
|
||||
],
|
||||
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 {
|
||||
|
||||
161
src/shell.rs
161
src/shell.rs
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -8,6 +9,166 @@ const GIT_ENV: &[(&str, &str)] = &[
|
||||
("LC_ALL", "C"),
|
||||
];
|
||||
|
||||
/// Shell execution configuration for gated access.
|
||||
pub struct ShellConfig {
|
||||
pub enabled: bool,
|
||||
pub allowlist: Vec<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
476
src/terminal.rs
Normal 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
132
src/transcript.rs
Normal 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)
|
||||
}
|
||||
177
src/trend.rs
177
src/trend.rs
@@ -1,10 +1,19 @@
|
||||
use crate::cache::Cache;
|
||||
use crate::color;
|
||||
use std::time::Duration;
|
||||
|
||||
const SPARKLINE_CHARS: &[char] = &[
|
||||
'\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}',
|
||||
];
|
||||
|
||||
/// Baseline sparkline placeholder (space — "no data yet").
|
||||
const BASELINE_CHAR: &str = " ";
|
||||
const BASELINE_CHAR_CH: char = ' ';
|
||||
|
||||
/// Flatline sparkline character (lowest bar — "data exists but is flat/zero").
|
||||
const FLATLINE_CHAR: char = '\u{2581}';
|
||||
const FLATLINE_STR: &str = "\u{2581}";
|
||||
|
||||
/// Append a value to a trend file. Throttled to at most once per `interval`.
|
||||
/// Returns the full comma-separated series (for immediate sparkline rendering).
|
||||
pub fn append(
|
||||
@@ -57,7 +66,62 @@ pub fn append(
|
||||
Some(csv)
|
||||
}
|
||||
|
||||
/// Append a **delta** (difference from previous cumulative value) to a trend.
|
||||
/// Useful for rate-of-change sparklines (e.g., cost burn rate).
|
||||
/// Stores the previous cumulative value in a separate cache key for delta computation.
|
||||
pub fn append_delta(
|
||||
cache: &Cache,
|
||||
key: &str,
|
||||
cumulative_value: i64,
|
||||
max_points: usize,
|
||||
interval: Duration,
|
||||
) -> Option<String> {
|
||||
let trend_key = format!("trend_{key}");
|
||||
let prev_key = format!("trend_{key}_prev");
|
||||
|
||||
// Check throttle: skip if last write was within interval
|
||||
if let Some(existing) = cache.get(&trend_key, interval) {
|
||||
return Some(existing);
|
||||
}
|
||||
|
||||
// Get previous cumulative value for delta computation
|
||||
let prev = cache
|
||||
.get_stale(&prev_key)
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(cumulative_value);
|
||||
|
||||
let delta = cumulative_value - prev;
|
||||
|
||||
// Store current cumulative for next delta
|
||||
cache.set(&prev_key, &cumulative_value.to_string());
|
||||
|
||||
// Read existing series (ignoring TTL)
|
||||
let mut series: Vec<i64> = cache
|
||||
.get_stale(&trend_key)
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect();
|
||||
|
||||
series.push(delta);
|
||||
|
||||
// Trim from left to max_points
|
||||
if series.len() > max_points {
|
||||
series = series[series.len() - max_points..].to_vec();
|
||||
}
|
||||
|
||||
let csv = series
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
cache.set(&trend_key, &csv);
|
||||
Some(csv)
|
||||
}
|
||||
|
||||
/// Render a sparkline from comma-separated values.
|
||||
/// Always renders exactly `width` characters — pads with baseline chars
|
||||
/// on the left when fewer data points exist.
|
||||
pub fn sparkline(csv: &str, width: usize) -> String {
|
||||
let vals: Vec<i64> = csv
|
||||
.split(',')
|
||||
@@ -65,23 +129,116 @@ pub fn sparkline(csv: &str, width: usize) -> String {
|
||||
.collect();
|
||||
|
||||
if vals.is_empty() {
|
||||
return String::new();
|
||||
return BASELINE_CHAR.repeat(width);
|
||||
}
|
||||
|
||||
let min = *vals.iter().min().unwrap();
|
||||
let max = *vals.iter().max().unwrap();
|
||||
let count = vals.len().min(width);
|
||||
let data_count = vals.len().min(width);
|
||||
let pad_count = width.saturating_sub(data_count);
|
||||
|
||||
if max == min {
|
||||
return "\u{2584}".repeat(count);
|
||||
// Data exists but is flat — show visible lowest bars (not invisible spaces)
|
||||
let mut result = String::with_capacity(width * 3);
|
||||
for _ in 0..pad_count {
|
||||
result.push(BASELINE_CHAR_CH);
|
||||
}
|
||||
for _ in 0..data_count {
|
||||
result.push(FLATLINE_CHAR);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let range = (max - min) as f64;
|
||||
vals.iter()
|
||||
.take(width)
|
||||
.map(|&v| {
|
||||
let idx = (((v - min) as f64 / range) * 7.0) as usize;
|
||||
SPARKLINE_CHARS[idx.min(7)]
|
||||
})
|
||||
.collect()
|
||||
let mut result = String::with_capacity(width * 3);
|
||||
|
||||
// Left-pad with baseline chars
|
||||
for _ in 0..pad_count {
|
||||
result.push(BASELINE_CHAR_CH);
|
||||
}
|
||||
|
||||
// Render data points (take the last `data_count` from available values,
|
||||
// capped to `width`)
|
||||
let skip = vals.len().saturating_sub(width);
|
||||
for &v in vals.iter().skip(skip) {
|
||||
let idx = (((v - min) as f64 / range) * 7.0) as usize;
|
||||
result.push(SPARKLINE_CHARS[idx.min(7)]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Render a sparkline with per-character gradient coloring.
|
||||
/// Each character is colored based on its normalized value (0.0=min, 1.0=max).
|
||||
/// Always renders exactly `width` characters — pads with DIM baseline chars on the left.
|
||||
/// Returns (raw, ansi) — raw is plain sparkline, ansi has gradient colors.
|
||||
pub fn sparkline_colored(
|
||||
csv: &str,
|
||||
width: usize,
|
||||
grad: &colorgrad::LinearGradient,
|
||||
) -> Option<(String, String)> {
|
||||
let vals: Vec<i64> = csv
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect();
|
||||
|
||||
if vals.is_empty() {
|
||||
let raw = BASELINE_CHAR.repeat(width);
|
||||
let ansi = format!("{}{raw}{}", color::DIM, color::RESET);
|
||||
return Some((raw, ansi));
|
||||
}
|
||||
|
||||
let min = *vals.iter().min().unwrap();
|
||||
let max = *vals.iter().max().unwrap();
|
||||
let data_count = vals.len().min(width);
|
||||
let pad_count = width.saturating_sub(data_count);
|
||||
|
||||
if max == min {
|
||||
// Data exists but is flat — show visible lowest bars with DIM styling
|
||||
let mut raw = String::with_capacity(width * 3);
|
||||
let mut ansi = String::with_capacity(width * 20);
|
||||
for _ in 0..pad_count {
|
||||
raw.push(BASELINE_CHAR_CH);
|
||||
}
|
||||
if pad_count > 0 {
|
||||
ansi.push_str(color::DIM);
|
||||
ansi.push_str(&BASELINE_CHAR.repeat(pad_count));
|
||||
ansi.push_str(color::RESET);
|
||||
}
|
||||
let flatline = FLATLINE_STR.repeat(data_count);
|
||||
raw.push_str(&flatline);
|
||||
ansi.push_str(color::DIM);
|
||||
ansi.push_str(&flatline);
|
||||
ansi.push_str(color::RESET);
|
||||
return Some((raw, ansi));
|
||||
}
|
||||
|
||||
let range = (max - min) as f32;
|
||||
let mut raw = String::with_capacity(width * 3);
|
||||
let mut ansi = String::with_capacity(width * 20);
|
||||
|
||||
// Left-pad with DIM baseline chars
|
||||
if pad_count > 0 {
|
||||
let pad_str = BASELINE_CHAR.repeat(pad_count);
|
||||
raw.push_str(&pad_str);
|
||||
ansi.push_str(color::DIM);
|
||||
ansi.push_str(&pad_str);
|
||||
ansi.push_str(color::RESET);
|
||||
}
|
||||
|
||||
// Render data points (take the last `width` values)
|
||||
let skip = vals.len().saturating_sub(width);
|
||||
for &v in vals.iter().skip(skip) {
|
||||
let norm = (v - min) as f32 / range;
|
||||
let idx = (norm * 7.0) as usize;
|
||||
let ch = SPARKLINE_CHARS[idx.min(7)];
|
||||
|
||||
raw.push(ch);
|
||||
ansi.push_str(&color::sample_fg(grad, norm));
|
||||
ansi.push(ch);
|
||||
}
|
||||
|
||||
ansi.push_str(color::RESET);
|
||||
|
||||
Some((raw, ansi))
|
||||
}
|
||||
|
||||
115
src/width.rs
115
src/width.rs
@@ -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
28
statusline.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
2245
statusline.sh
2245
statusline.sh
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user