#!/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