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"
|
name = "claude-statusline"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"colorgrad",
|
||||||
"criterion",
|
"criterion",
|
||||||
"libc",
|
"libc",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -123,6 +124,15 @@ dependencies = [
|
|||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorgrad"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faedab4fd8670120c2be7f49225fbdb8b6db6d46f04ce4f864b1f1cdd55e6400"
|
||||||
|
dependencies = [
|
||||||
|
"csscolorparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion"
|
name = "criterion"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -200,6 +210,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csscolorparser"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16"
|
||||||
|
dependencies = [
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -322,6 +341,48 @@ version = "11.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
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]]
|
[[package]]
|
||||||
name = "plotters"
|
name = "plotters"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -368,6 +429,21 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -496,6 +572,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.114"
|
version = "2.0.114"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ unicode-segmentation = "1"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
serde_path_to_error = "0.1"
|
serde_path_to_error = "0.1"
|
||||||
serde_ignored = "0.1"
|
serde_ignored = "0.1"
|
||||||
|
colorgrad = "0.7"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.5", features = ["html_reports"] }
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"global": {
|
"global": {
|
||||||
"separator": " | ",
|
"separator": " | ",
|
||||||
|
"separator_style": "text",
|
||||||
"justify": "space-between",
|
"justify": "space-between",
|
||||||
"vcs": "auto",
|
"vcs": "auto",
|
||||||
"cache_dir": "/tmp/claude-sl-{session_id}",
|
"cache_dir": "/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}",
|
||||||
"responsive": true,
|
"responsive": true,
|
||||||
"breakpoints": {
|
"breakpoints": {
|
||||||
"narrow": 60,
|
"narrow": 60,
|
||||||
@@ -14,14 +15,14 @@
|
|||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"dark": {
|
"dark": {
|
||||||
"success": "green",
|
"success": "#50fa7b",
|
||||||
"warning": "yellow",
|
"warning": "#f1fa8c",
|
||||||
"danger": "red",
|
"danger": "#ff5555",
|
||||||
"critical": "red bold",
|
"critical": "#ff5555 bold",
|
||||||
"muted": "dim",
|
"muted": "dim",
|
||||||
"accent": "cyan",
|
"accent": "#8be9fd",
|
||||||
"highlight": "bold",
|
"highlight": "bold",
|
||||||
"info": "blue"
|
"info": "#bd93f9"
|
||||||
},
|
},
|
||||||
"light": {
|
"light": {
|
||||||
"success": "green bold",
|
"success": "green bold",
|
||||||
@@ -41,9 +42,9 @@
|
|||||||
"separator_alt": "",
|
"separator_alt": "",
|
||||||
"branch": "",
|
"branch": "",
|
||||||
"dirty": "*",
|
"dirty": "*",
|
||||||
"clean": "✓",
|
"clean": "\u2713",
|
||||||
"ahead": "↑",
|
"ahead": "\u2191",
|
||||||
"behind": "↓",
|
"behind": "\u2193",
|
||||||
"folder": "",
|
"folder": "",
|
||||||
"clock": "",
|
"clock": "",
|
||||||
"dollar": ""
|
"dollar": ""
|
||||||
@@ -93,24 +94,33 @@
|
|||||||
[
|
[
|
||||||
"model",
|
"model",
|
||||||
"provider",
|
"provider",
|
||||||
|
"spacer",
|
||||||
|
"lines_changed",
|
||||||
"project",
|
"project",
|
||||||
"vcs",
|
"vcs",
|
||||||
"beads"
|
"beads"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"context_bar",
|
"context_bar",
|
||||||
"tokens_raw",
|
"context_usage",
|
||||||
"cache_efficiency",
|
"cache_efficiency",
|
||||||
|
"spacer",
|
||||||
"cost",
|
"cost",
|
||||||
"cost_velocity"
|
"cost_velocity",
|
||||||
|
"cost_trend",
|
||||||
|
"duration"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"lines_changed",
|
"context_trend",
|
||||||
"duration",
|
"tokens_raw",
|
||||||
|
"spacer",
|
||||||
"tools",
|
"tools",
|
||||||
"turns",
|
"turns",
|
||||||
"load",
|
"load",
|
||||||
"version"
|
"cloud_profile",
|
||||||
|
"k8s_context",
|
||||||
|
"python_env",
|
||||||
|
"toolchain"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -167,6 +177,8 @@
|
|||||||
"flex": true,
|
"flex": true,
|
||||||
"min_width": 15,
|
"min_width": 15,
|
||||||
"bar_width": 10,
|
"bar_width": 10,
|
||||||
|
"bar_style": "block",
|
||||||
|
"gradient": true,
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"warn": 50,
|
"warn": 50,
|
||||||
"danger": 70,
|
"danger": 70,
|
||||||
@@ -174,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"context_usage": {
|
"context_usage": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
"capacity": 200000,
|
"capacity": 200000,
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
@@ -210,14 +222,16 @@
|
|||||||
"priority": 3
|
"priority": 3
|
||||||
},
|
},
|
||||||
"cost_trend": {
|
"cost_trend": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"priority": 3,
|
"priority": 3,
|
||||||
"width": 8
|
"width": 12,
|
||||||
|
"gradient": true
|
||||||
},
|
},
|
||||||
"context_trend": {
|
"context_trend": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"priority": 3,
|
"priority": 3,
|
||||||
"width": 8,
|
"width": 12,
|
||||||
|
"gradient": true,
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"warn": 50,
|
"warn": 50,
|
||||||
"danger": 70,
|
"danger": 70,
|
||||||
@@ -237,6 +251,8 @@
|
|||||||
"priority": 2,
|
"priority": 2,
|
||||||
"min_width": 6,
|
"min_width": 6,
|
||||||
"show_last_name": true,
|
"show_last_name": true,
|
||||||
|
"show_breakdown": true,
|
||||||
|
"top_n": 7,
|
||||||
"ttl": 2
|
"ttl": 2
|
||||||
},
|
},
|
||||||
"turns": {
|
"turns": {
|
||||||
@@ -265,6 +281,23 @@
|
|||||||
"hostname": {
|
"hostname": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"priority": 3
|
"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": []
|
"custom": []
|
||||||
|
|||||||
130
install.sh
130
install.sh
@@ -1,75 +1,109 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# install.sh — Set up claude-statusline symlinks
|
# install.sh — Build and install claude-statusline (Rust binary)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INSTALL_DIR="$HOME/.local/bin"
|
||||||
|
BINARY_NAME="claude-statusline"
|
||||||
CLAUDE_DIR="$HOME/.claude"
|
CLAUDE_DIR="$HOME/.claude"
|
||||||
|
SETTINGS="$CLAUDE_DIR/settings.json"
|
||||||
|
|
||||||
echo "claude-statusline installer"
|
echo "claude-statusline installer"
|
||||||
echo "==========================="
|
echo "==========================="
|
||||||
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
|
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 " macOS: brew install jq"
|
||||||
echo " Ubuntu: sudo apt install jq"
|
echo " Ubuntu: sudo apt install jq"
|
||||||
echo " Fedora: sudo dnf install jq"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "[ok] jq found"
|
echo "[ok] jq found"
|
||||||
|
|
||||||
# Check bash version
|
# ── Build release binary ─────────────────────────────────────────────
|
||||||
if (( BASH_VERSINFO[0] < 4 )); then
|
echo ""
|
||||||
echo "WARNING: bash 4+ recommended (you have ${BASH_VERSION})"
|
echo "Building release binary..."
|
||||||
echo " macOS ships bash 3. Install bash 4+:"
|
(cd "$SCRIPT_DIR" && cargo build --release --quiet)
|
||||||
echo " brew install bash"
|
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
|
fi
|
||||||
|
|
||||||
# Ensure ~/.claude exists
|
# ── Configure Claude Code settings.json ──────────────────────────────
|
||||||
|
echo ""
|
||||||
mkdir -p "$CLAUDE_DIR"
|
mkdir -p "$CLAUDE_DIR"
|
||||||
|
|
||||||
# Create symlinks
|
BINARY_PATH="$INSTALL_DIR/$BINARY_NAME"
|
||||||
create_link() {
|
|
||||||
local src="$1" dst="$2" name="$3"
|
|
||||||
if [[ -L "$dst" ]]; then
|
|
||||||
local existing
|
|
||||||
existing="$(readlink "$dst")"
|
|
||||||
if [[ "$existing" == "$src" ]]; then
|
|
||||||
echo "[ok] $name already linked"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "[update] $name: updating symlink"
|
|
||||||
ln -sf "$src" "$dst"
|
|
||||||
elif [[ -f "$dst" ]]; then
|
|
||||||
echo "[skip] $name: $dst exists as a regular file"
|
|
||||||
echo " To use the symlink, rename or remove the existing file first."
|
|
||||||
return
|
|
||||||
else
|
|
||||||
ln -s "$src" "$dst"
|
|
||||||
echo "[ok] $name linked"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
create_link "$SCRIPT_DIR/statusline.sh" "$CLAUDE_DIR/statusline.sh" "statusline.sh"
|
# The binary runs in a non-TTY context, so force color on.
|
||||||
|
STATUSLINE_CMD="$BINARY_PATH --color=always"
|
||||||
|
|
||||||
# Optionally link user config if they want to start from an example
|
if [[ -f "$SETTINGS" ]]; then
|
||||||
if [[ ! -f "$CLAUDE_DIR/statusline.json" ]]; then
|
# Update existing settings.json
|
||||||
echo "[info] No statusline.json found. You can copy an example from:"
|
CURRENT_CMD=$(jq -r '.statusLine.command // empty' "$SETTINGS" 2>/dev/null || true)
|
||||||
echo " $SCRIPT_DIR/examples/"
|
if [[ -n "$CURRENT_CMD" ]]; then
|
||||||
|
echo "[info] Current statusLine command: $CURRENT_CMD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write updated settings
|
||||||
|
TMP="$SETTINGS.tmp.$$"
|
||||||
|
jq --arg cmd "$STATUSLINE_CMD" '.statusLine = {"type": "command", "command": $cmd, "padding": 0}' "$SETTINGS" > "$TMP"
|
||||||
|
mv "$TMP" "$SETTINGS"
|
||||||
|
echo "[ok] Updated statusLine in $SETTINGS"
|
||||||
|
else
|
||||||
|
# Create minimal settings.json
|
||||||
|
jq -n --arg cmd "$STATUSLINE_CMD" '{"statusLine": {"type": "command", "command": $cmd, "padding": 0}}' > "$SETTINGS"
|
||||||
|
echo "[ok] Created $SETTINGS"
|
||||||
fi
|
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 ""
|
||||||
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 ""
|
||||||
echo "Add this to ~/.claude/settings.json:"
|
echo "Quick test: $BINARY_NAME --test --color=always"
|
||||||
echo ""
|
echo "Debug: $BINARY_NAME --test --dump-state=json"
|
||||||
echo ' "statusLine": "'$CLAUDE_DIR'/statusline.sh"'
|
|
||||||
echo ""
|
|
||||||
echo "If ~/.claude/settings.json doesn't exist yet, create it:"
|
|
||||||
echo ""
|
|
||||||
echo ' {'
|
|
||||||
echo ' "statusLine": "'$CLAUDE_DIR'/statusline.sh"'
|
|
||||||
echo ' }'
|
|
||||||
echo ""
|
|
||||||
echo "Then restart Claude Code to see the status line."
|
|
||||||
|
|||||||
3091
rust_prd.md
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",
|
"title": "Claude Code Status Line Configuration",
|
||||||
"description": "Configuration for the claude-statusline status line script",
|
"description": "Configuration for the claude-statusline status line script",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
|
||||||
"version"
|
|
||||||
],
|
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"$schema": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON Schema reference for editor support"
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"const": 1,
|
"const": 1,
|
||||||
@@ -77,6 +78,15 @@
|
|||||||
"token_velocity": {
|
"token_velocity": {
|
||||||
"$ref": "#/$defs/basicSection"
|
"$ref": "#/$defs/basicSection"
|
||||||
},
|
},
|
||||||
|
"cost_trend": {
|
||||||
|
"$ref": "#/$defs/trendSection"
|
||||||
|
},
|
||||||
|
"context_trend": {
|
||||||
|
"$ref": "#/$defs/contextTrendSection"
|
||||||
|
},
|
||||||
|
"context_remaining": {
|
||||||
|
"$ref": "#/$defs/basicSection"
|
||||||
|
},
|
||||||
"lines_changed": {
|
"lines_changed": {
|
||||||
"$ref": "#/$defs/basicSection"
|
"$ref": "#/$defs/basicSection"
|
||||||
},
|
},
|
||||||
@@ -154,9 +164,15 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"separator": {
|
"separator": {
|
||||||
"type": "string",
|
"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": " | "
|
"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": {
|
"justify": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -181,7 +197,7 @@
|
|||||||
"width": {
|
"width": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 40,
|
"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": {
|
"width_margin": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -227,22 +243,11 @@
|
|||||||
},
|
},
|
||||||
"colorName": {
|
"colorName": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"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')."
|
||||||
"red",
|
|
||||||
"green",
|
|
||||||
"yellow",
|
|
||||||
"blue",
|
|
||||||
"magenta",
|
|
||||||
"cyan",
|
|
||||||
"white",
|
|
||||||
"dim",
|
|
||||||
"bold"
|
|
||||||
],
|
|
||||||
"description": "Named ANSI color"
|
|
||||||
},
|
},
|
||||||
"colorPalette": {
|
"colorPalette": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Semantic color palette for a theme",
|
"description": "Semantic color palette for a theme. Values support all colorName formats.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"success": { "type": "string" },
|
"success": { "type": "string" },
|
||||||
"warning": { "type": "string" },
|
"warning": { "type": "string" },
|
||||||
@@ -343,6 +348,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -415,6 +428,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -475,6 +496,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -503,6 +532,25 @@
|
|||||||
"minimum": 3,
|
"minimum": 3,
|
||||||
"default": 10
|
"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": {
|
"thresholds": {
|
||||||
"$ref": "#/$defs/thresholds"
|
"$ref": "#/$defs/thresholds"
|
||||||
},
|
},
|
||||||
@@ -523,6 +571,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -563,6 +619,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -599,6 +663,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -633,6 +705,135 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -656,6 +857,26 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true
|
"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": {
|
"ttl": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"default": 2
|
"default": 2
|
||||||
@@ -677,6 +898,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -711,6 +940,14 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
@@ -747,26 +984,23 @@
|
|||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
"$ref": "#/$defs/colorName"
|
"$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
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"colorMatch": {
|
"colorMatch": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Map of output value to color name",
|
"description": "Map of output value to color specifier",
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"enum": [
|
|
||||||
"red",
|
|
||||||
"green",
|
|
||||||
"yellow",
|
|
||||||
"blue",
|
|
||||||
"magenta",
|
|
||||||
"cyan",
|
|
||||||
"white",
|
|
||||||
"dim",
|
|
||||||
"bold"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customCommand": {
|
"customCommand": {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
use claude_statusline::section::RenderContext;
|
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::io::Read;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
@@ -18,12 +25,27 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if args.iter().any(|a| a == "--list-sections") {
|
if args.iter().any(|a| a == "--list-sections") {
|
||||||
for (id, _) in section::registry() {
|
println!("{:<22} pri flex shell est_w", "ID");
|
||||||
println!("{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;
|
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
|
let cli_color = args
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|a| a.strip_prefix("--color="))
|
.find_map(|a| a.strip_prefix("--color="))
|
||||||
@@ -57,7 +79,7 @@ fn main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load config
|
// 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,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("claude-statusline: {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() {
|
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() {
|
||||||
return;
|
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) {
|
match serde_json::from_str(&buf) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -102,8 +127,14 @@ fn main() {
|
|||||||
|
|
||||||
// Detect environment
|
// Detect environment
|
||||||
let detected_theme = theme::detect_theme(&config);
|
let detected_theme = theme::detect_theme(&config);
|
||||||
let term_width =
|
let stdin_width = input_data.workspace.as_ref().and_then(|w| w.terminal_width);
|
||||||
width::detect_width(cli_width, config.global.width, config.global.width_margin);
|
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(
|
let tier = width::width_tier(
|
||||||
term_width,
|
term_width,
|
||||||
config.global.breakpoints.narrow,
|
config.global.breakpoints.narrow,
|
||||||
@@ -117,31 +148,73 @@ fn main() {
|
|||||||
.unwrap_or(".");
|
.unwrap_or(".");
|
||||||
|
|
||||||
let session = cache::session_id(project_dir);
|
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 color_enabled = color::should_use_color(cli_color.as_deref(), &config.global.color);
|
||||||
|
|
||||||
let vcs_type = detect_vcs(project_dir, &config);
|
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
|
// Build render context
|
||||||
let project_path = std::path::Path::new(project_dir);
|
let project_path = std::path::Path::new(project_dir);
|
||||||
let computed_metrics = metrics::ComputedMetrics::from_input(&input_data);
|
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 {
|
let ctx = RenderContext {
|
||||||
input: &input_data,
|
input: &input_data,
|
||||||
config: &config,
|
config: &config,
|
||||||
@@ -153,11 +226,261 @@ fn main() {
|
|||||||
cache: &cache,
|
cache: &cache,
|
||||||
glyphs_enabled: config.glyphs.enabled,
|
glyphs_enabled: config.glyphs.enabled,
|
||||||
color_enabled,
|
color_enabled,
|
||||||
|
no_shell,
|
||||||
|
shell_config: &shell_config,
|
||||||
metrics: computed_metrics,
|
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);
|
let output = layout::render_all(&ctx);
|
||||||
print!("{output}");
|
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 {
|
fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType {
|
||||||
@@ -196,29 +519,84 @@ fn dump_state_output(
|
|||||||
format: &str,
|
format: &str,
|
||||||
config: &config::Config,
|
config: &config::Config,
|
||||||
term_width: u16,
|
term_width: u16,
|
||||||
|
width_source: &str,
|
||||||
tier: width::WidthTier,
|
tier: width::WidthTier,
|
||||||
theme: theme::Theme,
|
theme: theme::Theme,
|
||||||
vcs: section::VcsType,
|
vcs: section::VcsType,
|
||||||
project_dir: &str,
|
project_dir: &str,
|
||||||
session_id: &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!({
|
let json = serde_json::json!({
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"effective_width": term_width,
|
"effective_width": term_width,
|
||||||
"width_margin": config.global.width_margin,
|
"width_margin": config.global.width_margin,
|
||||||
"width_tier": format!("{tier:?}"),
|
"width_tier": format!("{tier:?}"),
|
||||||
|
"source": width_source,
|
||||||
},
|
},
|
||||||
"theme": theme.as_str(),
|
"theme": theme.as_str(),
|
||||||
"vcs": format!("{vcs:?}"),
|
"vcs": format!("{vcs:?}"),
|
||||||
"layout": {
|
"layout": {
|
||||||
"justify": format!("{:?}", config.global.justify),
|
"justify": format!("{:?}", config.global.justify),
|
||||||
"separator": &config.global.separator,
|
"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": {
|
"paths": {
|
||||||
"project_dir": project_dir,
|
"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,
|
"session_id": session_id,
|
||||||
|
"input": &ctx.input,
|
||||||
});
|
});
|
||||||
|
|
||||||
match format {
|
match format {
|
||||||
|
|||||||
243
src/cache.rs
243
src/cache.rs
@@ -1,21 +1,56 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, SystemTime};
|
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 {
|
pub struct Cache {
|
||||||
dir: Option<PathBuf>,
|
dir: Option<PathBuf>,
|
||||||
|
jitter_pct: u8,
|
||||||
|
diagnostics: RefCell<Vec<CacheDiag>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cache {
|
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.
|
/// Create cache with secure directory. Returns disabled cache on failure.
|
||||||
pub fn new(template: &str, session_id: &str) -> Self {
|
/// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
|
||||||
let dir_str = template.replace("{session_id}", session_id);
|
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);
|
let dir = PathBuf::from(&dir_str);
|
||||||
|
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
if fs::create_dir_all(&dir).is_err() {
|
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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -26,29 +61,105 @@ impl Cache {
|
|||||||
|
|
||||||
// Security: verify ownership, not a symlink, not world-writable
|
// Security: verify ownership, not a symlink, not world-writable
|
||||||
if !verify_cache_dir(&dir) {
|
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> {
|
pub fn dir(&self) -> Option<&Path> {
|
||||||
self.dir.as_deref()
|
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> {
|
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
|
||||||
let path = self.key_path(key)?;
|
let path = match self.key_path(key) {
|
||||||
let meta = fs::metadata(&path).ok()?;
|
Some(p) => p,
|
||||||
let modified = meta.modified().ok()?;
|
None => {
|
||||||
let age = SystemTime::now().duration_since(modified).ok()?;
|
self.record_diag(key, false, None);
|
||||||
if age < ttl {
|
return None;
|
||||||
fs::read_to_string(&path).ok()
|
}
|
||||||
|
};
|
||||||
|
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 {
|
} else {
|
||||||
|
self.record_diag(key, false, Some(age_ms));
|
||||||
None
|
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.
|
/// Get stale cached value (ignores TTL). Used as fallback on command failure.
|
||||||
pub fn get_stale(&self, key: &str) -> Option<String> {
|
pub fn get_stale(&self, key: &str) -> Option<String> {
|
||||||
let path = self.key_path(key)?;
|
let path = self.key_path(key)?;
|
||||||
@@ -76,6 +187,23 @@ impl Cache {
|
|||||||
Some(())
|
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> {
|
fn key_path(&self, key: &str) -> Option<PathBuf> {
|
||||||
let dir = self.dir.as_ref()?;
|
let dir = self.dir.as_ref()?;
|
||||||
let safe_key: String = key
|
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());
|
let hash = Md5::digest(project_dir.as_bytes());
|
||||||
format!("{:x}", hash)[..12].to_string()
|
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 RESET: &str = "\x1b[0m";
|
||||||
pub const BOLD: &str = "\x1b[1m";
|
pub const BOLD: &str = "\x1b[1m";
|
||||||
pub const DIM: &str = "\x1b[2m";
|
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 RED: &str = "\x1b[31m";
|
||||||
pub const GREEN: &str = "\x1b[32m";
|
pub const GREEN: &str = "\x1b[32m";
|
||||||
pub const YELLOW: &str = "\x1b[33m";
|
pub const YELLOW: &str = "\x1b[33m";
|
||||||
@@ -12,7 +15,92 @@ pub const MAGENTA: &str = "\x1b[35m";
|
|||||||
pub const CYAN: &str = "\x1b[36m";
|
pub const CYAN: &str = "\x1b[36m";
|
||||||
pub const WHITE: &str = "\x1b[37m";
|
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).
|
/// 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 {
|
pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String {
|
||||||
if let Some(key) = name.strip_prefix("p:") {
|
if let Some(key) = name.strip_prefix("p:") {
|
||||||
let map = match theme {
|
let map = match theme {
|
||||||
@@ -27,18 +115,53 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String
|
|||||||
|
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
for part in name.split_whitespace() {
|
for part in name.split_whitespace() {
|
||||||
result.push_str(match part {
|
let resolved = match part {
|
||||||
"red" => RED,
|
// Named foreground colors
|
||||||
"green" => GREEN,
|
"red" => RED.to_string(),
|
||||||
"yellow" => YELLOW,
|
"green" => GREEN.to_string(),
|
||||||
"blue" => BLUE,
|
"yellow" => YELLOW.to_string(),
|
||||||
"magenta" => MAGENTA,
|
"blue" => BLUE.to_string(),
|
||||||
"cyan" => CYAN,
|
"magenta" => MAGENTA.to_string(),
|
||||||
"white" => WHITE,
|
"cyan" => CYAN.to_string(),
|
||||||
"dim" => DIM,
|
"white" => WHITE.to_string(),
|
||||||
"bold" => BOLD,
|
// 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() {
|
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.
|
/// 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 {
|
pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool {
|
||||||
if std::env::var("NO_COLOR").is_ok() {
|
if std::env::var("NO_COLOR").is_ok() {
|
||||||
return false;
|
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 {
|
match config_color {
|
||||||
crate::config::ColorMode::Always => true,
|
crate::config::ColorMode::Always => true,
|
||||||
crate::config::ColorMode::Never => false,
|
crate::config::ColorMode::Never => false,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ impl Default for LayoutValue {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct GlobalConfig {
|
pub struct GlobalConfig {
|
||||||
pub separator: String,
|
pub separator: String,
|
||||||
|
pub separator_style: SeparatorStyle,
|
||||||
pub justify: JustifyMode,
|
pub justify: JustifyMode,
|
||||||
pub vcs: String,
|
pub vcs: String,
|
||||||
pub width: Option<u16>,
|
pub width: Option<u16>,
|
||||||
@@ -69,18 +70,18 @@ pub struct GlobalConfig {
|
|||||||
pub shell_env: HashMap<String, String>,
|
pub shell_env: HashMap<String, String>,
|
||||||
pub cache_version: u32,
|
pub cache_version: u32,
|
||||||
pub drop_strategy: String,
|
pub drop_strategy: String,
|
||||||
pub breakpoint_hysteresis: u16,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GlobalConfig {
|
impl Default for GlobalConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
separator: " | ".into(),
|
separator: " | ".into(),
|
||||||
|
separator_style: SeparatorStyle::Text,
|
||||||
justify: JustifyMode::Left,
|
justify: JustifyMode::Left,
|
||||||
vcs: "auto".into(),
|
vcs: "auto".into(),
|
||||||
width: None,
|
width: None,
|
||||||
width_margin: 4,
|
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_days: 7,
|
||||||
cache_gc_interval_hours: 24,
|
cache_gc_interval_hours: 24,
|
||||||
cache_ttl_jitter_pct: 10,
|
cache_ttl_jitter_pct: 10,
|
||||||
@@ -100,7 +101,6 @@ impl Default for GlobalConfig {
|
|||||||
shell_env: HashMap::new(),
|
shell_env: HashMap::new(),
|
||||||
cache_version: 1,
|
cache_version: 1,
|
||||||
drop_strategy: "tiered".into(),
|
drop_strategy: "tiered".into(),
|
||||||
breakpoint_hysteresis: 2,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,11 +123,30 @@ pub enum ColorMode {
|
|||||||
Never,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Breakpoints {
|
pub struct Breakpoints {
|
||||||
pub narrow: u16,
|
pub narrow: u16,
|
||||||
pub medium: u16,
|
pub medium: u16,
|
||||||
|
pub hysteresis: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Breakpoints {
|
impl Default for Breakpoints {
|
||||||
@@ -135,6 +154,7 @@ impl Default for Breakpoints {
|
|||||||
Self {
|
Self {
|
||||||
narrow: 60,
|
narrow: 60,
|
||||||
medium: 100,
|
medium: 100,
|
||||||
|
hysteresis: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,6 +192,8 @@ pub struct SectionBase {
|
|||||||
pub pad: Option<u16>,
|
pub pad: Option<u16>,
|
||||||
pub align: Option<String>,
|
pub align: Option<String>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
pub background: Option<String>,
|
||||||
|
pub placeholder: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SectionBase {
|
impl Default for SectionBase {
|
||||||
@@ -186,6 +208,8 @@ impl Default for SectionBase {
|
|||||||
pad: None,
|
pad: None,
|
||||||
align: None,
|
align: None,
|
||||||
color: None,
|
color: None,
|
||||||
|
background: None,
|
||||||
|
placeholder: Some("\u{2500}".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +243,10 @@ pub struct Sections {
|
|||||||
pub time: TimeSection,
|
pub time: TimeSection,
|
||||||
pub output_style: SectionBase,
|
pub output_style: SectionBase,
|
||||||
pub hostname: SectionBase,
|
pub hostname: SectionBase,
|
||||||
|
pub cloud_profile: SectionBase,
|
||||||
|
pub k8s_context: CachedSection,
|
||||||
|
pub python_env: SectionBase,
|
||||||
|
pub toolchain: SectionBase,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -356,6 +384,10 @@ pub struct ContextBarSection {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub base: SectionBase,
|
pub base: SectionBase,
|
||||||
pub bar_width: u16,
|
pub bar_width: u16,
|
||||||
|
pub bar_style: BarStyle,
|
||||||
|
pub gradient: bool,
|
||||||
|
pub fill_char: Option<String>,
|
||||||
|
pub empty_char: Option<String>,
|
||||||
pub thresholds: Thresholds,
|
pub thresholds: Thresholds,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +401,10 @@ impl Default for ContextBarSection {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
bar_width: 10,
|
bar_width: 10,
|
||||||
|
bar_style: BarStyle::Block,
|
||||||
|
gradient: true,
|
||||||
|
fill_char: None,
|
||||||
|
empty_char: None,
|
||||||
thresholds: Thresholds {
|
thresholds: Thresholds {
|
||||||
warn: 50.0,
|
warn: 50.0,
|
||||||
danger: 70.0,
|
danger: 70.0,
|
||||||
@@ -492,6 +528,7 @@ pub struct TrendSection {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub base: SectionBase,
|
pub base: SectionBase,
|
||||||
pub width: u8,
|
pub width: u8,
|
||||||
|
pub gradient: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TrendSection {
|
impl Default for TrendSection {
|
||||||
@@ -502,6 +539,7 @@ impl Default for TrendSection {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
width: 8,
|
width: 8,
|
||||||
|
gradient: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,6 +550,7 @@ pub struct ContextTrendSection {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub base: SectionBase,
|
pub base: SectionBase,
|
||||||
pub width: u8,
|
pub width: u8,
|
||||||
|
pub gradient: bool,
|
||||||
pub thresholds: Thresholds,
|
pub thresholds: Thresholds,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +562,7 @@ impl Default for ContextTrendSection {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
width: 8,
|
width: 8,
|
||||||
|
gradient: true,
|
||||||
thresholds: Thresholds::default(),
|
thresholds: Thresholds::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,6 +574,13 @@ pub struct ToolsSection {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub base: SectionBase,
|
pub base: SectionBase,
|
||||||
pub show_last_name: bool,
|
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,
|
pub ttl: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,6 +593,9 @@ impl Default for ToolsSection {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
show_last_name: true,
|
show_last_name: true,
|
||||||
|
show_breakdown: true,
|
||||||
|
top_n: 7,
|
||||||
|
palette: Vec::new(),
|
||||||
ttl: 2,
|
ttl: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,7 +710,11 @@ pub fn deep_merge(base: &mut Value, patch: &Value) {
|
|||||||
// ── Config loading ──────────────────────────────────────────────────────
|
// ── Config loading ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Load config: embedded defaults deep-merged with user overrides.
|
/// 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 mut base: Value = serde_json::from_str(DEFAULTS_JSON)?;
|
||||||
|
|
||||||
let user_path = explicit_path
|
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 mut warnings = Vec::new();
|
||||||
let config: Config = serde_ignored::deserialize(base, |path| {
|
let config: Config = serde_ignored::deserialize(base, |path| {
|
||||||
warnings.push(format!("unknown config key: {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> {
|
fn xdg_config_path() -> Option<std::path::PathBuf> {
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ pub fn apply_formatting(
|
|||||||
*ansi = format!("{c}{raw}{}", color::RESET);
|
*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 {
|
if let Some(pad) = base.pad {
|
||||||
let pad = pad as usize;
|
let pad = pad as usize;
|
||||||
let raw_w = display_width(raw);
|
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)]
|
#[serde(default)]
|
||||||
pub struct InputData {
|
pub struct InputData {
|
||||||
pub model: Option<ModelInfo>,
|
pub model: Option<ModelInfo>,
|
||||||
@@ -9,28 +9,52 @@ pub struct InputData {
|
|||||||
pub workspace: Option<Workspace>,
|
pub workspace: Option<Workspace>,
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
pub output_style: Option<OutputStyle>,
|
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)]
|
#[serde(default)]
|
||||||
pub struct ModelInfo {
|
pub struct ModelInfo {
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CostInfo {
|
pub struct CostInfo {
|
||||||
pub total_cost_usd: Option<f64>,
|
pub total_cost_usd: Option<f64>,
|
||||||
pub total_duration_ms: Option<u64>,
|
pub total_duration_ms: Option<u64>,
|
||||||
|
pub total_api_duration_ms: Option<u64>,
|
||||||
pub total_lines_added: Option<u64>,
|
pub total_lines_added: Option<u64>,
|
||||||
pub total_lines_removed: Option<u64>,
|
pub total_lines_removed: Option<u64>,
|
||||||
pub total_tool_uses: Option<u64>,
|
pub total_tool_uses: Option<u64>,
|
||||||
pub last_tool_name: Option<String>,
|
pub last_tool_name: Option<String>,
|
||||||
pub total_turns: Option<u64>,
|
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)]
|
#[serde(default)]
|
||||||
pub struct ContextWindow {
|
pub struct ContextWindow {
|
||||||
pub used_percentage: Option<f64>,
|
pub used_percentage: Option<f64>,
|
||||||
@@ -40,20 +64,23 @@ pub struct ContextWindow {
|
|||||||
pub current_usage: Option<CurrentUsage>,
|
pub current_usage: Option<CurrentUsage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CurrentUsage {
|
pub struct CurrentUsage {
|
||||||
|
pub input_tokens: Option<u64>,
|
||||||
|
pub output_tokens: Option<u64>,
|
||||||
pub cache_read_input_tokens: Option<u64>,
|
pub cache_read_input_tokens: Option<u64>,
|
||||||
pub cache_creation_input_tokens: Option<u64>,
|
pub cache_creation_input_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
pub project_dir: Option<String>,
|
pub project_dir: Option<String>,
|
||||||
|
pub terminal_width: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct OutputStyle {
|
pub struct OutputStyle {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|||||||
@@ -44,12 +44,14 @@ pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator:
|
|||||||
ansi: padding,
|
ansi: padding,
|
||||||
};
|
};
|
||||||
} else if active[idx].id == "context_bar" {
|
} else if active[idx].id == "context_bar" {
|
||||||
// Rebuild context_bar with wider bar_width
|
// Rebuild context_bar with wider bar_width.
|
||||||
let cur_bar_width = ctx.config.sections.context_bar.bar_width;
|
// Account for prefix/suffix that apply_formatting will add,
|
||||||
let new_bar_width = cur_bar_width + extra as u16;
|
// so the final width doesn't overshoot term_width.
|
||||||
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;
|
let base = &ctx.config.sections.context_bar.base;
|
||||||
|
let fmt_overhead = formatting_overhead(base);
|
||||||
|
let cur_bar_width = ctx.config.sections.context_bar.bar_width;
|
||||||
|
let new_bar_width = cur_bar_width + extra.saturating_sub(fmt_overhead) as u16;
|
||||||
|
if let Some(mut output) = section::context_bar::render_at_width(ctx, new_bar_width) {
|
||||||
format::apply_formatting(
|
format::apply_formatting(
|
||||||
&mut output.raw,
|
&mut output.raw,
|
||||||
&mut output.ansi,
|
&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 {
|
fn line_width(active: &[ActiveSection], separator: &str) -> usize {
|
||||||
let sep_w = format::display_width(separator);
|
let sep_w = format::display_width(separator);
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub fn justify(
|
|||||||
term_width: u16,
|
term_width: u16,
|
||||||
separator: &str,
|
separator: &str,
|
||||||
_mode: JustifyMode,
|
_mode: JustifyMode,
|
||||||
|
color_enabled: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
let content_width: usize = active
|
let content_width: usize = active
|
||||||
.iter()
|
.iter()
|
||||||
@@ -37,11 +38,15 @@ pub fn justify(
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
let this_gap = gap_width + usize::from(i - 1 < gap_remainder);
|
let this_gap = gap_width + usize::from(i - 1 < gap_remainder);
|
||||||
let gap_str = build_gap(sep_core, sep_core_len, this_gap);
|
let gap_str = build_gap(sep_core, sep_core_len, this_gap);
|
||||||
|
if color_enabled {
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"{}{gap_str}{}",
|
"{}{gap_str}{}",
|
||||||
crate::color::DIM,
|
crate::color::DIM,
|
||||||
crate::color::RESET
|
crate::color::RESET
|
||||||
));
|
));
|
||||||
|
} else {
|
||||||
|
output.push_str(&gap_str);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
output.push_str(&sec.output.ansi);
|
output.push_str(&sec.output.ansi);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ pub mod flex;
|
|||||||
pub mod justify;
|
pub mod justify;
|
||||||
pub mod priority;
|
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};
|
use crate::section::{self, RenderContext, SectionOutput};
|
||||||
|
|
||||||
/// A section that survived priority drops and has rendered output.
|
/// 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 {
|
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"
|
"dense"
|
||||||
} else if width < bp.medium {
|
} else if width < bp.medium {
|
||||||
"standard"
|
"standard"
|
||||||
} else {
|
} else {
|
||||||
"verbose"
|
"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.
|
/// Full render: resolve layout, render each line, join with newlines.
|
||||||
pub fn render_all(ctx: &RenderContext) -> String {
|
pub fn render_all(ctx: &RenderContext) -> String {
|
||||||
let layout = resolve_layout(ctx.config, ctx.term_width);
|
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
|
let lines: Vec<String> = layout
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|line_ids| render_line(line_ids, ctx, separator))
|
.filter_map(|line_ids| render_line(line_ids, ctx, &separator))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
@@ -64,11 +133,30 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
|
|||||||
let mut active: Vec<ActiveSection> = Vec::new();
|
let mut active: Vec<ActiveSection> = Vec::new();
|
||||||
|
|
||||||
for id in section_ids {
|
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) {
|
if output.raw.is_empty() && !section::is_spacer(id) {
|
||||||
continue;
|
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);
|
let (prio, is_flex) = section_meta(id, ctx.config);
|
||||||
active.push(ActiveSection {
|
active.push(ActiveSection {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -77,6 +165,54 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
|
|||||||
is_spacer: section::is_spacer(id),
|
is_spacer: section::is_spacer(id),
|
||||||
is_flex,
|
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
|
// 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
|
// Phase 3: Flex expand or justify
|
||||||
let line = if ctx.config.global.justify != JustifyMode::Left
|
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,
|
ctx.term_width,
|
||||||
separator,
|
separator,
|
||||||
ctx.config.global.justify,
|
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 {
|
} else {
|
||||||
flex::flex_expand(&mut active, ctx, separator);
|
flex::flex_expand(&mut active, ctx, separator);
|
||||||
assemble_left(&active, separator, ctx.color_enabled)
|
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
|
_ => (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::format;
|
||||||
use crate::layout::ActiveSection;
|
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.
|
/// Priority 1 sections never drop.
|
||||||
pub fn priority_drop(
|
fn tiered_drop(
|
||||||
mut active: Vec<ActiveSection>,
|
mut active: Vec<ActiveSection>,
|
||||||
term_width: u16,
|
term_width: u16,
|
||||||
separator: &str,
|
separator: &str,
|
||||||
@@ -23,9 +36,42 @@ pub fn priority_drop(
|
|||||||
active
|
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.
|
/// Calculate total display width including separators.
|
||||||
/// Spacers suppress adjacent separators on both sides.
|
/// 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 sep_w = format::display_width(separator);
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ pub mod layout;
|
|||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod section;
|
pub mod section;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod terminal;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
pub mod transcript;
|
||||||
pub mod trend;
|
pub mod trend;
|
||||||
pub mod width;
|
pub mod width;
|
||||||
|
|||||||
@@ -21,19 +21,21 @@ impl ComputedMetrics {
|
|||||||
m.usage_pct = cw.used_percentage.unwrap_or(0.0);
|
m.usage_pct = cw.used_percentage.unwrap_or(0.0);
|
||||||
|
|
||||||
if let Some(ref usage) = cw.current_usage {
|
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_read = usage.cache_read_input_tokens.unwrap_or(0);
|
||||||
let cache_create = usage.cache_creation_input_tokens.unwrap_or(0);
|
let cache_create = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||||
let total_cache = cache_read + cache_create;
|
let total_input = input + cache_create + cache_read;
|
||||||
if total_cache > 0 {
|
if total_input > 0 {
|
||||||
m.cache_efficiency_pct = Some(cache_read as f64 / total_cache as f64 * 100.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(ref cost) = input.cost {
|
||||||
if let (Some(cost_usd), Some(duration_ms)) =
|
// Prefer API duration (active processing time) over wall-clock
|
||||||
(cost.total_cost_usd, cost.total_duration_ms)
|
// 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 {
|
if duration_ms > 0 {
|
||||||
let minutes = duration_ms as f64 / 60_000.0;
|
let minutes = duration_ms as f64 / 60_000.0;
|
||||||
m.cost_velocity = Some(cost_usd / minutes);
|
m.cost_velocity = Some(cost_usd / minutes);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::section::{RenderContext, SectionOutput};
|
|||||||
use crate::shell;
|
use crate::shell;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Cached format: "open:N,wip:N,ready:N,closed:N"
|
||||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||||
if !ctx.config.sections.beads.base.enabled {
|
if !ctx.config.sections.beads.base.enabled {
|
||||||
return None;
|
return None;
|
||||||
@@ -13,31 +14,84 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ttl = Duration::from_secs(ctx.config.sections.beads.ttl);
|
// --no-shell: serve stale cache only
|
||||||
let timeout = Duration::from_millis(200);
|
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(|| {
|
let summary = cached.or_else(|| {
|
||||||
// Run br ready to get count of ready items
|
let out = ctx.shell_results.get("beads").cloned().unwrap_or_else(|| {
|
||||||
let out = shell::exec_with_timeout(
|
shell::exec_gated(
|
||||||
|
ctx.shell_config,
|
||||||
"br",
|
"br",
|
||||||
&["ready", "--json"],
|
&["stats", "--json"],
|
||||||
Some(ctx.project_dir.to_str()?),
|
Some(ctx.project_dir.to_str()?),
|
||||||
timeout,
|
)
|
||||||
)?;
|
})?;
|
||||||
// Count JSON array items (simple: count opening braces at indent level 1)
|
let summary = parse_stats(&out)?;
|
||||||
let count = out.matches("\"id\"").count();
|
ctx.cache.set("beads_stats", &summary);
|
||||||
let summary = format!("{count}");
|
|
||||||
ctx.cache.set("beads_summary", &summary);
|
|
||||||
Some(summary)
|
Some(summary)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let count: usize = summary.trim().parse().unwrap_or(0);
|
render_from_summary(ctx, &summary)
|
||||||
if count == 0 {
|
}
|
||||||
|
|
||||||
|
/// 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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = format!("{count} ready");
|
let raw = parts.join(" ");
|
||||||
let ansi = if ctx.color_enabled {
|
let ansi = if ctx.color_enabled {
|
||||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||||
} else {
|
} 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::color;
|
||||||
|
use crate::config::BarStyle;
|
||||||
use crate::section::{RenderContext, SectionOutput};
|
use crate::section::{RenderContext, SectionOutput};
|
||||||
|
|
||||||
/// Render context bar at a given bar_width. Called both at initial render
|
/// Render context bar at a given bar_width. Called both at initial render
|
||||||
/// and during flex expansion (with wider bar_width).
|
/// and during flex expansion (with wider bar_width).
|
||||||
pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOutput> {
|
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 pct_int = pct.round() as u16;
|
||||||
|
|
||||||
let filled = (u32::from(pct_int) * u32::from(bar_width) / 100) as usize;
|
let filled = (u32::from(pct_int) * u32::from(bar_width) / 100) as usize;
|
||||||
let empty = bar_width as usize - filled;
|
let empty = bar_width as usize - filled;
|
||||||
|
|
||||||
let bar = "=".repeat(filled) + &"-".repeat(empty);
|
let cfg = &ctx.config.sections.context_bar;
|
||||||
let raw = format!("[{bar}] {pct_int}%");
|
let thresh = &cfg.thresholds;
|
||||||
|
|
||||||
let thresh = &ctx.config.sections.context_bar.thresholds;
|
// Determine characters based on bar_style
|
||||||
let color_code = threshold_color(pct, thresh);
|
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 {
|
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 {
|
} else {
|
||||||
raw.clone()
|
raw.clone()
|
||||||
};
|
};
|
||||||
@@ -25,6 +53,41 @@ pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOut
|
|||||||
Some(SectionOutput { raw, ansi })
|
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> {
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||||
if !ctx.config.sections.context_bar.base.enabled {
|
if !ctx.config.sections.context_bar.base.enabled {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -8,19 +8,38 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
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 pct_int = pct.round() as i64;
|
||||||
|
|
||||||
let width = ctx.config.sections.context_trend.width as usize;
|
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,
|
ctx.cache,
|
||||||
"context",
|
"context",
|
||||||
pct_int,
|
pct_int,
|
||||||
width,
|
width,
|
||||||
Duration::from_secs(30),
|
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() {
|
if spark.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,32 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cw = ctx.input.context_window.as_ref()?;
|
let cw = ctx.input.context_window.as_ref()?;
|
||||||
let pct = cw.used_percentage?;
|
let pct = cw.used_percentage.unwrap_or(0.0);
|
||||||
let pct_int = pct.round() as u64;
|
|
||||||
|
|
||||||
let total_input = cw.total_input_tokens.unwrap_or(0);
|
|
||||||
let total_output = cw.total_output_tokens.unwrap_or(0);
|
|
||||||
let used = total_input + total_output;
|
|
||||||
let capacity = cw
|
let capacity = cw
|
||||||
.context_window_size
|
.context_window_size
|
||||||
.unwrap_or(ctx.config.sections.context_usage.capacity);
|
.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!(
|
let raw = format!(
|
||||||
"{}/{} ({pct_int}%)",
|
"{}/{} ({pct:.1}%)",
|
||||||
format::human_tokens(used),
|
format::human_tokens(used),
|
||||||
format::human_tokens(capacity),
|
format::human_tokens(capacity),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
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 {
|
let decimals = match ctx.width_tier {
|
||||||
WidthTier::Narrow => 0,
|
WidthTier::Narrow => 0,
|
||||||
|
|||||||
@@ -8,19 +8,35 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
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 cost_cents = (cost_val * 100.0) as i64;
|
||||||
|
|
||||||
let width = ctx.config.sections.cost_trend.width as usize;
|
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,
|
ctx.cache,
|
||||||
"cost",
|
"cost",
|
||||||
cost_cents,
|
cost_cents,
|
||||||
width,
|
width,
|
||||||
Duration::from_secs(30),
|
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() {
|
if spark.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
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 raw = format!("${velocity:.2}/min");
|
||||||
|
|
||||||
let ansi = if ctx.color_enabled {
|
let ansi = if ctx.color_enabled {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::color;
|
use crate::color;
|
||||||
|
use crate::config::CustomCommand;
|
||||||
use crate::section::{RenderContext, SectionOutput};
|
use crate::section::{RenderContext, SectionOutput};
|
||||||
use crate::shell;
|
use crate::shell;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -7,10 +8,16 @@ use std::time::Duration;
|
|||||||
pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
||||||
let cmd_cfg = ctx.config.custom.iter().find(|c| c.id == id)?;
|
let cmd_cfg = ctx.config.custom.iter().find(|c| c.id == id)?;
|
||||||
|
|
||||||
let ttl = Duration::from_secs(cmd_cfg.ttl);
|
|
||||||
let timeout = Duration::from_millis(ctx.config.global.shell_timeout_ms);
|
|
||||||
let cache_key = format!("custom_{id}");
|
let 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 cached = ctx.cache.get(&cache_key, ttl);
|
||||||
let output_str = cached.or_else(|| {
|
let output_str = cached.or_else(|| {
|
||||||
let result = if let Some(ref exec) = cmd_cfg.exec {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let args: Vec<&str> = exec[1..].iter().map(|s| s.as_str()).collect();
|
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 {
|
} 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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -34,16 +41,29 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
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 label = cmd_cfg.label.as_deref().unwrap_or("");
|
||||||
let raw = if label.is_empty() {
|
let raw = if label.is_empty() {
|
||||||
output_str.clone()
|
output_str.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("{label}: {output_str}")
|
format!("{label}: {output_str}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let ansi = if ctx.color_enabled {
|
let ansi = if ctx.color_enabled {
|
||||||
if let Some(ref color_cfg) = cmd_cfg.color {
|
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);
|
let c = color::resolve_color(matched_color, ctx.theme, &ctx.config.colors);
|
||||||
format!("{c}{raw}{}", color::RESET)
|
format!("{c}{raw}{}", color::RESET)
|
||||||
} else if let Some(ref default_c) = cmd_cfg.default_color {
|
} else if let Some(ref default_c) = cmd_cfg.default_color {
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ms = ctx.input.cost.as_ref()?.total_duration_ms?;
|
let ms = ctx.input.cost.as_ref()?.total_duration_ms.unwrap_or(0);
|
||||||
if ms == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw = format::human_duration(ms);
|
let raw = format::human_duration(ms);
|
||||||
let ansi = if ctx.color_enabled {
|
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;
|
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 ttl = Duration::from_secs(ctx.config.sections.load.ttl);
|
||||||
let cached = ctx.cache.get("load_avg", ttl);
|
let cached = ctx.cache.get("load_avg", ttl);
|
||||||
|
|
||||||
@@ -21,12 +33,10 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let out = crate::shell::exec_with_timeout(
|
// Use prefetched result if available, otherwise exec
|
||||||
"sysctl",
|
let out = ctx.shell_results.get("load").cloned().unwrap_or_else(|| {
|
||||||
&["-n", "vm.loadavg"],
|
crate::shell::exec_gated(ctx.shell_config, "sysctl", &["-n", "vm.loadavg"], None)
|
||||||
None,
|
})?;
|
||||||
Duration::from_millis(100),
|
|
||||||
)?;
|
|
||||||
// sysctl output: "{ 1.23 4.56 7.89 }"
|
// sysctl output: "{ 1.23 4.56 7.89 }"
|
||||||
let load1 = out
|
let load1 = out
|
||||||
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.')
|
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.')
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ use crate::cache::Cache;
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::input::InputData;
|
use crate::input::InputData;
|
||||||
use crate::metrics::ComputedMetrics;
|
use crate::metrics::ComputedMetrics;
|
||||||
|
use crate::shell::ShellConfig;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
|
use crate::transcript::TranscriptStats;
|
||||||
use crate::width::WidthTier;
|
use crate::width::WidthTier;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub mod beads;
|
pub mod beads;
|
||||||
pub mod cache_efficiency;
|
pub mod cache_efficiency;
|
||||||
|
pub mod cloud_profile;
|
||||||
pub mod context_bar;
|
pub mod context_bar;
|
||||||
pub mod context_remaining;
|
pub mod context_remaining;
|
||||||
pub mod context_trend;
|
pub mod context_trend;
|
||||||
@@ -19,15 +22,18 @@ pub mod cost_velocity;
|
|||||||
pub mod custom;
|
pub mod custom;
|
||||||
pub mod duration;
|
pub mod duration;
|
||||||
pub mod hostname;
|
pub mod hostname;
|
||||||
|
pub mod k8s_context;
|
||||||
pub mod lines_changed;
|
pub mod lines_changed;
|
||||||
pub mod load;
|
pub mod load;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod output_style;
|
pub mod output_style;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
pub mod python_env;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
pub mod token_velocity;
|
pub mod token_velocity;
|
||||||
pub mod tokens_raw;
|
pub mod tokens_raw;
|
||||||
|
pub mod toolchain;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod turns;
|
pub mod turns;
|
||||||
pub mod vcs;
|
pub mod vcs;
|
||||||
@@ -62,36 +68,297 @@ pub struct RenderContext<'a> {
|
|||||||
pub cache: &'a Cache,
|
pub cache: &'a Cache,
|
||||||
pub glyphs_enabled: bool,
|
pub glyphs_enabled: bool,
|
||||||
pub color_enabled: bool,
|
pub color_enabled: bool,
|
||||||
|
pub no_shell: bool,
|
||||||
|
pub shell_config: &'a ShellConfig,
|
||||||
pub metrics: ComputedMetrics,
|
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.
|
impl RenderContext<'_> {
|
||||||
pub fn registry() -> Vec<(&'static str, RenderFn)> {
|
/// 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![
|
vec![
|
||||||
("model", model::render),
|
SectionDescriptor {
|
||||||
("provider", provider::render),
|
id: "model",
|
||||||
("project", project::render),
|
render: model::render,
|
||||||
("vcs", vcs::render),
|
priority: 1,
|
||||||
("beads", beads::render),
|
is_spacer: false,
|
||||||
("context_bar", context_bar::render),
|
is_flex: false,
|
||||||
("context_usage", context_usage::render),
|
estimated_width: 12,
|
||||||
("context_remaining", context_remaining::render),
|
shell_out: false,
|
||||||
("tokens_raw", tokens_raw::render),
|
},
|
||||||
("cache_efficiency", cache_efficiency::render),
|
SectionDescriptor {
|
||||||
("cost", cost::render),
|
id: "provider",
|
||||||
("cost_velocity", cost_velocity::render),
|
render: provider::render,
|
||||||
("token_velocity", token_velocity::render),
|
priority: 2,
|
||||||
("cost_trend", cost_trend::render),
|
is_spacer: false,
|
||||||
("context_trend", context_trend::render),
|
is_flex: false,
|
||||||
("lines_changed", lines_changed::render),
|
estimated_width: 10,
|
||||||
("duration", duration::render),
|
shell_out: false,
|
||||||
("tools", tools::render),
|
},
|
||||||
("turns", turns::render),
|
SectionDescriptor {
|
||||||
("load", load::render),
|
id: "project",
|
||||||
("version", version::render),
|
render: project::render,
|
||||||
("time", time::render),
|
priority: 1,
|
||||||
("output_style", output_style::render),
|
is_spacer: false,
|
||||||
("hostname", hostname::render),
|
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() {
|
for desc in registry() {
|
||||||
if name == id {
|
if desc.id == id {
|
||||||
return render_fn(ctx);
|
return (desc.render)(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
let raw = style_name.to_string();
|
let raw = style_name.to_string();
|
||||||
|
|
||||||
let ansi = if ctx.color_enabled {
|
let ansi = if ctx.color_enabled {
|
||||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
format!("{}{raw}{}", color::MAGENTA, color::RESET)
|
||||||
} else {
|
} else {
|
||||||
raw.clone()
|
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;
|
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 raw = format!("{} tok/min", format::human_tokens(velocity as u64));
|
||||||
|
|
||||||
let ansi = if ctx.color_enabled {
|
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 input_tok = cw.total_input_tokens.unwrap_or(0);
|
||||||
let output_tok = cw.total_output_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
|
let raw = ctx
|
||||||
.config
|
.config
|
||||||
.sections
|
.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::color;
|
||||||
|
use crate::format;
|
||||||
use crate::section::{RenderContext, SectionOutput};
|
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> {
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||||
if !ctx.config.sections.tools.base.enabled {
|
if !ctx.config.sections.tools.base.enabled {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cost = ctx.input.cost.as_ref()?;
|
let cfg = &ctx.config.sections.tools;
|
||||||
let count = cost.total_tool_uses.unwrap_or(0);
|
|
||||||
|
// Prefer cost.total_tool_uses (forward compat if Claude Code adds it),
|
||||||
|
// fall back to transcript-derived stats.
|
||||||
|
let (count, last_name, tool_counts) = if let Some(cost) = ctx.input.cost.as_ref() {
|
||||||
|
if let Some(n) = cost.total_tool_uses {
|
||||||
|
(n, cost.last_tool_name.clone(), Vec::new())
|
||||||
|
} else if let Some(ts) = &ctx.transcript_stats {
|
||||||
|
(
|
||||||
|
ts.total_tool_uses,
|
||||||
|
ts.last_tool_name.clone(),
|
||||||
|
ts.tool_counts.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(0, None, Vec::new())
|
||||||
|
}
|
||||||
|
} else if let Some(ts) = &ctx.transcript_stats {
|
||||||
|
(
|
||||||
|
ts.total_tool_uses,
|
||||||
|
ts.last_tool_name.clone(),
|
||||||
|
ts.tool_counts.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = if count == 1 { "tool" } else { "tools" };
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return None;
|
let raw = "0 tools".to_string();
|
||||||
|
let ansi = if ctx.color_enabled {
|
||||||
|
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||||
|
} else {
|
||||||
|
raw.clone()
|
||||||
|
};
|
||||||
|
return Some(SectionOutput { raw, ansi });
|
||||||
}
|
}
|
||||||
|
|
||||||
let last = if ctx.config.sections.tools.show_last_name {
|
// Progressive disclosure based on terminal width:
|
||||||
cost.last_tool_name
|
// 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()
|
.as_deref()
|
||||||
.map(|n| format!(" ({n})"))
|
.map(|n| format!(" ({n})"))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
let raw = format!("{count} {label}{detail}");
|
||||||
let raw = format!("{count} tools{last}");
|
|
||||||
let ansi = if ctx.color_enabled {
|
let ansi = if ctx.color_enabled {
|
||||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||||
} else {
|
} else {
|
||||||
raw.clone()
|
raw.clone()
|
||||||
};
|
};
|
||||||
|
Some(SectionOutput { raw, ansi })
|
||||||
|
}
|
||||||
|
WidthTier::Wide => {
|
||||||
|
if cfg.show_breakdown && !tool_counts.is_empty() {
|
||||||
|
render_breakdown(ctx, count, label, &tool_counts, cfg)
|
||||||
|
} else {
|
||||||
|
let detail = if cfg.show_last_name {
|
||||||
|
last_name
|
||||||
|
.as_deref()
|
||||||
|
.map(|n| format!(" ({n})"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let raw = format!("{count} {label}{detail}");
|
||||||
|
let ansi = if ctx.color_enabled {
|
||||||
|
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||||
|
} else {
|
||||||
|
raw.clone()
|
||||||
|
};
|
||||||
|
Some(SectionOutput { raw, ansi })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the full breakdown, adaptively reducing the number of tools shown
|
||||||
|
/// until it fits within a width budget (1/3 of terminal width).
|
||||||
|
fn render_breakdown(
|
||||||
|
ctx: &RenderContext,
|
||||||
|
count: u64,
|
||||||
|
label: &str,
|
||||||
|
tool_counts: &[(String, u64)],
|
||||||
|
cfg: &crate::config::ToolsSection,
|
||||||
|
) -> Option<SectionOutput> {
|
||||||
|
let max_limit = if cfg.top_n == 0 {
|
||||||
|
tool_counts.len()
|
||||||
|
} else {
|
||||||
|
cfg.top_n.min(tool_counts.len())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Width budget: tools section shouldn't dominate the line
|
||||||
|
let budget = (ctx.term_width as usize) / 3;
|
||||||
|
|
||||||
|
// Try from max_limit down to 1, find the largest that fits the budget
|
||||||
|
let mut limit = max_limit;
|
||||||
|
loop {
|
||||||
|
let raw = build_raw(count, label, tool_counts, limit);
|
||||||
|
let width = format::display_width(&raw);
|
||||||
|
if width <= budget || limit <= 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
limit -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = build_raw(count, label, tool_counts, limit);
|
||||||
|
|
||||||
|
let ansi = if ctx.color_enabled {
|
||||||
|
let palette = resolve_palette(cfg, &ctx.terminal_palette);
|
||||||
|
build_ansi(count, label, tool_counts, limit, &palette)
|
||||||
|
} else {
|
||||||
|
raw.clone()
|
||||||
|
};
|
||||||
|
|
||||||
Some(SectionOutput { raw, ansi })
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = ctx.input.cost.as_ref()?.total_turns?;
|
// Prefer cost.total_turns (forward compat if Claude Code adds it),
|
||||||
if count == 0 {
|
// 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;
|
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 label = if count == 1 { "turn" } else { "turns" };
|
||||||
let raw = format!("{count} {label}");
|
let raw = format!("{count} {label}");
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use crate::glyph;
|
|||||||
use crate::section::{RenderContext, SectionOutput, VcsType};
|
use crate::section::{RenderContext, SectionOutput, VcsType};
|
||||||
use crate::shell::{self, GitStatusV2};
|
use crate::shell::{self, GitStatusV2};
|
||||||
use crate::width::WidthTier;
|
use crate::width::WidthTier;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||||
if !ctx.config.sections.vcs.base.enabled {
|
if !ctx.config.sections.vcs.base.enabled {
|
||||||
@@ -13,6 +12,11 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|||||||
return None;
|
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 dir = ctx.project_dir.to_str()?;
|
||||||
let ttl = &ctx.config.sections.vcs.ttl;
|
let ttl = &ctx.config.sections.vcs.ttl;
|
||||||
let glyphs = &ctx.config.glyphs;
|
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(
|
fn render_git(
|
||||||
ctx: &RenderContext,
|
ctx: &RenderContext,
|
||||||
dir: &str,
|
dir: &str,
|
||||||
ttl: &crate::config::VcsTtl,
|
ttl: &crate::config::VcsTtl,
|
||||||
glyphs: &crate::config::GlyphConfig,
|
glyphs: &crate::config::GlyphConfig,
|
||||||
) -> Option<SectionOutput> {
|
) -> Option<SectionOutput> {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
let branch_ttl = Duration::from_secs(ttl.branch);
|
let branch_ttl = Duration::from_secs(ttl.branch);
|
||||||
let dirty_ttl = Duration::from_secs(ttl.dirty);
|
let dirty_ttl = Duration::from_secs(ttl.dirty);
|
||||||
let ab_ttl = Duration::from_secs(ttl.ahead_behind);
|
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 branch_cached = ctx.cache.get("vcs_branch", branch_ttl);
|
||||||
let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl);
|
let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl);
|
||||||
let ab_cached = ctx.cache.get("vcs_ab", ab_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 status = if branch_cached.is_none() || dirty_cached.is_none() || ab_cached.is_none() {
|
||||||
let output = shell::exec_with_timeout(
|
// Use prefetched result if available, otherwise exec
|
||||||
|
let output = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
|
||||||
|
shell::exec_gated(
|
||||||
|
ctx.shell_config,
|
||||||
"git",
|
"git",
|
||||||
&["-C", dir, "status", "--porcelain=v2", "--branch"],
|
&["-C", dir, "status", "--porcelain=v2", "--branch"],
|
||||||
None,
|
None,
|
||||||
timeout,
|
)
|
||||||
);
|
});
|
||||||
match output {
|
match output {
|
||||||
Some(ref out) => {
|
Some(ref out) => {
|
||||||
let s = shell::parse_git_status_v2(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 branch_glyph = glyph::glyph("branch", glyphs);
|
||||||
let dirty_glyph = if status.is_dirty && ctx.config.sections.vcs.show_dirty {
|
let dirty_glyph = if status.is_dirty && ctx.config.sections.vcs.show_dirty {
|
||||||
glyph::glyph("dirty", glyphs)
|
glyph::glyph("dirty", glyphs)
|
||||||
@@ -128,15 +161,19 @@ fn render_git(
|
|||||||
|
|
||||||
fn render_jj(
|
fn render_jj(
|
||||||
ctx: &RenderContext,
|
ctx: &RenderContext,
|
||||||
_dir: &str,
|
dir: &str,
|
||||||
ttl: &crate::config::VcsTtl,
|
ttl: &crate::config::VcsTtl,
|
||||||
glyphs: &crate::config::GlyphConfig,
|
glyphs: &crate::config::GlyphConfig,
|
||||||
) -> Option<SectionOutput> {
|
) -> Option<SectionOutput> {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
let branch_ttl = Duration::from_secs(ttl.branch);
|
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 branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| {
|
||||||
let out = shell::exec_with_timeout(
|
// Use prefetched result if available, otherwise exec
|
||||||
|
let out = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
|
||||||
|
shell::exec_gated(
|
||||||
|
ctx.shell_config,
|
||||||
"jj",
|
"jj",
|
||||||
&[
|
&[
|
||||||
"log",
|
"log",
|
||||||
@@ -147,13 +184,20 @@ fn render_jj(
|
|||||||
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
|
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
|
||||||
"--color=never",
|
"--color=never",
|
||||||
],
|
],
|
||||||
None,
|
Some(dir),
|
||||||
timeout,
|
)
|
||||||
)?;
|
})?;
|
||||||
ctx.cache.set("vcs_branch", &out);
|
ctx.cache.set("vcs_branch", &out);
|
||||||
Some(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 branch_glyph = glyph::glyph("branch", glyphs);
|
||||||
let raw = format!("{branch_glyph}{branch}");
|
let raw = format!("{branch_glyph}{branch}");
|
||||||
let ansi = if ctx.color_enabled {
|
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::process::{Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -8,6 +9,166 @@ const GIT_ENV: &[(&str, &str)] = &[
|
|||||||
("LC_ALL", "C"),
|
("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.
|
/// Execute a command with a polling timeout. Returns None on timeout or error.
|
||||||
pub fn exec_with_timeout(
|
pub fn exec_with_timeout(
|
||||||
program: &str,
|
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)
|
||||||
|
}
|
||||||
175
src/trend.rs
175
src/trend.rs
@@ -1,10 +1,19 @@
|
|||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
|
use crate::color;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const SPARKLINE_CHARS: &[char] = &[
|
const SPARKLINE_CHARS: &[char] = &[
|
||||||
'\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}',
|
'\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`.
|
/// Append a value to a trend file. Throttled to at most once per `interval`.
|
||||||
/// Returns the full comma-separated series (for immediate sparkline rendering).
|
/// Returns the full comma-separated series (for immediate sparkline rendering).
|
||||||
pub fn append(
|
pub fn append(
|
||||||
@@ -57,7 +66,62 @@ pub fn append(
|
|||||||
Some(csv)
|
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.
|
/// 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 {
|
pub fn sparkline(csv: &str, width: usize) -> String {
|
||||||
let vals: Vec<i64> = csv
|
let vals: Vec<i64> = csv
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -65,23 +129,116 @@ pub fn sparkline(csv: &str, width: usize) -> String {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if vals.is_empty() {
|
if vals.is_empty() {
|
||||||
return String::new();
|
return BASELINE_CHAR.repeat(width);
|
||||||
}
|
}
|
||||||
|
|
||||||
let min = *vals.iter().min().unwrap();
|
let min = *vals.iter().min().unwrap();
|
||||||
let max = *vals.iter().max().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 {
|
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;
|
let range = (max - min) as f64;
|
||||||
vals.iter()
|
let mut result = String::with_capacity(width * 3);
|
||||||
.take(width)
|
|
||||||
.map(|&v| {
|
// 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;
|
let idx = (((v - min) as f64 / range) * 7.0) as usize;
|
||||||
SPARKLINE_CHARS[idx.min(7)]
|
result.push(SPARKLINE_CHARS[idx.min(7)]);
|
||||||
})
|
}
|
||||||
.collect()
|
|
||||||
|
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.
|
/// 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
|
// Check memo first
|
||||||
if let Ok(guard) = CACHED_WIDTH.lock() {
|
if let Ok(guard) = CACHED_WIDTH.lock() {
|
||||||
if let Some((w, ts)) = *guard {
|
if let Some((w, ts)) = *guard {
|
||||||
if ts.elapsed() < WIDTH_TTL {
|
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);
|
let effective = raw.saturating_sub(config_margin).max(40);
|
||||||
|
|
||||||
// Store in memo
|
// 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()));
|
*guard = Some((effective, Instant::now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
effective
|
(effective, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
|
fn detect_raw_with_source(
|
||||||
// 1. --width CLI flag
|
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 let Some(w) = cli_width {
|
||||||
if w > 0 {
|
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(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") {
|
||||||
if let Ok(w) = val.parse::<u16>() {
|
if let Ok(w) = val.parse::<u16>() {
|
||||||
if w > 0 {
|
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 let Some(w) = config_width {
|
||||||
if w > 0 {
|
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
|
// 4. ioctl(TIOCGWINSZ) on stdout
|
||||||
if let Some(w) = ioctl_width(libc::STDOUT_FILENO) {
|
if let Some(w) = ioctl_width(libc::STDOUT_FILENO) {
|
||||||
if w > 0 {
|
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 let Some(w) = process_tree_width() {
|
||||||
if w > 0 {
|
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 let Some(w) = stty_dev_tty() {
|
||||||
if w > 0 {
|
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(val) = std::env::var("COLUMNS") {
|
||||||
if let Ok(w) = val.parse::<u16>() {
|
if let Ok(w) = val.parse::<u16>() {
|
||||||
if w > 0 {
|
if w > 0 {
|
||||||
return w;
|
return Some((w, "columns_env"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. tput cols
|
// 9. tput cols
|
||||||
if let Some(w) = tput_cols() {
|
if let Some(w) = tput_cols() {
|
||||||
if w > 0 {
|
if w > 0 {
|
||||||
return w;
|
return Some((w, "tput"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Fallback
|
None
|
||||||
120
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
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