Port the entire 2236-line bash statusline script to Rust. Implements all 25 sections, 3-phase layout engine (render, priority drop, flex/justify), file-based caching with flock, 9-level terminal width detection, trend sparklines, and deep-merge JSON config. Release binary: 864K with LTO. Render time: <1ms warm. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2246 lines
69 KiB
Bash
Executable File
2246 lines
69 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Simple shell-style status line for Claude Code
|
|
set -euo pipefail
|
|
|
|
# Dependencies check
|
|
if ! command -v jq &>/dev/null; then
|
|
printf "statusline: jq not found\n" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Read JSON from stdin
|
|
INPUT=$(cat)
|
|
|
|
# ANSI Colors (dimmed for status line)
|
|
C_RESET='\033[0m'
|
|
C_DIM='\033[2m'
|
|
C_GREEN='\033[32m'
|
|
C_CYAN='\033[36m'
|
|
C_YELLOW='\033[33m'
|
|
|
|
# Get current directory
|
|
CWD=$(echo "$INPUT" | jq -r '.workspace.current_dir // .workspace.project_dir // empty')
|
|
if [ -z "$CWD" ]; then
|
|
CWD=$(pwd)
|
|
fi
|
|
|
|
# Shorten path: replace home with ~
|
|
CWD_DISPLAY="${CWD/#$HOME/\~}"
|
|
|
|
# Get username and hostname
|
|
USER=$(whoami)
|
|
HOST=$(hostname -s)
|
|
|
|
# Detect VCS (prefer jj over git)
|
|
VCS_BRANCH=""
|
|
VCS_DIRTY=""
|
|
if [ -d "$CWD/.jj" ]; then
|
|
# Jujutsu
|
|
VCS_BRANCH=$(cd "$CWD" && jj log -r @ --no-graph -T 'if(bookmarks, bookmarks.join(","), change_id.shortest(8))' --color=never 2>/dev/null || echo "")
|
|
if [ -n "$VCS_BRANCH" ]; then
|
|
VCS_DIRTY=$(cd "$CWD" && jj diff --stat --color=never 2>/dev/null | tail -1 || echo "")
|
|
if [ -n "$VCS_DIRTY" ] && [[ "$VCS_DIRTY" != *"0 files changed"* ]]; then
|
|
VCS_DIRTY="*"
|
|
else
|
|
VCS_DIRTY=""
|
|
fi
|
|
fi
|
|
elif [ -d "$CWD/.git" ]; then
|
|
# Git
|
|
VCS_BRANCH=$(cd "$CWD" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
if [ -n "$VCS_BRANCH" ]; then
|
|
VCS_STATUS=$(cd "$CWD" && git status --porcelain --untracked-files=no 2>/dev/null || echo "")
|
|
if [ -n "$VCS_STATUS" ]; then
|
|
VCS_DIRTY="*"
|
|
else
|
|
VCS_DIRTY=""
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Build prompt
|
|
OUTPUT=""
|
|
|
|
# user@host
|
|
OUTPUT+="${C_GREEN}${USER}@${HOST}${C_RESET}"
|
|
|
|
# :path
|
|
OUTPUT+="${C_DIM}:${C_RESET}"
|
|
OUTPUT+="${C_CYAN}${CWD_DISPLAY}${C_RESET}"
|
|
|
|
# (branch*)
|
|
if [ -n "$VCS_BRANCH" ]; then
|
|
OUTPUT+=" ${C_DIM}(${C_RESET}"
|
|
OUTPUT+="${C_GREEN}${VCS_BRANCH}${C_RESET}"
|
|
if [ -n "$VCS_DIRTY" ]; then
|
|
OUTPUT+="${C_YELLOW}${VCS_DIRTY}${C_RESET}"
|
|
fi
|
|
OUTPUT+="${C_DIM})${C_RESET}"
|
|
fi
|
|
|
|
printf '%b' "$OUTPUT"
|
|
# Mock data for testing config without Claude Code
|
|
INPUT_JSON='{
|
|
"model": {"id": "claude-opus-4-6-20260101", "display_name": "Opus 4.6"},
|
|
"cost": {
|
|
"total_cost_usd": 0.42,
|
|
"total_duration_ms": 840000,
|
|
"total_lines_added": 156,
|
|
"total_lines_removed": 23,
|
|
"total_tool_uses": 7,
|
|
"last_tool_name": "Edit",
|
|
"total_turns": 12
|
|
},
|
|
"context_window": {
|
|
"used_percentage": 58.5,
|
|
"total_input_tokens": 115000,
|
|
"total_output_tokens": 8500,
|
|
"context_window_size": 200000,
|
|
"current_usage": {
|
|
"cache_read_input_tokens": 75000,
|
|
"cache_creation_input_tokens": 15000
|
|
}
|
|
},
|
|
"workspace": {"project_dir": "'"$(pwd)"'"},
|
|
"version": "1.0.80",
|
|
"output_style": {"name": "learning"}
|
|
}'
|
|
elif [[ "$CLI_MODE" == "dump-state" ]]; then
|
|
# Dump-state mode uses minimal mock data to compute state
|
|
INPUT_JSON='{"workspace":{"project_dir":"'"$(pwd)"'"}}'
|
|
else
|
|
INPUT_JSON="$(cat)"
|
|
if [[ -z "$INPUT_JSON" ]]; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
# ── ANSI Colors ───────────────────────────────────────────────────────────────
|
|
|
|
C_RESET='\033[0m'
|
|
C_BOLD='\033[1m'
|
|
C_DIM='\033[2m'
|
|
C_RED='\033[31m'
|
|
C_GREEN='\033[32m'
|
|
C_YELLOW='\033[33m'
|
|
C_BLUE='\033[34m'
|
|
C_MAGENTA='\033[35m'
|
|
C_CYAN='\033[36m'
|
|
C_WHITE='\033[37m'
|
|
|
|
# ── Config Loading ────────────────────────────────────────────────────────────
|
|
|
|
# SCRIPT_DIR already resolved above
|
|
DEFAULTS_PATH="$SCRIPT_DIR/defaults.json"
|
|
|
|
if [[ -f "$DEFAULTS_PATH" ]]; then
|
|
DEFAULTS_JSON="$(cat "$DEFAULTS_PATH")"
|
|
else
|
|
# Minimal fallback if defaults.json is missing
|
|
DEFAULTS_JSON='{"global":{},"presets":{"standard":[["model","project"]]},"layout":"standard","sections":{}}'
|
|
fi
|
|
|
|
# User config contains only overrides — merged on top of defaults
|
|
USER_CONFIG_PATH="${CLAUDE_STATUSLINE_CONFIG:-$HOME/.claude/statusline.json}"
|
|
if [[ -f "$USER_CONFIG_PATH" ]]; then
|
|
USER_CONFIG_JSON="$(cat "$USER_CONFIG_PATH")"
|
|
# Deep merge: defaults * user (user wins)
|
|
CONFIG_JSON="$(jq -s '.[0] * .[1]' <<< "$DEFAULTS_JSON"$'\n'"$USER_CONFIG_JSON")"
|
|
else
|
|
CONFIG_JSON="$DEFAULTS_JSON"
|
|
fi
|
|
|
|
# ── Config Preloading ─────────────────────────────────────────────────────────
|
|
# Extract all config values in a single jq call for performance.
|
|
# Each value is stored in a CFG_* variable. The cfg() function checks these
|
|
# preloaded values first, falling back to jq for non-preloaded paths.
|
|
#
|
|
# Variable naming: .global.separator -> CFG__global__separator
|
|
# (dots become double underscores to create valid bash variable names)
|
|
|
|
# Preload all config values in one jq call
|
|
eval "$(jq -r '
|
|
# Helper to output shell variable assignment
|
|
# Converts path like .global.separator to CFG__global__separator
|
|
def assign($name; $val):
|
|
"CFG_" + ($name | gsub("\\."; "__")) + "=" + ($val | @sh) + "\n";
|
|
|
|
# Global settings
|
|
assign(".global.separator"; .global.separator // " | ") +
|
|
assign(".global.justify"; .global.justify // "left") +
|
|
assign(".global.width"; (.global.width // "") | tostring) +
|
|
assign(".global.width_margin"; (.global.width_margin // 4) | tostring) +
|
|
assign(".global.responsive"; (.global.responsive // true) | tostring) +
|
|
assign(".global.theme"; .global.theme // "auto") +
|
|
assign(".global.vcs"; .global.vcs // "auto") +
|
|
assign(".global.cache_dir"; .global.cache_dir // "/tmp/claude-sl-{session_id}") +
|
|
assign(".global.breakpoints.narrow"; (.global.breakpoints.narrow // 60) | tostring) +
|
|
assign(".global.breakpoints.medium"; (.global.breakpoints.medium // 100) | tostring) +
|
|
|
|
# Glyphs
|
|
assign(".glyphs.enabled"; (.glyphs.enabled // false) | tostring) +
|
|
|
|
# Glyph set (Nerd Font icons)
|
|
(.glyphs.set // {} | to_entries | map(
|
|
assign(".glyphs.set." + .key; .value // "")
|
|
) | add // "") +
|
|
|
|
# Glyph fallbacks (ASCII)
|
|
(.glyphs.fallback // {} | to_entries | map(
|
|
assign(".glyphs.fallback." + .key; .value // "")
|
|
) | add // "") +
|
|
|
|
# Section enabled flags and priorities for all builtin sections
|
|
# Need to capture root context since we are inside a map
|
|
(. as $root | ["model", "provider", "project", "vcs", "beads", "context_bar", "context_usage", "tokens_raw",
|
|
"cache_efficiency", "cost", "cost_velocity", "token_velocity", "cost_trend", "context_trend",
|
|
"lines_changed", "duration", "tools", "turns", "load", "version", "time",
|
|
"output_style", "hostname"] | map(. as $section |
|
|
assign(".sections." + $section + ".enabled"; ($root.sections[$section].enabled // true) | tostring) +
|
|
assign(".sections." + $section + ".priority"; ($root.sections[$section].priority // 2) | tostring) +
|
|
assign(".sections." + $section + ".flex"; ($root.sections[$section].flex // false) | tostring)
|
|
) | add // "") +
|
|
|
|
# Section-specific settings
|
|
# VCS
|
|
assign(".sections.vcs.prefer"; .sections.vcs.prefer // "auto") +
|
|
assign(".sections.vcs.show_dirty"; (.sections.vcs.show_dirty // true) | tostring) +
|
|
assign(".sections.vcs.show_ahead_behind"; (.sections.vcs.show_ahead_behind // true) | tostring) +
|
|
assign(".sections.vcs.ttl.branch"; (.sections.vcs.ttl.branch // 3) | tostring) +
|
|
assign(".sections.vcs.ttl.dirty"; (.sections.vcs.ttl.dirty // 5) | tostring) +
|
|
assign(".sections.vcs.ttl.ahead_behind"; (.sections.vcs.ttl.ahead_behind // 30) | tostring) +
|
|
|
|
# Beads
|
|
assign(".sections.beads.ttl"; (.sections.beads.ttl // 30) | tostring) +
|
|
assign(".sections.beads.show_wip"; (.sections.beads.show_wip // true) | tostring) +
|
|
assign(".sections.beads.show_wip_count"; (.sections.beads.show_wip_count // true) | tostring) +
|
|
assign(".sections.beads.show_ready_count"; (.sections.beads.show_ready_count // true) | tostring) +
|
|
assign(".sections.beads.show_open_count"; (.sections.beads.show_open_count // true) | tostring) +
|
|
assign(".sections.beads.show_closed_count"; (.sections.beads.show_closed_count // true) | tostring) +
|
|
|
|
# Context bar
|
|
assign(".sections.context_bar.bar_width"; (.sections.context_bar.bar_width // 10) | tostring) +
|
|
assign(".sections.context_bar.thresholds.warn"; (.sections.context_bar.thresholds.warn // 50) | tostring) +
|
|
assign(".sections.context_bar.thresholds.danger"; (.sections.context_bar.thresholds.danger // 70) | tostring) +
|
|
assign(".sections.context_bar.thresholds.critical"; (.sections.context_bar.thresholds.critical // 85) | tostring) +
|
|
|
|
# Tokens raw
|
|
assign(".sections.tokens_raw.format"; .sections.tokens_raw.format // "{input}in/{output}out") +
|
|
|
|
# Cost
|
|
assign(".sections.cost.thresholds.warn"; (.sections.cost.thresholds.warn // 5.00) | tostring) +
|
|
assign(".sections.cost.thresholds.danger"; (.sections.cost.thresholds.danger // 8.00) | tostring) +
|
|
assign(".sections.cost.thresholds.critical"; (.sections.cost.thresholds.critical // 10.00) | tostring) +
|
|
|
|
# Cost trend
|
|
assign(".sections.cost_trend.width"; (.sections.cost_trend.width // 8) | tostring) +
|
|
|
|
# Context trend
|
|
assign(".sections.context_trend.width"; (.sections.context_trend.width // 8) | tostring) +
|
|
assign(".sections.context_trend.thresholds.warn"; (.sections.context_trend.thresholds.warn // 50) | tostring) +
|
|
assign(".sections.context_trend.thresholds.danger"; (.sections.context_trend.thresholds.danger // 70) | tostring) +
|
|
assign(".sections.context_trend.thresholds.critical"; (.sections.context_trend.thresholds.critical // 85) | tostring) +
|
|
|
|
# Tools
|
|
assign(".sections.tools.show_last_name"; (.sections.tools.show_last_name // true) | tostring) +
|
|
|
|
# Load
|
|
assign(".sections.load.ttl"; (.sections.load.ttl // 10) | tostring) +
|
|
|
|
# Time
|
|
assign(".sections.time.format"; .sections.time.format // "%H:%M") +
|
|
|
|
# Truncation settings for sections that support it
|
|
# Need to capture root context since we are inside a map
|
|
(. as $root | ["project", "vcs"] | map(. as $section |
|
|
assign(".sections." + $section + ".truncate.enabled"; ($root.sections[$section].truncate.enabled // false) | tostring) +
|
|
assign(".sections." + $section + ".truncate.max"; ($root.sections[$section].truncate.max // 0) | tostring) +
|
|
assign(".sections." + $section + ".truncate.style"; $root.sections[$section].truncate.style // "right")
|
|
) | add // "") +
|
|
|
|
# Themed color palettes (dark and light)
|
|
# Need to capture root context since we are inside nested maps
|
|
(. as $root | ["dark", "light"] | map(. as $theme |
|
|
["success", "warning", "danger", "critical", "muted", "accent", "highlight", "info"] | map(. as $color |
|
|
assign(".colors." + $theme + "." + $color; $root.colors[$theme][$color] // "")
|
|
) | add // ""
|
|
) | add // "") +
|
|
|
|
""
|
|
' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
|
|
# Helper: read config value with default (optimized with preloading)
|
|
# First checks preloaded CFG_* variables, falls back to jq for non-preloaded paths.
|
|
# Path conversion: .global.separator -> CFG__global__separator
|
|
cfg() {
|
|
local path="$1" default="${2:-}"
|
|
|
|
# Convert path to variable name: .global.separator -> CFG__global__separator
|
|
local var_name="CFG_${path//./__}"
|
|
|
|
# Check preloaded value first (fast path)
|
|
# Use eval for indirect variable access (bash 3.x compatible)
|
|
local val
|
|
eval "val=\"\${$var_name:-__CFG_UNSET__}\""
|
|
if [[ "$val" != "__CFG_UNSET__" ]]; then
|
|
if [[ -z "$val" || "$val" == "null" ]]; then
|
|
printf '%s' "$default"
|
|
else
|
|
printf '%s' "$val"
|
|
fi
|
|
return
|
|
fi
|
|
|
|
# Fallback to jq for non-preloaded paths (slow path)
|
|
val="$(jq -e "$path" <<< "$CONFIG_JSON" 2>/dev/null | jq -r 'tostring' 2>/dev/null)" || true
|
|
if [[ -z "$val" || "$val" == "null" ]]; then
|
|
printf '%s' "$default"
|
|
else
|
|
printf '%s' "$val"
|
|
fi
|
|
}
|
|
|
|
# Helper: read from stdin JSON
|
|
inp() {
|
|
local path="$1" default="${2:-}"
|
|
local val
|
|
val="$(jq -r "$path // empty" <<< "$INPUT_JSON" 2>/dev/null)" || true
|
|
if [[ -z "$val" ]]; then
|
|
printf '%s' "$default"
|
|
else
|
|
printf '%s' "$val"
|
|
fi
|
|
}
|
|
|
|
# Helper: check if section is enabled (uses preloaded values)
|
|
section_enabled() {
|
|
local section="$1"
|
|
local val
|
|
val="$(cfg ".sections.${section}.enabled" "true")"
|
|
[[ "$val" == "true" ]]
|
|
}
|
|
|
|
# Helper: get section priority (uses preloaded values)
|
|
section_priority() {
|
|
local section="$1"
|
|
cfg ".sections.${section}.priority" "2"
|
|
}
|
|
|
|
# ── Theme Detection ──────────────────────────────────────────────────────────
|
|
# Detects terminal background (light/dark) for color palette selection.
|
|
# Detection priority:
|
|
# 1. Config override (global.theme = "dark" or "light")
|
|
# 2. COLORFGBG env var (set by some terminals, format: "fg;bg")
|
|
# 3. Default to dark (most common for developers)
|
|
#
|
|
# Note: OSC 11 query is complex and unreliable without a TTY, so we use
|
|
# simpler heuristics. Users can always override via config.
|
|
|
|
_detect_theme_impl() {
|
|
# 1. Config override
|
|
local theme_cfg
|
|
theme_cfg="$(cfg '.global.theme' 'auto')"
|
|
if [[ "$theme_cfg" != "auto" ]]; then
|
|
echo "$theme_cfg"
|
|
return
|
|
fi
|
|
|
|
# 2. COLORFGBG env var (set by xterm, rxvt, some others)
|
|
# Format: "fg;bg" or "fg;bg;unused" — bg > 8 usually indicates light background
|
|
if [[ -n "${COLORFGBG:-}" ]]; then
|
|
local bg="${COLORFGBG##*;}"
|
|
# Standard ANSI colors 0-7 are dark, 8-15 are bright (light)
|
|
# Some terminals use 15 for white background
|
|
if [[ "$bg" =~ ^[0-9]+$ ]] && (( bg > 8 && bg < 16 )); then
|
|
echo "light"
|
|
return
|
|
fi
|
|
# bg values 0-8 or outside range -> dark
|
|
if [[ "$bg" =~ ^[0-9]+$ ]] && (( bg <= 8 )); then
|
|
echo "dark"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
# 3. Default to dark (most common for developers)
|
|
echo "dark"
|
|
}
|
|
|
|
# Initialize detected theme
|
|
DETECTED_THEME="$(_detect_theme_impl)"
|
|
|
|
# ── Color System ──────────────────────────────────────────────────────────────
|
|
|
|
color_by_name() {
|
|
local name="$1"
|
|
|
|
# Check for palette reference (p:colorname)
|
|
if [[ "$name" == p:* ]]; then
|
|
local palette_key="${name#p:}"
|
|
local resolved=""
|
|
|
|
# Try themed palette first: .colors.{theme}.{key}
|
|
if [[ -n "$DETECTED_THEME" ]]; then
|
|
resolved="$(cfg ".colors.${DETECTED_THEME}.${palette_key}" "")"
|
|
fi
|
|
|
|
# Fall back to flat palette: .colors.{key} (backwards compatibility)
|
|
if [[ -z "$resolved" ]]; then
|
|
resolved="$(cfg ".colors.${palette_key}" "")"
|
|
fi
|
|
|
|
if [[ -z "$resolved" ]]; then
|
|
printf '%s' "$C_RESET"
|
|
return
|
|
fi
|
|
name="$resolved"
|
|
fi
|
|
|
|
# Handle compound styles (e.g., "red bold")
|
|
local result=""
|
|
for part in $name; do
|
|
case "$part" in
|
|
red) result+="$C_RED" ;;
|
|
green) result+="$C_GREEN" ;;
|
|
yellow) result+="$C_YELLOW" ;;
|
|
blue) result+="$C_BLUE" ;;
|
|
magenta) result+="$C_MAGENTA" ;;
|
|
cyan) result+="$C_CYAN" ;;
|
|
white) result+="$C_WHITE" ;;
|
|
dim) result+="$C_DIM" ;;
|
|
bold) result+="$C_BOLD" ;;
|
|
*) ;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$result" ]]; then
|
|
printf '%s' "$C_RESET"
|
|
else
|
|
printf '%s' "$result"
|
|
fi
|
|
}
|
|
|
|
# ── Glyph System ─────────────────────────────────────────────────────────────
|
|
# Nerd Font glyphs with automatic ASCII fallback.
|
|
# When glyphs.enabled is true, uses fancy Unicode/Powerline symbols.
|
|
# When disabled (default), uses plain ASCII characters.
|
|
|
|
GLYPHS_ENABLED="$(cfg '.glyphs.enabled' 'false')"
|
|
|
|
# glyph NAME — returns the appropriate glyph or fallback
|
|
glyph() {
|
|
local name="$1"
|
|
if [[ "$GLYPHS_ENABLED" == "true" ]]; then
|
|
local val
|
|
val="$(cfg ".glyphs.set.${name}" "")"
|
|
if [[ -n "$val" ]]; then
|
|
printf '%s' "$val"
|
|
return
|
|
fi
|
|
fi
|
|
# Fallback (either glyphs disabled or specific glyph not in set)
|
|
cfg ".glyphs.fallback.${name}" ""
|
|
}
|
|
|
|
# ── Cache Infrastructure ──────────────────────────────────────────────────────
|
|
|
|
# Session ID needs to be stable across invocations for trends to accumulate.
|
|
# Use project directory hash so same project = same session cache.
|
|
_project_dir="$(inp '.workspace.project_dir' "$(pwd)")"
|
|
|
|
# Sanitize project directory: reject paths with shell metacharacters
|
|
# This prevents command injection via malicious workspace.project_dir
|
|
sanitize_path() {
|
|
local path="$1"
|
|
# Reject paths containing shell metacharacters that could enable injection
|
|
# Allow: alphanumeric, slash, dash, underscore, dot, space, tilde
|
|
if [[ "$path" =~ [^a-zA-Z0-9/_.\-\ ~] ]]; then
|
|
# Fall back to current directory if path contains suspicious chars
|
|
pwd
|
|
else
|
|
printf '%s' "$path"
|
|
fi
|
|
}
|
|
_project_dir="$(sanitize_path "$_project_dir")"
|
|
|
|
SESSION_ID="$(printf '%s' "$_project_dir" | md5 -q 2>/dev/null || printf '%s' "$_project_dir" | md5sum | cut -d' ' -f1)"
|
|
SESSION_ID="${SESSION_ID:0:12}" # Truncate to 12 chars
|
|
|
|
CACHE_DIR_TEMPLATE="$(cfg '.global.cache_dir' '/tmp/claude-sl-{session_id}')"
|
|
CACHE_DIR="${CACHE_DIR_TEMPLATE//\{session_id\}/$SESSION_ID}"
|
|
|
|
# Secure cache directory creation: prevent symlink attacks
|
|
if [[ ! -d "$CACHE_DIR" ]]; then
|
|
mkdir -p "$CACHE_DIR" 2>/dev/null || true
|
|
chmod 700 "$CACHE_DIR" 2>/dev/null || true
|
|
fi
|
|
# Verify we own the cache directory (defense against pre-created symlinks)
|
|
if [[ ! -d "$CACHE_DIR" ]] || [[ ! -O "$CACHE_DIR" ]]; then
|
|
# Disable caching if directory is suspicious
|
|
CACHE_DIR=""
|
|
fi
|
|
|
|
# get_mtime FILE — portable mtime retrieval
|
|
get_mtime() {
|
|
local file="$1"
|
|
case "$(uname -s)" in
|
|
Darwin) stat -f %m "$file" 2>/dev/null ;;
|
|
*) stat -c %Y "$file" 2>/dev/null ;;
|
|
esac || echo 0
|
|
}
|
|
|
|
# cached_value KEY TTL_SECONDS COMMAND [ARGS...]
|
|
# Returns cached value if fresh, otherwise runs command and caches result.
|
|
# SECURITY: Commands are executed directly (not via eval) to prevent injection.
|
|
cached_value() {
|
|
local key="$1" ttl="$2"
|
|
shift 2
|
|
|
|
# If caching is disabled, just run the command
|
|
if [[ -z "$CACHE_DIR" ]]; then
|
|
"$@" 2>/dev/null || true
|
|
return
|
|
fi
|
|
|
|
# Sanitize cache key to prevent path traversal
|
|
local safe_key="${key//[^a-zA-Z0-9_-]/_}"
|
|
local cache_file="$CACHE_DIR/$safe_key"
|
|
|
|
if [[ -f "$cache_file" ]]; then
|
|
local now mtime age
|
|
now="$(date +%s)"
|
|
mtime="$(get_mtime "$cache_file")"
|
|
age=$(( now - mtime ))
|
|
if (( age < ttl )); then
|
|
cat "$cache_file"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
local result
|
|
# Execute command directly without eval — prevents injection
|
|
result="$("$@" 2>/dev/null)" || true
|
|
printf '%s' "$result" > "$cache_file"
|
|
printf '%s' "$result"
|
|
}
|
|
|
|
# ── Terminal Width ────────────────────────────────────────────────────────────
|
|
# Claude Code runs the script without a TTY, so tput/COLUMNS often return wrong
|
|
# values. Detection priority:
|
|
# 1. Explicit config override (global.width)
|
|
# 2. CLAUDE_STATUSLINE_WIDTH env var
|
|
# 3. Walk process tree to find ancestor with a real TTY, then stty size on it
|
|
# 4. stty via /dev/tty (works on some systems)
|
|
# 5. COLUMNS env var
|
|
# 6. tput cols
|
|
# 7. Fallback: 120
|
|
|
|
detect_width() {
|
|
# 1. Config override
|
|
local cfg_width
|
|
cfg_width="$(cfg '.global.width' '')"
|
|
if [[ -n "$cfg_width" && "$cfg_width" != "0" ]]; then
|
|
echo "$cfg_width"
|
|
return
|
|
fi
|
|
|
|
# 2. Env var override
|
|
if [[ -n "${CLAUDE_STATUSLINE_WIDTH:-}" ]]; then
|
|
echo "$CLAUDE_STATUSLINE_WIDTH"
|
|
return
|
|
fi
|
|
|
|
# 3. Walk process tree to find ancestor's TTY
|
|
# The script runs without a TTY, but the shell that launched Claude Code has one.
|
|
local pid=$$
|
|
while [[ "$pid" -gt 1 ]]; do
|
|
local tty_name
|
|
tty_name="$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ')" || break
|
|
if [[ -n "$tty_name" && "$tty_name" != "??" && "$tty_name" != "-" && "$tty_name" != "" ]]; then
|
|
local w
|
|
w="$(stty size < "/dev/$tty_name" 2>/dev/null | awk '{print $2}')" || true
|
|
if [[ -n "$w" ]] && (( w > 0 )); then
|
|
echo "$w"
|
|
return
|
|
fi
|
|
fi
|
|
pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')" || break
|
|
done
|
|
|
|
# 4. stty via /dev/tty
|
|
local tty_width
|
|
tty_width="$(stty size < /dev/tty 2>/dev/null | awk '{print $2}')" || true
|
|
if [[ -n "$tty_width" ]] && (( tty_width > 0 )); then
|
|
echo "$tty_width"
|
|
return
|
|
fi
|
|
|
|
# 5. COLUMNS env var (often wrong in non-interactive shells)
|
|
if [[ -n "${COLUMNS:-}" ]] && (( ${COLUMNS:-0} > 0 )); then
|
|
echo "$COLUMNS"
|
|
return
|
|
fi
|
|
|
|
# 6. tput cols
|
|
local tput_width
|
|
tput_width="$(tput cols 2>/dev/null)" || true
|
|
if [[ -n "$tput_width" ]] && (( tput_width > 0 )); then
|
|
echo "$tput_width"
|
|
return
|
|
fi
|
|
|
|
# 7. Fallback
|
|
echo 120
|
|
}
|
|
|
|
# The detected PTY width may exceed the actual rendering area due to
|
|
# terminal multiplexer borders (Zellij/tmux) or Claude Code UI chrome.
|
|
# Configurable via global.width_margin (default: 4).
|
|
WIDTH_MARGIN="$(cfg '.global.width_margin' '4')"
|
|
TERM_WIDTH=$(( $(detect_width) - WIDTH_MARGIN ))
|
|
|
|
# ── Responsive Layout Selection ──────────────────────────────────────────────
|
|
# Auto-select layout preset based on terminal width when responsive mode is enabled.
|
|
# Breakpoints: narrow (<60) -> dense, medium (60-99) -> standard, wide (>=100) -> verbose
|
|
|
|
RESPONSIVE="$(cfg '.global.responsive' 'true')"
|
|
RESPONSIVE_LAYOUT=""
|
|
if [[ "$RESPONSIVE" == "true" ]]; then
|
|
NARROW_BP="$(cfg '.global.breakpoints.narrow' '60')"
|
|
MEDIUM_BP="$(cfg '.global.breakpoints.medium' '100')"
|
|
|
|
if (( TERM_WIDTH < NARROW_BP )); then
|
|
RESPONSIVE_LAYOUT="dense"
|
|
elif (( TERM_WIDTH < MEDIUM_BP )); then
|
|
RESPONSIVE_LAYOUT="standard"
|
|
else
|
|
RESPONSIVE_LAYOUT="verbose"
|
|
fi
|
|
fi
|
|
|
|
# ── Width Tiers for Progressive Disclosure ───────────────────────────────────
|
|
# Sections can show different levels of detail based on available width.
|
|
# Uses the same breakpoints as responsive layout selection.
|
|
|
|
if (( TERM_WIDTH < ${NARROW_BP:-60} )); then
|
|
WIDTH_TIER="narrow"
|
|
elif (( TERM_WIDTH < ${MEDIUM_BP:-100} )); then
|
|
WIDTH_TIER="medium"
|
|
else
|
|
WIDTH_TIER="wide"
|
|
fi
|
|
|
|
# ── Sparkline Helper ─────────────────────────────────────────────────────────
|
|
|
|
sparkline() {
|
|
local values="$1" # comma-separated: "10,20,30,25,40"
|
|
local width="${2:-8}"
|
|
local chars="▁▂▃▄▅▆▇█"
|
|
|
|
# Parse values into array
|
|
IFS=',' read -ra vals <<< "$values"
|
|
local count=${#vals[@]}
|
|
if (( count == 0 )); then
|
|
printf '%s' ""
|
|
return
|
|
fi
|
|
|
|
# Find min/max
|
|
local min=${vals[0]} max=${vals[0]}
|
|
local v
|
|
for v in "${vals[@]}"; do
|
|
(( v < min )) && min=$v
|
|
(( v > max )) && max=$v
|
|
done
|
|
|
|
# Handle flat line
|
|
if (( max == min )); then
|
|
local mid_char="${chars:4:1}"
|
|
local result=""
|
|
local i
|
|
for (( i = 0; i < count && i < width; i++ )); do
|
|
result+="$mid_char"
|
|
done
|
|
printf '%s' "$result"
|
|
return
|
|
fi
|
|
|
|
# Map values to sparkline characters
|
|
local result=""
|
|
local range=$((max - min))
|
|
local char_count=8
|
|
local i
|
|
for (( i = 0; i < count && i < width; i++ )); do
|
|
local v=${vals[$i]}
|
|
local idx=$(( (v - min) * (char_count - 1) / range ))
|
|
result+="${chars:$idx:1}"
|
|
done
|
|
|
|
printf '%s' "$result"
|
|
}
|
|
|
|
# ── Trend Tracking ───────────────────────────────────────────────────────────
|
|
|
|
# Track a value for trend sparkline
|
|
track_trend() {
|
|
local key="$1" value="$2" max_points="${3:-8}"
|
|
local trend_file="$CACHE_DIR/trend_${key}"
|
|
|
|
# Read existing values
|
|
local existing=""
|
|
[[ -f "$trend_file" ]] && existing="$(cat "$trend_file")"
|
|
|
|
# Append new value
|
|
if [[ -n "$existing" ]]; then
|
|
existing="${existing},${value}"
|
|
else
|
|
existing="$value"
|
|
fi
|
|
|
|
# Trim to max points
|
|
IFS=',' read -ra vals <<< "$existing"
|
|
if (( ${#vals[@]} > max_points )); then
|
|
vals=("${vals[@]: -$max_points}")
|
|
existing=$(IFS=','; echo "${vals[*]}")
|
|
fi
|
|
|
|
printf '%s' "$existing" > "$trend_file"
|
|
printf '%s' "$existing"
|
|
}
|
|
|
|
get_trend() {
|
|
local key="$1"
|
|
local trend_file="$CACHE_DIR/trend_${key}"
|
|
[[ -f "$trend_file" ]] && cat "$trend_file" || echo ""
|
|
}
|
|
|
|
# ── Human-Readable Helpers ────────────────────────────────────────────────────
|
|
|
|
human_tokens() {
|
|
local n="${1:-0}"
|
|
if (( n >= 1000000 )); then
|
|
printf '%.1fM' "$(echo "scale=1; $n / 1000000" | bc 2>/dev/null || echo "$n")"
|
|
elif (( n >= 1000 )); then
|
|
printf '%.1fk' "$(echo "scale=1; $n / 1000" | bc 2>/dev/null || echo "$n")"
|
|
else
|
|
printf '%d' "$n"
|
|
fi
|
|
}
|
|
|
|
human_duration() {
|
|
local ms="${1:-0}"
|
|
local secs=$(( ms / 1000 ))
|
|
if (( secs >= 3600 )); then
|
|
printf '%dh%dm' $(( secs / 3600 )) $(( (secs % 3600) / 60 ))
|
|
elif (( secs >= 60 )); then
|
|
printf '%dm' $(( secs / 60 ))
|
|
else
|
|
printf '%ds' "$secs"
|
|
fi
|
|
}
|
|
|
|
# ── Truncation Helpers ───────────────────────────────────────────────────────
|
|
|
|
truncate_right() {
|
|
local s="$1" max="$2" ellipsis="${3:-…}"
|
|
if (( ${#s} <= max )); then
|
|
printf '%s' "$s"
|
|
else
|
|
printf '%s%s' "${s:0:$((max - 1))}" "$ellipsis"
|
|
fi
|
|
}
|
|
|
|
truncate_middle() {
|
|
local s="$1" max="$2" ellipsis="${3:-…}"
|
|
if (( ${#s} <= max )); then
|
|
printf '%s' "$s"
|
|
else
|
|
local half=$(( (max - 1) / 2 ))
|
|
printf '%s%s%s' "${s:0:$half}" "$ellipsis" "${s: -$((max - 1 - half))}"
|
|
fi
|
|
}
|
|
|
|
truncate_left() {
|
|
local s="$1" max="$2" ellipsis="${3:-…}"
|
|
if (( ${#s} <= max )); then
|
|
printf '%s' "$s"
|
|
else
|
|
printf '%s%s' "$ellipsis" "${s: -$((max - 1))}"
|
|
fi
|
|
}
|
|
|
|
# Apply truncation to a section based on its config
|
|
# Modifies SEC_RAW and SEC_ANSI globals
|
|
apply_truncation() {
|
|
local id="$1"
|
|
local trunc_enabled trunc_max trunc_style
|
|
|
|
# Read truncation config (works for both builtin and custom sections)
|
|
if is_builtin "$id"; then
|
|
trunc_enabled="$(cfg ".sections.${id}.truncate.enabled" "false")"
|
|
trunc_max="$(cfg ".sections.${id}.truncate.max" "0")"
|
|
trunc_style="$(cfg ".sections.${id}.truncate.style" "right")"
|
|
else
|
|
local idx
|
|
idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
if [[ -n "$idx" ]]; then
|
|
trunc_enabled="$(jq -r ".custom[$idx].truncate.enabled // false" <<< "$CONFIG_JSON")"
|
|
trunc_max="$(jq -r ".custom[$idx].truncate.max // 0" <<< "$CONFIG_JSON")"
|
|
trunc_style="$(jq -r ".custom[$idx].truncate.style // \"right\"" <<< "$CONFIG_JSON")"
|
|
else
|
|
return
|
|
fi
|
|
fi
|
|
|
|
# Skip if truncation not enabled or max is 0
|
|
if [[ "$trunc_enabled" != "true" ]]; then
|
|
return
|
|
fi
|
|
if [[ "$trunc_max" == "0" || -z "$trunc_max" ]]; then
|
|
return
|
|
fi
|
|
|
|
# Only truncate if content exceeds max
|
|
if (( ${#SEC_RAW} > trunc_max )); then
|
|
case "$trunc_style" in
|
|
middle) SEC_RAW="$(truncate_middle "$SEC_RAW" "$trunc_max")" ;;
|
|
left) SEC_RAW="$(truncate_left "$SEC_RAW" "$trunc_max")" ;;
|
|
*) SEC_RAW="$(truncate_right "$SEC_RAW" "$trunc_max")" ;;
|
|
esac
|
|
# Regenerate ANSI version - apply dim styling to truncated content
|
|
SEC_ANSI="${C_DIM}${SEC_RAW}${C_RESET}"
|
|
fi
|
|
}
|
|
|
|
# ── VCS Detection ─────────────────────────────────────────────────────────────
|
|
|
|
# PROJECT_DIR is already sanitized via _project_dir above
|
|
PROJECT_DIR="$_project_dir"
|
|
VCS_PREFER="$(cfg '.sections.vcs.prefer' "$(cfg '.global.vcs' 'auto')")"
|
|
|
|
detect_vcs() {
|
|
local dir="${PROJECT_DIR:-.}"
|
|
case "$VCS_PREFER" in
|
|
jj)
|
|
if [[ -d "$dir/.jj" ]]; then echo "jj"; else echo "none"; fi
|
|
;;
|
|
git)
|
|
if [[ -d "$dir/.git" ]]; then echo "git"; else echo "none"; fi
|
|
;;
|
|
auto|*)
|
|
if [[ -d "$dir/.jj" ]]; then echo "jj"
|
|
elif [[ -d "$dir/.git" ]]; then echo "git"
|
|
else echo "none"
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
VCS_TYPE="$(detect_vcs)"
|
|
|
|
# ── VCS Helper Commands ───────────────────────────────────────────────────────
|
|
# Safe wrappers that accept sanitized paths and don't require eval.
|
|
|
|
_git_branch() {
|
|
local dir="$1"
|
|
git -C "$dir" rev-parse --abbrev-ref HEAD
|
|
}
|
|
|
|
_git_dirty() {
|
|
local dir="$1"
|
|
git -C "$dir" status --porcelain --untracked-files=no | head -1
|
|
}
|
|
|
|
_git_ahead_behind() {
|
|
local dir="$1"
|
|
git -C "$dir" rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo '0 0'
|
|
}
|
|
|
|
_jj_branch() {
|
|
jj log -r @ --no-graph -T 'if(bookmarks, bookmarks.join(","), change_id.shortest(8))' --color=never
|
|
}
|
|
|
|
_jj_dirty() {
|
|
jj diff --stat --color=never | tail -1
|
|
}
|
|
|
|
_system_load() {
|
|
if [[ "$(uname)" == "Darwin" ]]; then
|
|
sysctl -n vm.loadavg | awk '{print $2}'
|
|
else
|
|
awk '{print $1}' /proc/loadavg
|
|
fi
|
|
}
|
|
|
|
_beads_wip_id() {
|
|
br list --status=in_progress --json 2>/dev/null | jq -r 'if type == "array" then .[0].id // empty else empty end'
|
|
}
|
|
|
|
# Get all beads stats in one call (cached)
|
|
_beads_stats() {
|
|
br status --json 2>/dev/null | jq -r '.summary // empty'
|
|
}
|
|
|
|
# ── Section Renderers ─────────────────────────────────────────────────────────
|
|
# Each renderer sets two globals:
|
|
# SEC_RAW — plain text (for width calculation)
|
|
# SEC_ANSI — ANSI-colored text (for display)
|
|
|
|
render_model() {
|
|
local display_name model_id
|
|
display_name="$(inp '.model.display_name' '')"
|
|
model_id="$(inp '.model.id' '?')"
|
|
|
|
# Use model_id for parsing (display_name often equals model_id anyway)
|
|
local id_to_parse="$model_id"
|
|
|
|
# Convert to lowercase for matching (bash 3 compatible)
|
|
local id_lower
|
|
id_lower="$(printf '%s' "$id_to_parse" | tr '[:upper:]' '[:lower:]')"
|
|
|
|
# Extract base model name
|
|
local base_name=""
|
|
case "$id_lower" in
|
|
*opus*) base_name="Opus" ;;
|
|
*sonnet*) base_name="Sonnet" ;;
|
|
*haiku*) base_name="Haiku" ;;
|
|
*) base_name="${display_name:-$model_id}" ;;
|
|
esac
|
|
|
|
# Try to extract version number (e.g., "4-5" or "3-5" or "4-6" → "4.5" or "3.5" or "4.6")
|
|
local version=""
|
|
if [[ "$id_lower" =~ (opus|sonnet|haiku)-([0-9]+)-([0-9]+) ]]; then
|
|
version="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
|
|
elif [[ "$id_lower" =~ ([0-9]+)-([0-9]+)-(opus|sonnet|haiku) ]]; then
|
|
version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
|
fi
|
|
|
|
local name
|
|
if [[ -n "$version" ]]; then
|
|
name="${base_name} ${version}"
|
|
else
|
|
name="$base_name"
|
|
fi
|
|
|
|
SEC_RAW="[$name]"
|
|
SEC_ANSI="${C_BOLD}[${name}]${C_RESET}"
|
|
}
|
|
|
|
render_provider() {
|
|
local model_id
|
|
model_id="$(inp '.model.id' '')"
|
|
local provider=""
|
|
case "$model_id" in
|
|
us.anthropic.*|anthropic.*) provider="Bedrock" ;;
|
|
*@[0-9][0-9][0-9][0-9]*) provider="Vertex" ;;
|
|
claude-*) provider="Anthropic" ;;
|
|
*) provider="" ;;
|
|
esac
|
|
if [[ -z "$provider" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
SEC_RAW="$provider"
|
|
SEC_ANSI="${C_DIM}${provider}${C_RESET}"
|
|
}
|
|
|
|
render_project() {
|
|
local dir
|
|
dir="$(inp '.workspace.project_dir' '')"
|
|
local name
|
|
if [[ -n "$dir" ]]; then
|
|
name="$(basename "$dir")"
|
|
else
|
|
name="$(basename "$(pwd)")"
|
|
fi
|
|
SEC_RAW="$name"
|
|
SEC_ANSI="${C_CYAN}${name}${C_RESET}"
|
|
}
|
|
|
|
render_vcs() {
|
|
if [[ "$VCS_TYPE" == "none" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local dir="${PROJECT_DIR:-.}"
|
|
local branch="" dirty="" ahead="" behind=""
|
|
local show_dirty show_ahead_behind
|
|
show_dirty="$(cfg '.sections.vcs.show_dirty' 'true')"
|
|
show_ahead_behind="$(cfg '.sections.vcs.show_ahead_behind' 'true')"
|
|
|
|
if [[ "$VCS_TYPE" == "git" ]]; then
|
|
local branch_ttl dirty_ttl ab_ttl
|
|
branch_ttl="$(cfg '.sections.vcs.ttl.branch' '3')"
|
|
dirty_ttl="$(cfg '.sections.vcs.ttl.dirty' '5')"
|
|
ab_ttl="$(cfg '.sections.vcs.ttl.ahead_behind' '30')"
|
|
|
|
branch="$(cached_value "vcs_branch" "$branch_ttl" _git_branch "$dir")"
|
|
|
|
if [[ "$show_dirty" == "true" ]]; then
|
|
local status_out
|
|
status_out="$(cached_value "vcs_dirty" "$dirty_ttl" _git_dirty "$dir")"
|
|
[[ -n "$status_out" ]] && dirty="$(glyph dirty)"
|
|
fi
|
|
|
|
# Progressive disclosure: ahead/behind only for medium+ width
|
|
if [[ "$show_ahead_behind" == "true" && "$WIDTH_TIER" != "narrow" ]]; then
|
|
local ab_raw
|
|
ab_raw="$(cached_value "vcs_ab" "$ab_ttl" _git_ahead_behind "$dir")"
|
|
ahead="$(echo "$ab_raw" | awk '{print $1}')"
|
|
behind="$(echo "$ab_raw" | awk '{print $2}')"
|
|
fi
|
|
elif [[ "$VCS_TYPE" == "jj" ]]; then
|
|
local branch_ttl dirty_ttl
|
|
branch_ttl="$(cfg '.sections.vcs.ttl.branch' '3')"
|
|
dirty_ttl="$(cfg '.sections.vcs.ttl.dirty' '5')"
|
|
|
|
branch="$(cached_value "vcs_branch" "$branch_ttl" _jj_branch)"
|
|
|
|
if [[ "$show_dirty" == "true" ]]; then
|
|
local jj_diff
|
|
jj_diff="$(cached_value "vcs_dirty" "$dirty_ttl" _jj_dirty)"
|
|
[[ -n "$jj_diff" && "$jj_diff" != *"0 files changed"* && "$jj_diff" != "0"* ]] && dirty="$(glyph dirty)"
|
|
fi
|
|
# jj doesn't have a direct ahead/behind concept vs upstream
|
|
ahead="0"
|
|
behind="0"
|
|
fi
|
|
|
|
# Build output with glyphs
|
|
local branch_glyph
|
|
branch_glyph="$(glyph branch)"
|
|
local raw="${branch_glyph}${branch}${dirty}"
|
|
local ansi="${C_GREEN}${branch_glyph}${branch}${C_RESET}"
|
|
[[ -n "$dirty" ]] && ansi="${ansi}${C_YELLOW}${dirty}${C_RESET}"
|
|
|
|
# Progressive disclosure: show ahead/behind only for medium+ width
|
|
if [[ "$show_ahead_behind" == "true" && "$WIDTH_TIER" != "narrow" && ("${ahead:-0}" != "0" || "${behind:-0}" != "0") ]]; then
|
|
local ahead_glyph behind_glyph ab_str=""
|
|
ahead_glyph="$(glyph ahead)"
|
|
behind_glyph="$(glyph behind)"
|
|
[[ "${ahead:-0}" != "0" ]] && ab_str="${ahead_glyph}${ahead}"
|
|
[[ "${behind:-0}" != "0" ]] && ab_str="${ab_str}${behind_glyph}${behind}"
|
|
raw="${raw} ${ab_str}"
|
|
ansi="${ansi} ${C_DIM}${ab_str}${C_RESET}"
|
|
fi
|
|
|
|
SEC_RAW="$raw"
|
|
SEC_ANSI="$ansi"
|
|
}
|
|
|
|
render_beads() {
|
|
local ttl
|
|
ttl="$(cfg '.sections.beads.ttl' '30')"
|
|
local show_wip show_wip_count show_ready show_open show_closed
|
|
show_wip="$(cfg '.sections.beads.show_wip' 'true')"
|
|
show_wip_count="$(cfg '.sections.beads.show_wip_count' 'true')"
|
|
show_ready="$(cfg '.sections.beads.show_ready_count' 'true')"
|
|
show_open="$(cfg '.sections.beads.show_open_count' 'true')"
|
|
show_closed="$(cfg '.sections.beads.show_closed_count' 'true')"
|
|
|
|
if ! command -v br &>/dev/null; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local parts=()
|
|
local ansi_parts=()
|
|
|
|
# Get all stats in one cached call
|
|
local stats_json
|
|
stats_json="$(cached_value "beads_stats" "$ttl" _beads_stats)"
|
|
|
|
if [[ -z "$stats_json" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Current WIP bead ID (always show if enabled — most important context)
|
|
if [[ "$show_wip" == "true" ]]; then
|
|
local wip
|
|
wip="$(cached_value "beads_wip_id" "$ttl" _beads_wip_id)"
|
|
if [[ -n "$wip" ]]; then
|
|
parts+=("$wip wip")
|
|
ansi_parts+=("${C_YELLOW}${wip}${C_RESET} ${C_DIM}wip${C_RESET}")
|
|
fi
|
|
fi
|
|
|
|
# Ready count (high priority — what's available to work on)
|
|
if [[ "$show_ready" == "true" ]]; then
|
|
local ready_count
|
|
ready_count="$(jq -r '.ready_issues // 0' <<< "$stats_json")"
|
|
if [[ "${ready_count:-0}" -gt 0 ]]; then
|
|
parts+=("${ready_count} ready")
|
|
ansi_parts+=("${C_GREEN}${ready_count}${C_RESET} ${C_DIM}ready${C_RESET}")
|
|
fi
|
|
fi
|
|
|
|
# Open count (medium+ width — total backlog)
|
|
if [[ "$show_open" == "true" && "$WIDTH_TIER" != "narrow" ]]; then
|
|
local open_count
|
|
open_count="$(jq -r '.open_issues // 0' <<< "$stats_json")"
|
|
if [[ "${open_count:-0}" -gt 0 ]]; then
|
|
parts+=("${open_count} open")
|
|
ansi_parts+=("${C_BLUE}${open_count}${C_RESET} ${C_DIM}open${C_RESET}")
|
|
fi
|
|
fi
|
|
|
|
# In-progress count (medium+ width — shows workload)
|
|
if [[ "$show_wip_count" == "true" && "$WIDTH_TIER" != "narrow" ]]; then
|
|
local wip_count
|
|
wip_count="$(jq -r '.in_progress_issues // 0' <<< "$stats_json")"
|
|
if [[ "${wip_count:-0}" -gt 0 ]]; then
|
|
parts+=("${wip_count} wip")
|
|
ansi_parts+=("${C_YELLOW}${wip_count}${C_RESET} ${C_DIM}wip${C_RESET}")
|
|
fi
|
|
fi
|
|
|
|
# Closed count (wide only — shows progress/completion)
|
|
if [[ "$show_closed" == "true" && "$WIDTH_TIER" == "wide" ]]; then
|
|
local closed_count
|
|
closed_count="$(jq -r '.closed_issues // 0' <<< "$stats_json")"
|
|
if [[ "${closed_count:-0}" -gt 0 ]]; then
|
|
parts+=("${closed_count} done")
|
|
ansi_parts+=("${C_DIM}${closed_count} done${C_RESET}")
|
|
fi
|
|
fi
|
|
|
|
if [[ ${#parts[@]} -eq 0 ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local IFS=" | "
|
|
SEC_RAW="${parts[*]}"
|
|
SEC_ANSI="${ansi_parts[*]}"
|
|
}
|
|
|
|
render_context_bar() {
|
|
local pct
|
|
pct="$(inp '.context_window.used_percentage' '')"
|
|
if [[ -z "$pct" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Round to integer
|
|
local pct_int
|
|
pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)"
|
|
|
|
local bar_width
|
|
bar_width="$(cfg '.sections.context_bar.bar_width' '10')"
|
|
local filled=$(( pct_int * bar_width / 100 ))
|
|
local empty=$(( bar_width - filled ))
|
|
|
|
local bar=""
|
|
local i
|
|
for (( i = 0; i < filled; i++ )); do bar+="="; done
|
|
for (( i = 0; i < empty; i++ )); do bar+="-"; done
|
|
|
|
SEC_RAW="[${bar}] ${pct_int}%"
|
|
|
|
# Color by threshold
|
|
local warn danger critical
|
|
warn="$(cfg '.sections.context_bar.thresholds.warn' '50')"
|
|
danger="$(cfg '.sections.context_bar.thresholds.danger' '70')"
|
|
critical="$(cfg '.sections.context_bar.thresholds.critical' '85')"
|
|
|
|
local color="$C_GREEN"
|
|
if (( pct_int >= critical )); then
|
|
color="${C_RED}${C_BOLD}"
|
|
elif (( pct_int >= danger )); then
|
|
color="$C_RED"
|
|
elif (( pct_int >= warn )); then
|
|
color="$C_YELLOW"
|
|
fi
|
|
|
|
SEC_ANSI="${color}[${bar}] ${pct_int}%${C_RESET}"
|
|
}
|
|
|
|
render_context_usage() {
|
|
# Shows context usage as "154k / 200k" (current context used / total capacity)
|
|
# NOTE: total_input/output_tokens are CUMULATIVE session totals, not current context!
|
|
# We must calculate current usage from: used_percentage * context_window_size / 100
|
|
local pct context_size
|
|
pct="$(inp '.context_window.used_percentage' '')"
|
|
context_size="$(inp '.context_window.context_window_size' '')"
|
|
|
|
if [[ -z "$pct" || -z "$context_size" || "$context_size" == "0" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Calculate current used tokens from percentage
|
|
local used
|
|
used="$(awk "BEGIN{printf \"%.0f\", $pct * $context_size / 100}" 2>/dev/null || echo "0")"
|
|
|
|
if (( used == 0 )); then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local total="$context_size"
|
|
|
|
local used_h total_h
|
|
used_h="$(human_tokens "$used")"
|
|
total_h="$(human_tokens "$total")"
|
|
|
|
SEC_RAW="${used_h}/${total_h}"
|
|
|
|
# Color by percentage threshold
|
|
local pct_int=0
|
|
if [[ -n "$pct" ]]; then
|
|
pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)"
|
|
fi
|
|
|
|
local warn danger critical
|
|
warn="$(cfg '.sections.context_usage.thresholds.warn' '50')"
|
|
danger="$(cfg '.sections.context_usage.thresholds.danger' '70')"
|
|
critical="$(cfg '.sections.context_usage.thresholds.critical' '85')"
|
|
|
|
local color="$C_GREEN"
|
|
if (( pct_int >= critical )); then
|
|
color="${C_RED}${C_BOLD}"
|
|
elif (( pct_int >= danger )); then
|
|
color="$C_RED"
|
|
elif (( pct_int >= warn )); then
|
|
color="$C_YELLOW"
|
|
fi
|
|
|
|
SEC_ANSI="${color}${used_h}${C_RESET}${C_DIM}/${total_h}${C_RESET}"
|
|
}
|
|
|
|
render_tokens_raw() {
|
|
local input output
|
|
input="$(inp '.context_window.total_input_tokens' '0')"
|
|
output="$(inp '.context_window.total_output_tokens' '0')"
|
|
|
|
# Progressive disclosure: more detail at wider widths
|
|
# narrow: "115k/8k" (compact, no labels)
|
|
# medium: "115k in/8k out" (with labels)
|
|
# wide: "115.2k in / 8.5k out" (decimal precision, spaced)
|
|
local input_h output_h raw
|
|
|
|
case "$WIDTH_TIER" in
|
|
narrow)
|
|
# Compact format: integer k/M values, no labels
|
|
input_h="$(human_tokens "$input")"
|
|
output_h="$(human_tokens "$output")"
|
|
raw="${input_h}/${output_h}"
|
|
;;
|
|
medium)
|
|
# Standard format: k/M values with labels
|
|
input_h="$(human_tokens "$input")"
|
|
output_h="$(human_tokens "$output")"
|
|
local fmt
|
|
fmt="$(cfg '.sections.tokens_raw.format' '{input}in/{output}out')"
|
|
raw="${fmt//\{input\}/$input_h}"
|
|
raw="${raw//\{output\}/$output_h}"
|
|
;;
|
|
wide)
|
|
# Verbose format: more precision, spaced separators
|
|
# Use one decimal place for better precision
|
|
if (( input >= 1000000 )); then
|
|
input_h="$(printf '%.1fM' "$(echo "scale=2; $input / 1000000" | bc 2>/dev/null || echo "$input")")"
|
|
elif (( input >= 1000 )); then
|
|
input_h="$(printf '%.1fk' "$(echo "scale=2; $input / 1000" | bc 2>/dev/null || echo "$input")")"
|
|
else
|
|
input_h="$input"
|
|
fi
|
|
if (( output >= 1000000 )); then
|
|
output_h="$(printf '%.1fM' "$(echo "scale=2; $output / 1000000" | bc 2>/dev/null || echo "$output")")"
|
|
elif (( output >= 1000 )); then
|
|
output_h="$(printf '%.1fk' "$(echo "scale=2; $output / 1000" | bc 2>/dev/null || echo "$output")")"
|
|
else
|
|
output_h="$output"
|
|
fi
|
|
raw="${input_h} in / ${output_h} out"
|
|
;;
|
|
esac
|
|
|
|
SEC_RAW="$raw"
|
|
SEC_ANSI="${C_DIM}${raw}${C_RESET}"
|
|
}
|
|
|
|
render_cache_efficiency() {
|
|
local cache_read cache_creation
|
|
cache_read="$(inp '.context_window.current_usage.cache_read_input_tokens' '0')"
|
|
cache_creation="$(inp '.context_window.current_usage.cache_creation_input_tokens' '0')"
|
|
|
|
local total=$(( cache_read + cache_creation ))
|
|
if (( total == 0 )); then
|
|
SEC_RAW="cache:0%"
|
|
SEC_ANSI="${C_DIM}cache:0%${C_RESET}"
|
|
return
|
|
fi
|
|
|
|
local pct=$(( cache_read * 100 / total ))
|
|
SEC_RAW="cache:${pct}%"
|
|
|
|
local color="$C_DIM"
|
|
(( pct >= 50 )) && color="$C_GREEN"
|
|
(( pct >= 80 )) && color="${C_GREEN}${C_BOLD}"
|
|
|
|
SEC_ANSI="${color}cache:${pct}%${C_RESET}"
|
|
}
|
|
|
|
render_cost() {
|
|
local cost_raw
|
|
cost_raw="$(inp '.cost.total_cost_usd' '')"
|
|
if [[ -z "$cost_raw" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Progressive disclosure: decimal precision based on width tier
|
|
# narrow: "$0" (no decimals, rounds to nearest dollar)
|
|
# medium: "$0.25" (2 decimals, standard)
|
|
# wide: "$0.2547" (4 decimals, precise)
|
|
local decimals=2
|
|
case "$WIDTH_TIER" in
|
|
narrow) decimals=0 ;;
|
|
medium) decimals=2 ;;
|
|
wide) decimals=4 ;;
|
|
esac
|
|
|
|
local cost
|
|
cost="$(printf "%.${decimals}f" "$cost_raw" 2>/dev/null || echo "$cost_raw")"
|
|
|
|
SEC_RAW="\$${cost}"
|
|
|
|
local warn danger critical
|
|
warn="$(cfg '.sections.cost.thresholds.warn' '5.00')"
|
|
danger="$(cfg '.sections.cost.thresholds.danger' '8.00')"
|
|
critical="$(cfg '.sections.cost.thresholds.critical' '10.00')"
|
|
|
|
# Compare using awk (cost_raw retains full precision for comparison)
|
|
local color="$C_GREEN"
|
|
if awk "BEGIN{exit(!($cost_raw >= $critical))}"; then
|
|
color="${C_RED}${C_BOLD}"
|
|
elif awk "BEGIN{exit(!($cost_raw >= $danger))}"; then
|
|
color="$C_RED"
|
|
elif awk "BEGIN{exit(!($cost_raw >= $warn))}"; then
|
|
color="$C_YELLOW"
|
|
fi
|
|
|
|
SEC_ANSI="${color}\$${cost}${C_RESET}"
|
|
}
|
|
|
|
render_cost_velocity() {
|
|
local cost duration_ms
|
|
cost="$(inp '.cost.total_cost_usd' '0')"
|
|
duration_ms="$(inp '.cost.total_duration_ms' '0')"
|
|
|
|
if [[ "$duration_ms" == "0" || -z "$duration_ms" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local velocity
|
|
velocity="$(awk "BEGIN{printf \"%.2f\", $cost / ($duration_ms / 60000)}" 2>/dev/null || echo "0.00")"
|
|
SEC_RAW="\$${velocity}/m"
|
|
SEC_ANSI="${C_DIM}\$${velocity}/m${C_RESET}"
|
|
}
|
|
|
|
render_token_velocity() {
|
|
local input output duration_ms
|
|
input="$(inp '.context_window.total_input_tokens' '0')"
|
|
output="$(inp '.context_window.total_output_tokens' '0')"
|
|
duration_ms="$(inp '.cost.total_duration_ms' '0')"
|
|
|
|
if [[ "$duration_ms" == "0" || -z "$duration_ms" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local total=$(( input + output ))
|
|
local velocity
|
|
velocity="$(awk "BEGIN{printf \"%.1f\", $total / ($duration_ms / 60000)}" 2>/dev/null || echo "0.0")"
|
|
|
|
# Progressive disclosure: abbreviate based on width tier
|
|
local suffix="tok/m"
|
|
if [[ "$WIDTH_TIER" == "narrow" ]]; then
|
|
suffix="t/m"
|
|
fi
|
|
|
|
# Human-readable for large values
|
|
if awk "BEGIN{exit !($velocity >= 1000)}" 2>/dev/null; then
|
|
velocity="$(awk "BEGIN{printf \"%.1f\", $velocity / 1000}" 2>/dev/null)k"
|
|
fi
|
|
|
|
SEC_RAW="${velocity}${suffix}"
|
|
SEC_ANSI="${C_DIM}${velocity}${suffix}${C_RESET}"
|
|
}
|
|
|
|
render_lines_changed() {
|
|
local added removed
|
|
added="$(inp '.cost.total_lines_added' '0')"
|
|
removed="$(inp '.cost.total_lines_removed' '0')"
|
|
|
|
if [[ "$added" == "0" && "$removed" == "0" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
SEC_RAW="+${added} -${removed}"
|
|
SEC_ANSI="${C_GREEN}+${added}${C_RESET} ${C_RED}-${removed}${C_RESET}"
|
|
}
|
|
|
|
render_duration() {
|
|
local ms
|
|
ms="$(inp '.cost.total_duration_ms' '')"
|
|
if [[ -z "$ms" || "$ms" == "0" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local human
|
|
human="$(human_duration "$ms")"
|
|
SEC_RAW="$human"
|
|
SEC_ANSI="${C_DIM}${human}${C_RESET}"
|
|
}
|
|
|
|
render_tools() {
|
|
local tool_count
|
|
tool_count="$(inp '.cost.total_tool_uses' '0')"
|
|
|
|
if [[ "$tool_count" == "0" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Progressive disclosure: detail based on width tier
|
|
# narrow: "42" (just the count)
|
|
# medium: "42 tools" (count with label)
|
|
# wide: "42 tools (Read)" (count, label, and last tool name)
|
|
local raw ansi
|
|
|
|
case "$WIDTH_TIER" in
|
|
narrow)
|
|
raw="$tool_count"
|
|
ansi="${C_DIM}${tool_count}${C_RESET}"
|
|
;;
|
|
medium)
|
|
raw="${tool_count} tools"
|
|
ansi="${C_DIM}${tool_count} tools${C_RESET}"
|
|
;;
|
|
wide)
|
|
raw="${tool_count} tools"
|
|
ansi="${C_DIM}${tool_count} tools${C_RESET}"
|
|
|
|
local show_last_name
|
|
show_last_name="$(cfg '.sections.tools.show_last_name' 'true')"
|
|
if [[ "$show_last_name" == "true" ]]; then
|
|
local last_tool
|
|
last_tool="$(inp '.cost.last_tool_name' '')"
|
|
if [[ -n "$last_tool" ]]; then
|
|
# Shorten tool name if too long
|
|
if (( ${#last_tool} > 12 )); then
|
|
last_tool="${last_tool:0:11}~"
|
|
fi
|
|
raw="${raw} (${last_tool})"
|
|
ansi="${ansi} ${C_DIM}(${last_tool})${C_RESET}"
|
|
fi
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
SEC_RAW="$raw"
|
|
SEC_ANSI="$ansi"
|
|
}
|
|
|
|
render_turns() {
|
|
local turns
|
|
turns="$(inp '.cost.total_turns' '')"
|
|
if [[ -z "$turns" || "$turns" == "0" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
SEC_RAW="${turns} turns"
|
|
SEC_ANSI="${C_DIM}${turns} turns${C_RESET}"
|
|
}
|
|
|
|
render_load() {
|
|
local ttl
|
|
ttl="$(cfg '.sections.load.ttl' '10')"
|
|
local load_val
|
|
load_val="$(cached_value "load" "$ttl" _system_load)"
|
|
SEC_RAW="load:${load_val}"
|
|
SEC_ANSI="${C_DIM}load:${load_val}${C_RESET}"
|
|
}
|
|
|
|
render_version() {
|
|
local ver
|
|
ver="$(inp '.version' '')"
|
|
if [[ -z "$ver" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
SEC_RAW="v${ver}"
|
|
SEC_ANSI="${C_DIM}v${ver}${C_RESET}"
|
|
}
|
|
|
|
render_time() {
|
|
local fmt
|
|
fmt="$(cfg '.sections.time.format' '%H:%M')"
|
|
local t
|
|
t="$(date +"$fmt")"
|
|
SEC_RAW="$t"
|
|
SEC_ANSI="${C_DIM}${t}${C_RESET}"
|
|
}
|
|
|
|
render_output_style() {
|
|
local style
|
|
style="$(inp '.output_style.name' '')"
|
|
if [[ -z "$style" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
SEC_RAW="$style"
|
|
SEC_ANSI="${C_MAGENTA}${style}${C_RESET}"
|
|
}
|
|
|
|
render_hostname() {
|
|
local h
|
|
h="$(hostname -s 2>/dev/null || echo "?")"
|
|
SEC_RAW="$h"
|
|
SEC_ANSI="${C_DIM}${h}${C_RESET}"
|
|
}
|
|
|
|
render_cost_trend() {
|
|
local cost
|
|
cost="$(inp '.cost.total_cost_usd' '')"
|
|
if [[ -z "$cost" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Convert to cents for integer sparkline (avoids floating point issues)
|
|
local cents
|
|
cents="$(awk "BEGIN{printf \"%.0f\", $cost * 100}" 2>/dev/null || echo "0")"
|
|
|
|
# Get sparkline width from config
|
|
local width
|
|
width="$(cfg '.sections.cost_trend.width' '8')"
|
|
|
|
local trend
|
|
trend="$(track_trend "cost" "$cents" "$width")"
|
|
|
|
local spark
|
|
spark="$(sparkline "$trend" "$width")"
|
|
|
|
if [[ -z "$spark" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
SEC_RAW="$spark"
|
|
SEC_ANSI="${C_DIM}$spark${C_RESET}"
|
|
}
|
|
|
|
render_context_trend() {
|
|
local pct
|
|
pct="$(inp '.context_window.used_percentage' '')"
|
|
if [[ -z "$pct" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Round to integer
|
|
local pct_int
|
|
pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)"
|
|
|
|
# Get sparkline width from config
|
|
local width
|
|
width="$(cfg '.sections.context_trend.width' '8')"
|
|
|
|
local trend
|
|
trend="$(track_trend "context" "$pct_int" "$width")"
|
|
|
|
local spark
|
|
spark="$(sparkline "$trend" "$width")"
|
|
|
|
if [[ -z "$spark" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
# Color based on current percentage
|
|
local warn danger critical
|
|
warn="$(cfg '.sections.context_trend.thresholds.warn' '50')"
|
|
danger="$(cfg '.sections.context_trend.thresholds.danger' '70')"
|
|
critical="$(cfg '.sections.context_trend.thresholds.critical' '85')"
|
|
|
|
local color="$C_DIM"
|
|
if (( pct_int >= critical )); then
|
|
color="${C_RED}${C_BOLD}"
|
|
elif (( pct_int >= danger )); then
|
|
color="$C_RED"
|
|
elif (( pct_int >= warn )); then
|
|
color="$C_YELLOW"
|
|
fi
|
|
|
|
SEC_RAW="$spark"
|
|
SEC_ANSI="${color}$spark${C_RESET}"
|
|
}
|
|
|
|
# ── Custom Command Renderer ──────────────────────────────────────────────────
|
|
|
|
# Helper for custom commands — uses bash -c since commands are shell strings
|
|
# Custom commands come from user config (trusted source), not stdin
|
|
_run_custom_cmd() {
|
|
local cmd="$1"
|
|
bash -c "$cmd"
|
|
}
|
|
|
|
render_custom() {
|
|
local section_id="$1"
|
|
local idx
|
|
idx="$(jq -r --arg id "$section_id" '
|
|
.custom | to_entries[] | select(.value.id == $id) | .key
|
|
' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
|
|
if [[ -z "$idx" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
local cmd label ttl_val
|
|
cmd="$(jq -r ".custom[$idx].command" <<< "$CONFIG_JSON")"
|
|
label="$(jq -r ".custom[$idx].label // .custom[$idx].id" <<< "$CONFIG_JSON")"
|
|
ttl_val="$(jq -r ".custom[$idx].ttl // 30" <<< "$CONFIG_JSON")"
|
|
|
|
# Cache key sanitization is handled by cached_value()
|
|
local result
|
|
result="$(cached_value "custom_${section_id}" "$ttl_val" _run_custom_cmd "$cmd")"
|
|
|
|
if [[ -z "$result" ]]; then
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
return
|
|
fi
|
|
|
|
SEC_RAW="${label}:${result}"
|
|
|
|
# Color matching
|
|
local color_name
|
|
color_name="$(jq -r --arg val "$result" ".custom[$idx].color.match[\$val] // empty" <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
|
|
if [[ -n "$color_name" ]]; then
|
|
local c
|
|
c="$(color_by_name "$color_name")"
|
|
SEC_ANSI="${c}${label}:${result}${C_RESET}"
|
|
else
|
|
SEC_ANSI="${C_DIM}${label}:${result}${C_RESET}"
|
|
fi
|
|
}
|
|
|
|
# ── Section Dispatch ──────────────────────────────────────────────────────────
|
|
|
|
BUILTIN_SECTIONS="model provider project vcs beads context_bar context_usage tokens_raw cache_efficiency cost cost_velocity token_velocity cost_trend context_trend lines_changed duration tools turns load version time output_style hostname"
|
|
|
|
is_builtin() {
|
|
local id="$1"
|
|
[[ " $BUILTIN_SECTIONS " == *" $id "* ]]
|
|
}
|
|
|
|
is_spacer() {
|
|
local id="$1"
|
|
[[ "$id" == "spacer" || "$id" == _spacer* ]]
|
|
}
|
|
|
|
render_section() {
|
|
local id="$1"
|
|
SEC_RAW=""
|
|
SEC_ANSI=""
|
|
|
|
if is_spacer "$id"; then
|
|
SEC_RAW=" "
|
|
SEC_ANSI=" "
|
|
return
|
|
fi
|
|
|
|
if is_builtin "$id"; then
|
|
if ! section_enabled "$id"; then
|
|
return
|
|
fi
|
|
"render_${id}"
|
|
else
|
|
# Try custom command
|
|
render_custom "$id"
|
|
fi
|
|
|
|
# Apply truncation and formatting if section produced output
|
|
if [[ -n "$SEC_RAW" ]]; then
|
|
apply_truncation "$id"
|
|
apply_formatting "$id"
|
|
fi
|
|
}
|
|
|
|
# ── Per-Section Formatting ─────────────────────────────────────────────────
|
|
# Applies prefix, suffix, color override, and pad+align to any section.
|
|
# Uses a single jq call to batch-read all formatting properties.
|
|
|
|
apply_formatting() {
|
|
local id="$1"
|
|
|
|
# Single jq call using @sh to produce shell-safe variable assignments.
|
|
# Tab-split (IFS=$'\t' read) doesn't work: tab is whitespace in POSIX,
|
|
# so consecutive tabs collapse and leading empty fields are swallowed.
|
|
local prefix="" suffix="" pad="" align="" color_name=""
|
|
if is_builtin "$id"; then
|
|
eval "$(jq -r '
|
|
.sections["'"$id"'"] as $s |
|
|
"prefix=" + (@sh "\($s.prefix // "")") +
|
|
" suffix=" + (@sh "\($s.suffix // "")") +
|
|
" pad=" + (@sh "\(($s.pad // "") | tostring)") +
|
|
" align=" + (@sh "\($s.align // "")") +
|
|
" color_name=" + (@sh "\($s.color // "")")
|
|
' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
else
|
|
# Custom commands use default_color to avoid conflict with color.match
|
|
eval "$(jq -r --arg id "$id" '
|
|
(.custom | to_entries[] | select(.value.id == $id) | .value) as $sec |
|
|
"prefix=" + (@sh "\($sec.prefix // "")") +
|
|
" suffix=" + (@sh "\($sec.suffix // "")") +
|
|
" pad=" + (@sh "\(($sec.pad // "") | tostring)") +
|
|
" align=" + (@sh "\($sec.align // "")") +
|
|
" color_name=" + (@sh "\($sec.default_color // "")")
|
|
' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
fi
|
|
|
|
# If nothing to do, return early
|
|
if [[ -z "$prefix" && -z "$suffix" && -z "$pad" && -z "$color_name" ]]; then
|
|
return
|
|
fi
|
|
|
|
# 1. Apply prefix/suffix
|
|
if [[ -n "$prefix" ]]; then
|
|
SEC_RAW="${prefix}${SEC_RAW}"
|
|
SEC_ANSI="${prefix}${SEC_ANSI}"
|
|
fi
|
|
if [[ -n "$suffix" ]]; then
|
|
SEC_RAW="${SEC_RAW}${suffix}"
|
|
SEC_ANSI="${SEC_ANSI}${suffix}"
|
|
fi
|
|
|
|
# 2. Apply color override (re-wrap entire content)
|
|
if [[ -n "$color_name" ]]; then
|
|
local c
|
|
c="$(color_by_name "$color_name")"
|
|
SEC_ANSI="${c}${SEC_RAW}${C_RESET}"
|
|
fi
|
|
|
|
# 3. Apply pad + align (spaces stay uncolored)
|
|
if [[ -n "$pad" ]] && (( pad > ${#SEC_RAW} )); then
|
|
local pad_needed=$(( pad - ${#SEC_RAW} ))
|
|
local pad_str=""
|
|
local p
|
|
for (( p = 0; p < pad_needed; p++ )); do pad_str+=" "; done
|
|
|
|
case "${align:-left}" in
|
|
right)
|
|
SEC_RAW="${pad_str}${SEC_RAW}"
|
|
SEC_ANSI="${pad_str}${SEC_ANSI}"
|
|
;;
|
|
center)
|
|
local left_pad=$(( pad_needed / 2 ))
|
|
local right_pad=$(( pad_needed - left_pad ))
|
|
local lpad="" rpad=""
|
|
for (( p = 0; p < left_pad; p++ )); do lpad+=" "; done
|
|
for (( p = 0; p < right_pad; p++ )); do rpad+=" "; done
|
|
SEC_RAW="${lpad}${SEC_RAW}${rpad}"
|
|
SEC_ANSI="${lpad}${SEC_ANSI}${rpad}"
|
|
;;
|
|
*) # left (default)
|
|
SEC_RAW="${SEC_RAW}${pad_str}"
|
|
SEC_ANSI="${SEC_ANSI}${pad_str}"
|
|
;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
get_section_priority() {
|
|
local id="$1"
|
|
if is_spacer "$id"; then
|
|
echo "1"
|
|
elif is_builtin "$id"; then
|
|
section_priority "$id"
|
|
else
|
|
local idx
|
|
idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
if [[ -n "$idx" ]]; then
|
|
jq -r ".custom[$idx].priority // 2" <<< "$CONFIG_JSON"
|
|
else
|
|
echo "2"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
is_flex_section() {
|
|
local id="$1"
|
|
if is_spacer "$id"; then
|
|
return 0
|
|
elif is_builtin "$id"; then
|
|
local val
|
|
val="$(cfg ".sections.${id}.flex" "false")"
|
|
[[ "$val" == "true" ]]
|
|
else
|
|
local idx
|
|
idx="$(jq -r --arg id "$id" '.custom | to_entries[] | select(.value.id == $id) | .key' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
if [[ -n "$idx" ]]; then
|
|
local val
|
|
val="$(jq -r ".custom[$idx].flex // false" <<< "$CONFIG_JSON")"
|
|
[[ "$val" == "true" ]]
|
|
else
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# ── Layout Resolution ─────────────────────────────────────────────────────────
|
|
|
|
resolve_layout() {
|
|
local layout_val
|
|
layout_val="$(jq -r '.layout' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
|
|
if [[ -z "$layout_val" || "$layout_val" == "null" ]]; then
|
|
layout_val="standard"
|
|
fi
|
|
|
|
# Check if it's a string (preset name) or array
|
|
local layout_type
|
|
layout_type="$(jq -r '.layout | type' <<< "$CONFIG_JSON" 2>/dev/null)" || true
|
|
|
|
if [[ "$layout_type" == "string" ]]; then
|
|
# When responsive mode is enabled and layout is a preset name (not explicit array),
|
|
# use the responsive layout instead of the configured preset
|
|
local effective_layout="$layout_val"
|
|
if [[ -n "$RESPONSIVE_LAYOUT" ]]; then
|
|
effective_layout="$RESPONSIVE_LAYOUT"
|
|
fi
|
|
# Look up preset
|
|
jq -c ".presets[\"$effective_layout\"] // [[\"model\",\"project\"]]" <<< "$CONFIG_JSON"
|
|
else
|
|
# Explicit layout array provided — use it directly (ignore responsive)
|
|
jq -c '.layout' <<< "$CONFIG_JSON"
|
|
fi
|
|
}
|
|
|
|
# ── Layout Engine ─────────────────────────────────────────────────────────────
|
|
|
|
SEPARATOR="$(cfg '.global.separator' ' | ')"
|
|
SEP_LEN="${#SEPARATOR}"
|
|
JUSTIFY="$(cfg '.global.justify' 'left')"
|
|
|
|
# Extract the visible "core" of the separator (non-space chars, e.g. "|" from " | ")
|
|
# Used as the anchor when building justified gaps
|
|
SEP_CORE="${SEPARATOR#"${SEPARATOR%%[! ]*}"}" # trim leading spaces
|
|
SEP_CORE="${SEP_CORE%"${SEP_CORE##*[! ]}"}" # trim trailing spaces
|
|
SEP_CORE_LEN="${#SEP_CORE}"
|
|
# If separator is pure spaces, core is empty — gaps will be pure space
|
|
if [[ -z "$SEP_CORE" ]]; then
|
|
SEP_CORE_LEN=0
|
|
fi
|
|
|
|
# ── Dump State Mode ───────────────────────────────────────────────────────────
|
|
# Output all computed internal state as JSON for debugging
|
|
|
|
if [[ "$CLI_MODE" == "dump-state" ]]; then
|
|
jq -n \
|
|
--argjson term_width "$TERM_WIDTH" \
|
|
--arg width_tier "$WIDTH_TIER" \
|
|
--arg detected_theme "$DETECTED_THEME" \
|
|
--arg vcs_type "$VCS_TYPE" \
|
|
--arg responsive_layout "${RESPONSIVE_LAYOUT:-}" \
|
|
--arg responsive_enabled "$RESPONSIVE" \
|
|
--arg justify "$JUSTIFY" \
|
|
--arg separator "$SEPARATOR" \
|
|
--arg project_dir "$PROJECT_DIR" \
|
|
--arg session_id "$SESSION_ID" \
|
|
--arg cache_dir "$CACHE_DIR" \
|
|
--arg config_path "$USER_CONFIG_PATH" \
|
|
--arg defaults_path "$DEFAULTS_PATH" \
|
|
--argjson width_margin "$WIDTH_MARGIN" \
|
|
--argjson narrow_bp "${NARROW_BP:-60}" \
|
|
--argjson medium_bp "${MEDIUM_BP:-100}" \
|
|
--arg glyphs_enabled "$GLYPHS_ENABLED" \
|
|
'{
|
|
terminal: {
|
|
detected_width: ($term_width + $width_margin),
|
|
effective_width: $term_width,
|
|
width_margin: $width_margin,
|
|
width_tier: $width_tier
|
|
},
|
|
responsive: {
|
|
enabled: ($responsive_enabled == "true"),
|
|
layout: (if $responsive_layout == "" then null else $responsive_layout end),
|
|
breakpoints: {narrow: $narrow_bp, medium: $medium_bp}
|
|
},
|
|
theme: $detected_theme,
|
|
vcs: $vcs_type,
|
|
layout: {
|
|
justify: $justify,
|
|
separator: $separator
|
|
},
|
|
glyphs_enabled: ($glyphs_enabled == "true"),
|
|
paths: {
|
|
project_dir: $project_dir,
|
|
config: $config_path,
|
|
defaults: $defaults_path,
|
|
cache_dir: $cache_dir
|
|
},
|
|
session_id: $session_id
|
|
}'
|
|
exit 0
|
|
fi
|
|
|
|
render_line() {
|
|
local line_json="$1"
|
|
local section_ids=()
|
|
local raw_texts=()
|
|
local ansi_texts=()
|
|
local priorities=()
|
|
local flex_idx=-1
|
|
|
|
# Parse section IDs from JSON array
|
|
local count
|
|
count="$(jq 'length' <<< "$line_json")"
|
|
|
|
local i
|
|
for (( i = 0; i < count; i++ )); do
|
|
local sid
|
|
sid="$(jq -r ".[$i]" <<< "$line_json")"
|
|
|
|
render_section "$sid"
|
|
if [[ -z "$SEC_RAW" ]]; then
|
|
continue
|
|
fi
|
|
|
|
section_ids+=("$sid")
|
|
raw_texts+=("$SEC_RAW")
|
|
ansi_texts+=("$SEC_ANSI")
|
|
priorities+=("$(get_section_priority "$sid")")
|
|
|
|
if is_flex_section "$sid"; then
|
|
if (( flex_idx == -1 )); then
|
|
# First flex section found
|
|
flex_idx=$(( ${#section_ids[@]} - 1 ))
|
|
elif is_spacer "$sid" && ! is_spacer "${section_ids[$flex_idx]}"; then
|
|
# Spacer overrides a non-spacer flex section, but not another spacer
|
|
flex_idx=$(( ${#section_ids[@]} - 1 ))
|
|
fi
|
|
fi
|
|
done
|
|
|
|
local n=${#section_ids[@]}
|
|
if (( n == 0 )); then
|
|
return
|
|
fi
|
|
|
|
# If the only sections are spacers, skip the line
|
|
local non_spacer=0
|
|
for (( i = 0; i < n; i++ )); do
|
|
if ! is_spacer "${section_ids[$i]}"; then
|
|
non_spacer=1
|
|
break
|
|
fi
|
|
done
|
|
if (( non_spacer == 0 )); then
|
|
return
|
|
fi
|
|
|
|
# Calculate total width using minimum separators
|
|
# Skips separator width when either adjacent section is a spacer
|
|
calc_width() {
|
|
local total=0
|
|
local active=("$@")
|
|
local prev_idx=-1
|
|
local idx
|
|
for idx in "${active[@]}"; do
|
|
if (( prev_idx >= 0 )); then
|
|
if ! is_spacer "${section_ids[$prev_idx]}" && ! is_spacer "${section_ids[$idx]}"; then
|
|
total=$(( total + SEP_LEN ))
|
|
fi
|
|
fi
|
|
total=$(( total + ${#raw_texts[$idx]} ))
|
|
prev_idx=$idx
|
|
done
|
|
echo "$total"
|
|
}
|
|
|
|
# Calculate content-only width (no separators)
|
|
calc_content_width() {
|
|
local total=0
|
|
local active=("$@")
|
|
local idx
|
|
for idx in "${active[@]}"; do
|
|
total=$(( total + ${#raw_texts[$idx]} ))
|
|
done
|
|
echo "$total"
|
|
}
|
|
|
|
# Build active indices
|
|
local active_indices=()
|
|
for (( i = 0; i < n; i++ )); do
|
|
active_indices+=("$i")
|
|
done
|
|
|
|
local total_width
|
|
total_width="$(calc_width "${active_indices[@]}")"
|
|
|
|
# Priority drop: remove priority 3 from right
|
|
if (( total_width > TERM_WIDTH )); then
|
|
local new_active=()
|
|
for idx in "${active_indices[@]}"; do
|
|
if [[ "${priorities[$idx]}" != "3" ]]; then
|
|
new_active+=("$idx")
|
|
fi
|
|
done
|
|
active_indices=("${new_active[@]}")
|
|
total_width="$(calc_width "${active_indices[@]}")"
|
|
fi
|
|
|
|
# Priority drop: remove priority 2 from right
|
|
if (( total_width > TERM_WIDTH )); then
|
|
local new_active=()
|
|
for idx in "${active_indices[@]}"; do
|
|
if [[ "${priorities[$idx]}" != "2" ]]; then
|
|
new_active+=("$idx")
|
|
fi
|
|
done
|
|
active_indices=("${new_active[@]}")
|
|
total_width="$(calc_width "${active_indices[@]}")"
|
|
fi
|
|
|
|
local active_count=${#active_indices[@]}
|
|
|
|
# ── Justify: spread / space-between ──────────────────────────────────────
|
|
# Distributes extra space into gaps between sections.
|
|
# "spread" — equal gaps everywhere (like CSS space-evenly)
|
|
# "space-between" — first/last flush to edges, equal gaps in between
|
|
# When justify is active, flex on individual sections is ignored.
|
|
|
|
# Check if any active section is a spacer — if so, bypass justify
|
|
local has_spacer=0
|
|
for idx in "${active_indices[@]}"; do
|
|
if is_spacer "${section_ids[$idx]}"; then
|
|
has_spacer=1
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ "$JUSTIFY" != "left" ]] && (( has_spacer == 0 && active_count > 1 && total_width < TERM_WIDTH )); then
|
|
local content_width
|
|
content_width="$(calc_content_width "${active_indices[@]}")"
|
|
local num_gaps=$(( active_count - 1 ))
|
|
local available=$(( TERM_WIDTH - content_width ))
|
|
|
|
# For space-between: all available space goes into the gaps
|
|
# For spread: conceptually N+1 slots, but since we can't pad before first
|
|
# or after last in a terminal line, we treat it as N gaps with equal size
|
|
# (effectively the same as space-between for terminal output)
|
|
local gap_width=$(( available / num_gaps ))
|
|
local gap_remainder=$(( available % num_gaps ))
|
|
|
|
# Build gap separators: center the separator core in each gap
|
|
# E.g., gap_width=8 with core "|": " | " (3 left + 1 core + 4 right)
|
|
# Remainder chars get distributed one per gap from the left
|
|
|
|
local gap_separators=()
|
|
local g
|
|
for (( g = 0; g < num_gaps; g++ )); do
|
|
local this_gap=$gap_width
|
|
# Distribute remainder: first gaps get +1
|
|
if (( g < gap_remainder )); then
|
|
this_gap=$(( this_gap + 1 ))
|
|
fi
|
|
|
|
local sep_str=""
|
|
if (( SEP_CORE_LEN > 0 )); then
|
|
local pad_total=$(( this_gap - SEP_CORE_LEN ))
|
|
if (( pad_total < 0 )); then pad_total=0; fi
|
|
local pad_left=$(( pad_total / 2 ))
|
|
local pad_right=$(( pad_total - pad_left ))
|
|
local lpad="" rpad=""
|
|
for (( i = 0; i < pad_left; i++ )); do lpad+=" "; done
|
|
for (( i = 0; i < pad_right; i++ )); do rpad+=" "; done
|
|
sep_str="${lpad}${SEP_CORE}${rpad}"
|
|
else
|
|
# No core char — pure space gap
|
|
for (( i = 0; i < this_gap; i++ )); do sep_str+=" "; done
|
|
fi
|
|
gap_separators+=("$sep_str")
|
|
done
|
|
|
|
# Assemble justified output
|
|
local output=""
|
|
local gap_idx=0
|
|
local first=1
|
|
for idx in "${active_indices[@]}"; do
|
|
if (( first )); then
|
|
first=0
|
|
else
|
|
output+="${C_DIM}${gap_separators[$gap_idx]}${C_RESET}"
|
|
gap_idx=$(( gap_idx + 1 ))
|
|
fi
|
|
output+="${ansi_texts[$idx]}"
|
|
done
|
|
|
|
printf '%b' "$output"
|
|
return
|
|
fi
|
|
|
|
# ── Left-aligned layout with optional flex expansion ─────────────────────
|
|
|
|
if (( flex_idx >= 0 && total_width < TERM_WIDTH )); then
|
|
# Check if flex section is still active
|
|
local flex_active=0
|
|
for idx in "${active_indices[@]}"; do
|
|
if (( idx == flex_idx )); then
|
|
flex_active=1
|
|
break
|
|
fi
|
|
done
|
|
|
|
if (( flex_active )); then
|
|
local extra=$(( TERM_WIDTH - total_width ))
|
|
local old_raw="${raw_texts[$flex_idx]}"
|
|
local old_ansi="${ansi_texts[$flex_idx]}"
|
|
|
|
# For spacers, replace placeholder with pure spaces
|
|
if is_spacer "${section_ids[$flex_idx]}"; then
|
|
local padding=""
|
|
for (( i = 0; i < extra + 1; i++ )); do padding+=" "; done
|
|
raw_texts[$flex_idx]="$padding"
|
|
ansi_texts[$flex_idx]="$padding"
|
|
elif [[ "${section_ids[$flex_idx]}" == "context_bar" ]]; then
|
|
local pct
|
|
pct="$(inp '.context_window.used_percentage' '0')"
|
|
local pct_int
|
|
pct_int="$(printf '%.0f' "$pct" 2>/dev/null || echo 0)"
|
|
|
|
# Use config bar_width directly (not derived from raw text length,
|
|
# which may include formatting prefix/suffix/pad)
|
|
local cur_bar_width
|
|
cur_bar_width="$(cfg '.sections.context_bar.bar_width' '10')"
|
|
local new_bar_width=$(( cur_bar_width + extra ))
|
|
(( new_bar_width < 3 )) && new_bar_width=3
|
|
|
|
local filled=$(( pct_int * new_bar_width / 100 ))
|
|
local empty_count=$(( new_bar_width - filled ))
|
|
|
|
local bar=""
|
|
for (( i = 0; i < filled; i++ )); do bar+="="; done
|
|
for (( i = 0; i < empty_count; i++ )); do bar+="-"; done
|
|
|
|
raw_texts[$flex_idx]="[${bar}] ${pct_int}%"
|
|
|
|
local warn danger critical
|
|
warn="$(cfg '.sections.context_bar.thresholds.warn' '50')"
|
|
danger="$(cfg '.sections.context_bar.thresholds.danger' '70')"
|
|
critical="$(cfg '.sections.context_bar.thresholds.critical' '85')"
|
|
|
|
local color="$C_GREEN"
|
|
if (( pct_int >= critical )); then
|
|
color="${C_RED}${C_BOLD}"
|
|
elif (( pct_int >= danger )); then
|
|
color="$C_RED"
|
|
elif (( pct_int >= warn )); then
|
|
color="$C_YELLOW"
|
|
fi
|
|
|
|
ansi_texts[$flex_idx]="${color}[${bar}] ${pct_int}%${C_RESET}"
|
|
|
|
# Re-apply per-section formatting (prefix/suffix/color/pad) since
|
|
# the flex rebuild replaced the formatted output from scratch
|
|
SEC_RAW="${raw_texts[$flex_idx]}"
|
|
SEC_ANSI="${ansi_texts[$flex_idx]}"
|
|
apply_formatting "context_bar"
|
|
raw_texts[$flex_idx]="$SEC_RAW"
|
|
ansi_texts[$flex_idx]="$SEC_ANSI"
|
|
else
|
|
# Generic flex: pad with spaces
|
|
local padding=""
|
|
for (( i = 0; i < extra; i++ )); do padding+=" "; done
|
|
raw_texts[$flex_idx]="${old_raw}${padding}"
|
|
ansi_texts[$flex_idx]="${old_ansi}${padding}"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Assemble left-aligned output
|
|
local output=""
|
|
local prev_asm_idx=-1
|
|
for idx in "${active_indices[@]}"; do
|
|
if (( prev_asm_idx >= 0 )); then
|
|
# Suppress separator when either side is a spacer
|
|
if ! is_spacer "${section_ids[$prev_asm_idx]}" && ! is_spacer "${section_ids[$idx]}"; then
|
|
output+="${C_DIM}${SEPARATOR}${C_RESET}"
|
|
fi
|
|
fi
|
|
output+="${ansi_texts[$idx]}"
|
|
prev_asm_idx=$idx
|
|
done
|
|
|
|
printf '%b' "$output"
|
|
}
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
main() {
|
|
local layout_json
|
|
layout_json="$(resolve_layout)"
|
|
|
|
local line_count
|
|
line_count="$(jq 'length' <<< "$layout_json")"
|
|
|
|
local lines=()
|
|
local i
|
|
for (( i = 0; i < line_count; i++ )); do
|
|
local line_def
|
|
line_def="$(jq -c ".[$i]" <<< "$layout_json")"
|
|
local rendered
|
|
rendered="$(render_line "$line_def")"
|
|
if [[ -n "$rendered" ]]; then
|
|
lines+=("$rendered")
|
|
fi
|
|
done
|
|
|
|
# Output lines separated by newlines
|
|
local first=1
|
|
for line in "${lines[@]}"; do
|
|
if (( first )); then
|
|
first=0
|
|
else
|
|
printf '\n'
|
|
fi
|
|
printf '%b' "$line"
|
|
done
|
|
}
|
|
|
|
main
|