From b7b269ae960295222a0ce454fca9cc867c5c038e Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 13:24:49 +0200 Subject: [PATCH 1/7] feat(statusline): ship costs.sh as the one cost ledger, drop statusline-costs.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statusline-costs.py summed every assistant line of every transcript, which over-counts spend ~2.2x: Claude Code re-logs one API response 2-3 times (streaming / tool continuation), so a correct total must deduplicate on message.id:requestId before pricing. Measured over 172 local transcripts: $3438.84 deduped vs $7645.04 raw. costs.sh replaces it and prices each session from its OWN transcript plus the full recursive /subagents/ subtree, so Task, worktree-isolated and workflow agents are all billed — none of them appear in Claude Code's .cost.total_cost_usd, which covers the main thread only and additionally carries prior spend forward on a resumed session. pricing.json ships alongside: costs.sh resolves it next to itself, then falls back to ~/.claude/pricing.json, and refuses to run without it. Also fixes the six shellcheck findings the file carried, so it can join the CI shellcheck step rather than be excluded from it: three `local x="$(...)"` splits (SC2155 masks the substitution's exit status), one dead `log=` assignment (SC2034), and two scoped SC2016 disables on the jq programs, whose $models/$ml sigils are jq variables that must reach jq unexpanded. Verified behaviour-preserving against the unmodified script over the real ledger and a 531-line transcript: byte-identical ledger row ($10.178724), identical today/month totals, same three cache sidecars. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- plugins/statusline/assets/costs.sh | 643 ++++++++++++++++++ plugins/statusline/assets/pricing.json | 15 + plugins/statusline/assets/statusline-costs.py | 166 ----- 3 files changed, 658 insertions(+), 166 deletions(-) create mode 100755 plugins/statusline/assets/costs.sh create mode 100644 plugins/statusline/assets/pricing.json delete mode 100644 plugins/statusline/assets/statusline-costs.py diff --git a/plugins/statusline/assets/costs.sh b/plugins/statusline/assets/costs.sh new file mode 100755 index 0000000..8f0551b --- /dev/null +++ b/plugins/statusline/assets/costs.sh @@ -0,0 +1,643 @@ +#!/usr/bin/env bash +# claude-statusline cost ledger CLI. +# +# Owns ~/.claude/statusline-costs.jsonl. The render path (statusline.sh) shells +# out to this script for every refresh: `update` writes a row, `today` / `month` +# return current totals. +# +# Verbs: +# update stdin = Claude Code JSON; upsert a ledger row (idempotent per session_id) +# today echo "$X.XX" — sum of (cost_usd - cost_at_day_start) for today's rows +# month echo "$X.XX $Y.YY" — month-to-date total + weekday-average per day +# info human-friendly summary (today + month + avg) +# debug full diagnostics (per-session today, last-30-day table, ccusage cross-check, orphan tmps) +# init seed the ledger from ccusage (one-time, replaces existing log after backup) +# +# cost_usd source: priced locally from the session's OWN transcript (main + +# /subagents/**/*.jsonl), via _COST_JQ — NOT from Claude Code's +# cost.total_cost_usd. That field is cumulative across a resumed/continued +# conversation: after a same-day restart, the new session_id's total_cost_usd +# already carries the prior session's spend, which would double-count against +# that prior session's already-recorded ledger row. A session's transcript +# never contains another session's messages, so per-transcript pricing cannot +# double-count on resume. Verified no overlap between a main transcript and its +# own subagents/ dir (Task-tool sidechains live inline in main; agent-team +# subagents are separate files; never both). Disable the subagent half with +# STATUSLINE_COUNT_SUBAGENTS=0. +# +# Schema (one JSON object per line): +# {session_id, date, cost_at_day_start, cost_usd, month, month_day_start} +# +# today = sum(cost_usd - cost_at_day_start) where date=today +# month = sum(cost_usd - month_day_start) where month=current +# +# month_day_start: cost_usd at the moment this session entered the current month. +# Set to 0 for sessions that started this month. +# Set to prior cost_usd on a month rollover (session carried over from last month). +# Never changes within a month — so month total is always derivable from two raw fields, +# with no accumulation that can drift or be corrupted by concurrent writes. +# +# Day rollover: cost_at_day_start ← prior cost_usd; month_day_start unchanged (same month) +# Month rollover: month_day_start ← prior cost_usd (carry-over baseline) +# First-seen old session (duration > elapsed since midnight): cost_at_day_start ← cost_usd +# so its historical cost does NOT inflate today — only new spend from this point counts +# Account-switch protection: if cost_usd drops mid-session, rebaseline the day so +# today can't go negative, but preserve the month-to-date contribution already made +# (month_day_start absorbs the offset and may go negative). +# Concurrent-write protection: write is serialised with a mkdir lock; stale locks expire after 30s. +# +# STATUSLINE_TODAY_OVERRIDE (YYYY-MM-DD) / STATUSLINE_MONTH_OVERRIDE (YYYY-MM): +# test-only hooks to simulate "today"/"month" without faking the system clock — +# used to exercise the day/month rollover branches deterministically (e.g. the +# month-boundary transition). Unset in production; real `date` is used. + +set -u + +COST_LOG="${STATUSLINE_COST_LOG:-${HOME}/.claude/statusline-costs.jsonl}" +COST_LOG_RETENTION_DAYS="${COST_LOG_RETENTION_DAYS_OVERRIDE:-30}" + +# ── Shared pricing (single source of truth: pricing.json next to this script) ── +_PRICING="${STATUSLINE_PRICING_FILE:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/pricing.json}" +[[ -f "$_PRICING" ]] || _PRICING="$HOME/.claude/pricing.json" +if [[ ! -f "$_PRICING" ]]; then + echo "costs.sh: pricing.json not found (looked next to script and in ~/.claude)" >&2; exit 1 +fi +_today=$(date +%Y-%m-%d) +_MODELS=$(jq -c --arg today "$_today" ' + .sonnet5_intro_until as $cut + | .models + | map( if .intro then (. + (if $today < $cut then .intro else .list end) | del(.intro,.list)) else . end ) + | map({match, ind, out, cw5, cw1, cr})' "$_PRICING") + +# Price a stream of Claude Code transcript lines (passed on stdin) from token +# usage. Standard-tier $/Mtok by model family, resolved from the injected +# $models array (see pricing.json); cache writes split 5m/1h when the +# breakdown is present, with any unattributed cache-creation priced at the 5m rate. +# shellcheck disable=SC2016 # jq program: $models/$ml are jq variables, not shell. +_COST_JQ=' +def rates(m): + (m // "" | ascii_downcase) as $ml + | ($models | map(select(.match as $rx | $rx == "" or ($ml | test($rx)))) | first); +# Dedupe by message id BEFORE summing. Claude Code writes each API response to the +# transcript multiple times (streaming / tool-continuation re-logging), so summing +# every assistant line over-counts a response 2-3x. Key on message.id:requestId +# (matching ccusage); a line without message.id falls back to its stream position +# so id-less lines are never merged together. +[ inputs + | (fromjson? // empty) + | objects + | select(.type=="assistant" and (.message.usage != null)) ] +| to_entries +| map( .key as $i | .value as $v + | { dk: (if ($v.message.id) then ($v.message.id + ":" + ($v.requestId // "")) else "line-\($i)" end), + m: $v.message.model, u: $v.message.usage } ) +| unique_by(.dk) +| [ .[] + | .u as $u | rates(.m) as $r + | ( ($u.input_tokens // 0) * $r.ind + + ($u.output_tokens // 0) * $r.out + + (($u.cache_creation.ephemeral_5m_input_tokens // 0) * $r.cw5) + + (($u.cache_creation.ephemeral_1h_input_tokens // 0) * $r.cw1) + + ( ( [ ($u.cache_creation_input_tokens // 0) + - (($u.cache_creation.ephemeral_5m_input_tokens // 0) + +($u.cache_creation.ephemeral_1h_input_tokens // 0)), 0 ] | max ) * $r.cw5 ) + + ($u.cache_read_input_tokens // 0) * $r.cr + ) / 1000000 +] | add // 0 +' + +# Test seam: price EACH line of a transcript-shaped file independently (no +# .type=="assistant" filter, no message.id dedupe), using the same rates(m) +# lookup and the same per-line cost formula as _COST_JQ above. Used by the +# pricing-parity test to prove pricing.json produces byte-identical numbers +# to the pre-refactor hardcoded rates, one cost per input line. +# shellcheck disable=SC2016 # jq program: $models/$ml are jq variables, not shell. +_SAMPLE_JQ=' +def rates(m): + (m // "" | ascii_downcase) as $ml + | ($models | map(select(.match as $rx | $rx == "" or ($ml | test($rx)))) | first); +[ inputs | (fromjson? // empty) | objects ] +| .[] +| .message.model as $m | .message.usage as $u | rates($m) as $r +| ( ($u.input_tokens // 0) * $r.ind + + ($u.output_tokens // 0) * $r.out + + (($u.cache_creation.ephemeral_5m_input_tokens // 0) * $r.cw5) + + (($u.cache_creation.ephemeral_1h_input_tokens // 0) * $r.cw1) + + ( ( [ ($u.cache_creation_input_tokens // 0) + - (($u.cache_creation.ephemeral_5m_input_tokens // 0) + +($u.cache_creation.ephemeral_1h_input_tokens // 0)), 0 ] | max ) * $r.cw5 ) + + ($u.cache_read_input_tokens // 0) * $r.cr + ) / 1000000 +' + +cmd_price_sample() { + local file="$1" + [[ -n "$file" && -f "$file" ]] || { echo "usage: costs.sh __price_sample " >&2; return 2; } + jq -nR --argjson models "$_MODELS" "$_SAMPLE_JQ" < "$file" 2>/dev/null +} + +# Generic memoized pricing: re-price only when the watched mtime changes (and +# not more than once per throttle window), caching " +# " in $1. Shared by _main_cost and _subagent_cost below. +# * mtime key — transcripts are append-only, so if the newest mtime is +# unchanged the priced cost is still exact; skip the parse. +# * throttle — even when it IS changing, re-price at most once per +# $3 seconds. Bounds a busy burst to one parse per window; +# the displayed cost simply lags up to that delta. +# Steady state is one stat (~2ms) instead of re-reading a tens-of-MB transcript. +# ponytail: mtime granularity is whole seconds; the throttle makes sub-second +# precision irrelevant anyway. +_price_cached() { + local cache="$1" newest="$2" throttle="$3"; shift 3 + local price_fn="$1"; shift + [[ -n "$newest" ]] || { echo 0; return; } + + local now; now=$(date +%s) + if [[ -s "$cache" ]]; then + local cached_ts cached_cost cached_at + read -r cached_ts cached_cost cached_at < "$cache" + # Reuse if unchanged OR we re-priced within the throttle window. + if [[ "$cached_ts" == "$newest" ]] || (( now - ${cached_at:-0} < throttle )); then + printf '%s' "$cached_cost"; return + fi + fi + + local cost; cost=$("$price_fn" "$@") + cost=$(awk -v c="${cost:-0}" 'BEGIN{printf "%.6f", c+0}') + printf '%s %s %s\n' "$newest" "$cost" "$now" > "$cache" 2>/dev/null + printf '%s' "$cost" +} + +_do_price_file() { + jq -nR --argjson models "$_MODELS" "$_COST_JQ" < "$1" 2>/dev/null +} + +_do_price_dir() { + find "$1" -type f -name '*.jsonl' -exec cat {} + 2>/dev/null \ + | jq -nR --argjson models "$_MODELS" "$_COST_JQ" 2>/dev/null +} + +# Price the session's OWN spend from its main transcript file. This is the +# authoritative source for the main conversation's cost (see header note on +# why cost.total_cost_usd is not used: it double-counts on session resume). +# Arg: path to the main session transcript (.../.jsonl). +_main_cost() { + local transcript="$1" + [[ -n "$transcript" && -f "$transcript" ]] || { echo 0; return; } + local mtime; mtime=$(stat -f '%m' "$transcript" 2>/dev/null || stat -c '%Y' "$transcript" 2>/dev/null) + local cache; cache="${COST_LOG}.main.$(basename "${transcript%.jsonl}")" + _price_cached "$cache" "$mtime" "${STATUSLINE_MAIN_REFRESH_SEC:-60}" _do_price_file "$transcript" +} + +# Sum subagent/agent-team spend for a session from its transcript siblings. +# Arg: path to the main session transcript (.../.jsonl). +# Echoes a dollar figure (0 if the subagents dir is absent or empty). +_subagent_cost() { + local transcript="$1" + [[ -n "$transcript" ]] || { echo 0; return; } + local subdir="${transcript%.jsonl}/subagents" + [[ -d "$subdir" ]] || { echo 0; return; } + + # Newest transcript mtime across the WHOLE subtree = change key. Must recurse to + # match the recursive pricing below: agents can spawn agents, so a nested + # transcript being written has to invalidate the cache too. + local newest + newest=$(find "$subdir" -type f -name '*.jsonl' -print0 2>/dev/null \ + | while IFS= read -r -d '' f; do + stat -f '%m' "$f" 2>/dev/null || stat -c '%Y' "$f" 2>/dev/null + done | sort -rn | head -1) + [[ -n "$newest" ]] || { echo 0; return; } # dir present but empty + + local cache; cache="${COST_LOG}.sub.$(basename "${transcript%.jsonl}")" + _price_cached "$cache" "$newest" "${STATUSLINE_SUBAGENT_REFRESH_SEC:-60}" _do_price_dir "$subdir" +} + +# ── Helpers ────────────────────────────────────────────────────────────────── + +# Exclusive mkdir-based lock (atomic on all POSIX filesystems). +# Stale locks (> 30 s) are removed automatically. +_acquire_lock() { + local lockdir="${COST_LOG}.lock" + local waited=0 max_wait=50 # 50 × 0.1s = 5s + while ! mkdir "$lockdir" 2>/dev/null; do + local mtime now + mtime=$(stat -c %Y "$lockdir" 2>/dev/null \ + || stat -f %m "$lockdir" 2>/dev/null || echo 0) + now=$(date +%s) + if (( now - mtime > 30 )); then + rmdir "$lockdir" 2>/dev/null; continue + fi + (( waited >= max_wait )) && return 1 + sleep 0.1; (( waited++ )) || true + done + return 0 +} + +_release_lock() { rmdir "${COST_LOG}.lock" 2>/dev/null || true; } + +# Trigger async cleanup of orphan .tmp.* files at most once per hour. +# Writes a stamp file after each run; skips entirely if stamp is recent. +_cleanup_tmps_maybe() { + local stamp="${COST_LOG}.cleanup-stamp" + local now; now=$(date +%s) + local mtime=0 + [[ -f "$stamp" ]] && mtime=$(stat -c %Y "$stamp" 2>/dev/null \ + || stat -f %m "$stamp" 2>/dev/null || echo 0) + (( now - mtime < 3600 )) && return 0 # cleaned within the last hour — skip + + # Touch stamp before forking so concurrent updates don't spawn multiple cleaners. + touch "$stamp" 2>/dev/null + + local dir base + dir=$(dirname "$COST_LOG"); base=$(basename "$COST_LOG") + ( + local f mtime_f now_f; now_f=$(date +%s) + while IFS= read -r f; do + mtime_f=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo "$now_f") + (( now_f - mtime_f > 60 )) && rm -f "$f" 2>/dev/null || true + done < <(find "$dir" -maxdepth 1 -name "${base}.tmp.*" 2>/dev/null) + # Per-session price caches (main + subagent), expire on the retention window. + local ret_sec=$(( COST_LOG_RETENTION_DAYS * 86400 )) + while IFS= read -r f; do + mtime_f=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo "$now_f") + (( now_f - mtime_f > ret_sec )) && rm -f "$f" 2>/dev/null || true + done < <(find "$dir" -maxdepth 1 \( -name "${base}.sub.*" -o -name "${base}.main.*" \) 2>/dev/null) + ) & + disown 2>/dev/null || true +} + +_days_ago() { + date -v -"$1"d +%Y-%m-%d 2>/dev/null \ + || date -d "$1 days ago" +%Y-%m-%d 2>/dev/null \ + || echo "1970-01-01" +} + +_midnight_ts() { + date -v0H -v0M -v0S +%s 2>/dev/null \ + || date -d "today 00:00:00" +%s 2>/dev/null \ + || echo $(( $(date +%s) - 86400 )) +} + +# Count weekdays (Mon–Fri) elapsed so far this month, including today. +_weekdays_elapsed() { + local month day_of_month dow d dstr count=0 + month="${STATUSLINE_MONTH_OVERRIDE:-$(date +%Y-%m)}" + if [[ -n "${STATUSLINE_TODAY_OVERRIDE:-}" ]]; then + day_of_month=$((10#${STATUSLINE_TODAY_OVERRIDE##*-})) # force base-10 (strip leading-zero octal trap) + else + day_of_month=$(date +%-d) + fi + for (( d=1; d<=day_of_month; d++ )); do + dstr="${month}-$(printf '%02d' "$d")" + dow=$(date -j -f "%Y-%m-%d" "$dstr" +%u 2>/dev/null \ + || date -d "$dstr" +%u 2>/dev/null) + [[ "$dow" -le 5 ]] && (( count++ )) || true + done + [[ "$count" -lt 1 ]] && count=1 + echo "$count" +} + +# ── update ─────────────────────────────────────────────────────────────────── + +cmd_update() { + local input session_id duration_ms transcript_path + input=$(cat) + # One jq pass for every field we need — extra subprocesses lengthen the + # locked critical section under heavy parallelism (many Claude Code windows + # refreshing at once), which is exactly when lock waiters start timing out. + IFS=$'\t' read -r session_id duration_ms transcript_path < <( + printf '%s' "$input" | jq -r \ + '[.session_id // "", .cost.total_duration_ms // 0, .transcript_path // ""] | @tsv' \ + 2>/dev/null) + + [[ -z "$session_id" ]] && return 0 + + # Price from the session's own transcript (see header note: total_cost_usd + # is cumulative across a resume and would double-count). + local main_cost; main_cost=$(_main_cost "$transcript_path") + local sub_cost=0 + if [[ "${STATUSLINE_COUNT_SUBAGENTS:-1}" != "0" && -n "$transcript_path" ]]; then + sub_cost=$(_subagent_cost "$transcript_path") + fi + local cost_usd; cost_usd=$(awk -v a="$main_cost" -v b="$sub_cost" 'BEGIN{printf "%.6f", a+b}') + local session_cost; session_cost=$(printf '%s' "$cost_usd" | awk '{printf "%.6f", $0+0}') + [[ -z "$session_cost" || "$session_cost" == "0.000000" ]] && return 0 + + local today month cutoff + today="${STATUSLINE_TODAY_OVERRIDE:-$(date +%Y-%m-%d)}" + month="${STATUSLINE_MONTH_OVERRIDE:-$(date +%Y-%m)}" + cutoff=$(_days_ago "$COST_LOG_RETENTION_DAYS") + + mkdir -p "$(dirname "$COST_LOG")" 2>/dev/null + _cleanup_tmps_maybe + + # Serialise the read→compute→write with a lock so concurrent Claude Code + # windows can't double-count deltas. + if ! _acquire_lock; then + return 0 # couldn't acquire within 5s — skip silently, next refresh will catch up + fi + local tmp="${COST_LOG}.tmp.$$" + trap '_release_lock; rm -f "$tmp" 2>/dev/null' EXIT + + local prior + prior=$(grep -F "\"session_id\":\"${session_id}\"" "$COST_LOG" 2>/dev/null | tail -1) + + local needs_write=false day_start="0.000000" month_day_start="0.000000" + + if [[ -z "$prior" ]] || ! printf '%s' "$prior" | jq -e 'has("cost_at_day_start")' &>/dev/null; then + # New session OR old-schema row: detect whether session started today. + local now_ts midnight_ts elapsed_today session_age_sec + now_ts=$(date +%s); midnight_ts=$(_midnight_ts) + elapsed_today=$(( now_ts - midnight_ts )) + session_age_sec=$(( duration_ms / 1000 )) + needs_write=true + if [[ $session_age_sec -le $elapsed_today ]]; then + # Started today — baseline is 0. + day_start="0.000000"; month_day_start="0.000000" + else + # Started before today (or before this month) — treat its entire + # cost as historical; only new spend from this point counts. + day_start="$session_cost"; month_day_start="$session_cost" + fi + else + local prior_cost prior_date prior_day_start prior_month prior_mds + prior_cost=$(printf '%s' "$prior" | jq -r '.cost_usd // 0' | awk '{printf "%.6f", $0}') + prior_date=$(printf '%s' "$prior" | jq -r '.date // ""') + prior_day_start=$(printf '%s' "$prior" | jq -r '.cost_at_day_start // 0' | awk '{printf "%.6f", $0}') + prior_month=$(printf '%s' "$prior" | jq -r '.month // ""') + # Support both new schema (month_day_start) and old schema (month_cost). + # For old rows: approximate month_day_start = max(0, cost_usd - month_cost). + if printf '%s' "$prior" | jq -e 'has("month_day_start")' &>/dev/null; then + prior_mds=$(printf '%s' "$prior" | jq -r '.month_day_start // 0' | awk '{printf "%.6f", $0}') + else + local old_mc + old_mc=$(printf '%s' "$prior" | jq -r '.month_cost // 0' | awk '{printf "%.6f", $0}') + prior_mds=$(awk "BEGIN{v=$prior_cost - $old_mc; printf \"%.6f\", (v<0)?0:v}") + fi + + if [[ "$prior_date" != "$today" ]]; then + needs_write=true + day_start="$prior_cost" + if [[ "$prior_month" != "$month" ]]; then + # Month rollover: this session carries over; new month starts from prior cost. + month_day_start="$prior_cost" + else + # Day rollover within same month: month baseline unchanged. + month_day_start="$prior_mds" + fi + elif [[ "$prior_cost" != "$session_cost" ]]; then + needs_write=true + if awk "BEGIN{exit !($session_cost < $prior_cost)}"; then + # Cost dropped → account switch or SDK reset. Rebaseline the day + # baseline so today's delta can't go negative, but PRESERVE this + # session's month-to-date contribution so far (prior_cost - prior_mds): + # carry it into the new baseline so month delta (cost_usd - month_day_start) + # still equals it at the switch point. month_day_start may go negative, + # which is fine — it's a pure offset, and month sums clamp at 0. + day_start="$session_cost" + month_day_start=$(awk "BEGIN{printf \"%.6f\", $session_cost - ($prior_cost - $prior_mds)}") + else + day_start="$prior_day_start" + month_day_start="$prior_mds" + fi + else + day_start="$prior_day_start"; month_day_start="$prior_mds" + fi + fi + + if $needs_write; then + { + if [[ -s "$COST_LOG" ]]; then + jq -c --arg sid "$session_id" --arg cutoff "$cutoff" \ + 'select(.session_id != $sid and .date >= $cutoff)' \ + "$COST_LOG" 2>/dev/null + fi + jq -cn \ + --arg sid "$session_id" \ + --arg date "$today" \ + --argjson start "$day_start" \ + --argjson cur "$session_cost" \ + --arg mon "$month" \ + --argjson mds "$month_day_start" \ + '{session_id:$sid,date:$date,cost_at_day_start:$start,cost_usd:$cur,month:$mon,month_day_start:$mds}' + } > "$tmp" 2>/dev/null && mv "$tmp" "$COST_LOG" + fi + + _release_lock + rm -f "$tmp" 2>/dev/null + trap - EXIT + return 0 +} + +# ── today ──────────────────────────────────────────────────────────────────── + +cmd_today() { + if [[ ! -s "$COST_LOG" ]]; then echo "0"; return 0; fi + local today; today="${STATUSLINE_TODAY_OVERRIDE:-$(date +%Y-%m-%d)}" + jq -s --arg d "$today" \ + '[.[] | select(.date == $d) | ((.cost_usd - (.cost_at_day_start // 0)) | if . < 0 then 0 else . end)] | add // 0' \ + "$COST_LOG" 2>/dev/null || echo "0" +} + +# ── month ──────────────────────────────────────────────────────────────────── +# Outputs " " + +cmd_month() { + if [[ ! -s "$COST_LOG" ]]; then echo "0 0"; return 0; fi + local month total wd avg + month="${STATUSLINE_MONTH_OVERRIDE:-$(date +%Y-%m)}" + # Use month_day_start (new schema) if present; fall back to month_cost (old schema). + total=$(jq -s --arg m "$month" ' + [.[] | select(.month == $m) | + (if has("month_day_start") + then (.cost_usd - (.month_day_start // 0)) + else (.month_cost // 0) + end) | if . < 0 then 0 else . end + ] | add // 0' \ + "$COST_LOG" 2>/dev/null) || total="0" + [[ -z "$total" || "$total" == "null" ]] && total="0" + wd=$(_weekdays_elapsed) + avg=$(awk -v t="$total" -v w="$wd" 'BEGIN{printf "%.6f", (w>0)?t/w:0}') + echo "$total $avg" +} + +# ── info ───────────────────────────────────────────────────────────────────── + +cmd_info() { + local today_total month_pair month_total month_avg + today_total=$(cmd_today) + month_pair=$(cmd_month) + month_total=${month_pair%% *} + month_avg=${month_pair##* } + + if [[ ! -s "$COST_LOG" ]]; then + echo "Ledger: $COST_LOG (empty)" + echo "Run \`$0 init\` to seed from ccusage, or wait for the first Claude Code session to populate it." + return 0 + fi + + printf 'Today (%s): $%.2f\n' "$(date +%Y-%m-%d)" "$today_total" + printf 'Month (%s): $%.2f\n' "$(date +%Y-%m)" "$month_total" + printf 'Avg/weekday this month: $%.2f\n' "$month_avg" + echo "Ledger: $COST_LOG" +} + +# ── debug ──────────────────────────────────────────────────────────────────── + +cmd_debug() { + local today; today="${STATUSLINE_TODAY_OVERRIDE:-$(date +%Y-%m-%d)}" + local month; month="${STATUSLINE_MONTH_OVERRIDE:-$(date +%Y-%m)}" + + echo "claude-statusline costs debug" + echo " cost log: $COST_LOG" + echo " retention: ${COST_LOG_RETENTION_DAYS} days" + + if [[ -s "$COST_LOG" ]]; then + local lines size oldest newest + lines=$(wc -l < "$COST_LOG" | tr -d ' ') + size=$(wc -c < "$COST_LOG" | tr -d ' ') + oldest=$(jq -rs '[.[].date] | min // "(none)"' "$COST_LOG" 2>/dev/null) + newest=$(jq -rs '[.[].date] | max // "(none)"' "$COST_LOG" 2>/dev/null) + echo " entries: ${lines} rows (${size} bytes)" + echo " range: ${oldest} → ${newest}" + else + echo " entries: (no entries yet)" + fi + + echo + echo "Today (${today}):" + if [[ -s "$COST_LOG" ]]; then + jq -r --arg d "$today" ' + select(.date == $d) + | " \(.session_id) start=\(.cost_at_day_start) cur=\(.cost_usd) delta=\((.cost_usd - (.cost_at_day_start // 0)))" + ' "$COST_LOG" 2>/dev/null + printf ' total: $%.2f\n' "$(cmd_today)" + else + echo " (no entries yet)" + fi + + echo + echo "Last ${COST_LOG_RETENTION_DAYS} days (per-day totals):" + if [[ -s "$COST_LOG" ]]; then + local cutoff; cutoff=$(_days_ago "$COST_LOG_RETENTION_DAYS") + jq -rs --arg c "$cutoff" ' + [.[] | select(.date >= $c)] + | group_by(.date) + | map({date: .[0].date, + total: ([.[] | ((.cost_usd - (.cost_at_day_start // 0)) | if . < 0 then 0 else . end)] | add // 0)}) + | sort_by(.date) + | .[] + | " \(.date) $\(.total | . * 100 | round / 100)" + ' "$COST_LOG" 2>/dev/null + else + echo " (no entries yet)" + fi + + echo + echo "Month-to-date (${month}):" + local mp; mp=$(cmd_month) + printf ' total: $%.2f avg/weekday: $%.2f\n' "${mp%% *}" "${mp##* }" + echo " (computed as sum(cost_usd - month_day_start) — no accumulation drift possible)" + + echo + echo "ccusage cross-check:" + if command -v ccusage >/dev/null 2>&1; then + local cc_today cc_month + cc_today=$(NODE_USE_ENV_PROXY=1 ccusage daily --json --since "$(date +%Y%m%d)" --until "$(date +%Y%m%d)" 2>/dev/null \ + | jq -r '.totals.totalCost // empty' 2>/dev/null) + cc_month=$(NODE_USE_ENV_PROXY=1 ccusage monthly --json --since "$(date +%Y%m01)" --until "$(date +%Y%m%d)" 2>/dev/null \ + | jq -r '.totals.totalCost // empty' 2>/dev/null) + printf ' ccusage today: $%.2f\n' "${cc_today:-0}" + printf ' ccusage month: $%.2f\n' "${cc_month:-0}" + if [[ -z "${cc_today:-}" || "${cc_today:-0}" == "0" ]]; then + echo " (ccusage returned 0 — if you're behind a corporate proxy, set NODE_USE_ENV_PROXY=1)" + fi + else + echo " (ccusage not on PATH — install via brew install ccusage or npm i -g ccusage)" + fi + + echo + local dir base orphans + dir=$(dirname "$COST_LOG"); base=$(basename "$COST_LOG") + orphans=$(find "$dir" -maxdepth 1 -name "${base}.tmp.*" 2>/dev/null | wc -l | tr -d ' ') + if [[ "$orphans" -gt 0 ]]; then + echo "Orphan tmp files: ${orphans} found in ${dir} (run: rm \"${COST_LOG}.tmp.\"*)" + else + echo "Orphan tmp files: none" + fi + return 0 +} + +# ── init ───────────────────────────────────────────────────────────────────── +# Seeds the ledger from ccusage. Backs up any existing log. +# Writes one row carrying month-to-date minus today's spend so today's live +# accumulator can grow on top. + +cmd_init() { + if ! command -v ccusage >/dev/null 2>&1; then + echo "ccusage not on PATH — install via brew install ccusage or npm i -g ccusage" >&2 + return 2 + fi + if ! command -v jq >/dev/null 2>&1; then + echo "jq not on PATH" >&2 + return 2 + fi + + local month since today_y m_total t_total carry + month=$(date +%Y-%m); since=$(date +%Y%m01); today_y=$(date +%Y%m%d) + m_total=$(NODE_USE_ENV_PROXY=1 ccusage monthly --json --since "$since" --until "$today_y" 2>/dev/null \ + | jq -r '.totals.totalCost // 0') + t_total=$(NODE_USE_ENV_PROXY=1 ccusage daily --json --since "$today_y" --until "$today_y" 2>/dev/null \ + | jq -r '.totals.totalCost // 0') + carry=$(awk "BEGIN{printf \"%.6f\", $m_total - $t_total}") + + mkdir -p "$(dirname "$COST_LOG")" + if [[ -s "$COST_LOG" ]]; then + local bak; bak="${COST_LOG}.bak.$(date +%Y%m%d-%H%M%S)" + cp "$COST_LOG" "$bak" && echo "Backup: $bak" + fi + + # Seed row: cost_usd=carry, month_day_start=0 → month total = carry - 0 = carry. + jq -cn --arg m "$month" --argjson carry "$carry" \ + '{session_id:"__ccusage_seed__",date:($m+"-01"),cost_at_day_start:0,cost_usd:$carry,month:$m,month_day_start:0}' \ + > "$COST_LOG" + + printf 'Seeded month carryover: $%.2f (from ccusage month $%.2f minus today $%.2f).\n' \ + "$carry" "$m_total" "$t_total" + return 0 +} + +# ── dispatch ───────────────────────────────────────────────────────────────── + +usage() { + cat <<'EOF' +costs.sh — claude-statusline cost ledger CLI + +Usage: + costs.sh update # stdin = Claude Code JSON; upsert ledger row + costs.sh today # echo $X.XX + costs.sh month # echo "$X.XX $Y.YY" (total + avg/weekday) + costs.sh info # human-friendly summary + costs.sh debug # full diagnostics + ccusage cross-check + costs.sh init # seed from ccusage (one-time, backs up existing log) + +Schema: {session_id, date, cost_at_day_start, cost_usd, month, month_day_start} + month total = sum(cost_usd - month_day_start) — derived, never accumulated + +Env: + STATUSLINE_COST_LOG # override ledger path (default: ~/.claude/statusline-costs.jsonl) + COST_LOG_RETENTION_DAYS_OVERRIDE # override retention window (default: 30) +EOF +} + +case "${1:-}" in + update) shift; cmd_update "$@" ;; + today) shift; cmd_today "$@" ;; + month) shift; cmd_month "$@" ;; + info) shift; cmd_info "$@" ;; + debug) shift; cmd_debug "$@" ;; + init) shift; cmd_init "$@" ;; + __price_sample) shift; cmd_price_sample "$@" ;; + ""|help|-h|--help) usage; [[ -z "${1:-}" ]] && exit 1 || exit 0 ;; + *) echo "unknown verb: $1" >&2; usage >&2; exit 1 ;; +esac diff --git a/plugins/statusline/assets/pricing.json b/plugins/statusline/assets/pricing.json new file mode 100644 index 0000000..f1fea04 --- /dev/null +++ b/plugins/statusline/assets/pricing.json @@ -0,0 +1,15 @@ +{ + "schema": 1, + "sonnet5_intro_until": "2026-08-31", + "models": [ + { "match": "opus-4-(5|6|7|8)", "ind": 5, "out": 25, "cw5": 6.25, "cw1": 10, "cr": 0.5 }, + { "match": "opus", "ind": 15, "out": 75, "cw5": 18.75, "cw1": 30, "cr": 1.5 }, + { "match": "fable|mythos", "ind": 10, "out": 50, "cw5": 12.5, "cw1": 20, "cr": 1.0 }, + { "match": "haiku", "ind": 1, "out": 5, "cw5": 1.25, "cw1": 2, "cr": 0.10 }, + { "match": "sonnet-5", + "intro": { "ind": 2, "out": 10, "cw5": 2.5, "cw1": 4, "cr": 0.2 }, + "list": { "ind": 3, "out": 15, "cw5": 3.75, "cw1": 6, "cr": 0.3 } }, + { "match": "sonnet", "ind": 3, "out": 15, "cw5": 3.75, "cw1": 6, "cr": 0.30 }, + { "match": "", "ind": 3, "out": 15, "cw5": 3.75, "cw1": 6, "cr": 0.30 } + ] +} diff --git a/plugins/statusline/assets/statusline-costs.py b/plugins/statusline/assets/statusline-costs.py deleted file mode 100644 index 621ad0f..0000000 --- a/plugins/statusline/assets/statusline-costs.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -"""Aggregate token costs from Claude Code transcripts for the statusline. - -costUSD is null in transcripts, so cost is derived from token counts x per-model -rates. Output is a small JSON cache the statusline reads cheaply; this script is -the slow path (full scan of ~/.claude/projects/**/*.jsonl) and is meant to run in -the background on a TTL, never inline with the 10s statusline refresh. - -Pricing (USD per 1M tokens): - - base input/output: source ~/.claude/rules/agent-reference/effort-calibration.md - (Opus 4.8 5/25, Sonnet 4.6 3/15, Haiku 4.5 1/5). Fable 5 ~= 2x Opus per - ~/.claude/rules/agent-reference/token-budget.md ("pays ~2x Opus rates"). - - cache multipliers: standard Anthropic prompt-cache pricing — - read = 0.1x input, 5m write = 1.25x input, 1h write = 2.0x input. - source: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching -""" - -import json -import os -import sys -from datetime import datetime, timezone -from glob import glob - -PROJECTS_DIR = os.path.expanduser("~/.claude/projects") -CACHE_PATH = os.path.expanduser("~/.claude/.statusline-cost-cache.json") - -# (input, output) USD per 1M tokens, matched by substring of the model id. -BASE_RATES = ( - ("opus", (5.0, 25.0)), - ("sonnet", (3.0, 15.0)), - ("haiku", (1.0, 5.0)), - ("fable", (10.0, 50.0)), # ~2x Opus, source: token-budget.md -) -DEFAULT_RATE = (5.0, 25.0) # unknown -> Opus, the conservative high tier - -CACHE_READ_MULT = 0.1 -CACHE_WRITE_5M_MULT = 1.25 -CACHE_WRITE_1H_MULT = 2.0 - - -def rate_for(model): - m = (model or "").lower() - for needle, rate in BASE_RATES: - if needle in m: - return rate - return DEFAULT_RATE - - -def message_cost_tokens(model, usage): - """(USD cost, total tokens) of one assistant message from its usage block. - - Total tokens sums every counter (input + output + cache read + cache - creation) — the raw volume processed, the figure a monthly token quota is - measured against.""" - rate_in, rate_out = rate_for(model) - in_tok = usage.get("input_tokens", 0) or 0 - out_tok = usage.get("output_tokens", 0) or 0 - read_tok = usage.get("cache_read_input_tokens", 0) or 0 - creation = usage.get("cache_creation", {}) or {} - w5 = creation.get("ephemeral_5m_input_tokens", 0) or 0 - w1 = creation.get("ephemeral_1h_input_tokens", 0) or 0 - # Older records only carry the flat cache_creation_input_tokens (treat as 5m). - if not creation: - w5 = usage.get("cache_creation_input_tokens", 0) or 0 - cost = ( - in_tok * rate_in - + out_tok * rate_out - + read_tok * rate_in * CACHE_READ_MULT - + w5 * rate_in * CACHE_WRITE_5M_MULT - + w1 * rate_in * CACHE_WRITE_1H_MULT - ) - return cost / 1_000_000.0, in_tok + out_tok + read_tok + w5 + w1 - - -def month_key(ts): - """YYYY-MM from an ISO timestamp, or None if unparseable.""" - if not ts: - return None - try: - dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) - return f"{dt.year:04d}-{dt.month:02d}" - except (ValueError, AttributeError): - return None - - -def scan_file(path, per_month, per_month_tok, agent_acc): - """Add this transcript's cost/tokens into per_month and per_month_tok; if it - is a subagent file, accumulate its cost and count into - agent_acc=[total_cost, invocations].""" - # Subagent transcripts are named agent-*.jsonl, whether spawned via Task - # (/subagents/) or by a workflow (/subagents/workflows/wf_*/). - is_agent = os.path.basename(path).startswith("agent-") - file_cost = 0.0 - try: - with open(path, "r", encoding="utf-8", errors="ignore") as fh: - for line in fh: - line = line.strip() - if not line or '"usage"' not in line: - continue - try: - rec = json.loads(line) - except json.JSONDecodeError: - continue - if rec.get("type") != "assistant": - continue - msg = rec.get("message") or {} - usage = msg.get("usage") - if not usage: - continue - c, t = message_cost_tokens(msg.get("model"), usage) - file_cost += c - mk = month_key(rec.get("timestamp")) - if mk: - per_month[mk] = per_month.get(mk, 0.0) + c - per_month_tok[mk] = per_month_tok.get(mk, 0) + t - except OSError: - return - if is_agent: - agent_acc[0] += file_cost - agent_acc[1] += 1 - - -def build(): - per_month = {} - per_month_tok = {} - agent_acc = [0.0, 0] # [total_cost, invocations] - for path in glob(os.path.join(PROJECTS_DIR, "**", "*.jsonl"), recursive=True): - scan_file(path, per_month, per_month_tok, agent_acc) - - now = datetime.now(timezone.utc) - current_key = f"{now.year:04d}-{now.month:02d}" - current_month = per_month.get(current_key, 0.0) - current_month_tok = per_month_tok.get(current_key, 0) - n_months = len(per_month) - total = sum(per_month.values()) - total_tok = sum(per_month_tok.values()) - avg_month = total / n_months if n_months else 0.0 - avg_month_tok = total_tok / n_months if n_months else 0.0 - agent_total, agent_n = agent_acc - avg_agent = agent_total / agent_n if agent_n else 0.0 - - return { - "generated_at": now.isoformat(), - "current_month": round(current_month, 2), - "avg_month": round(avg_month, 2), - "current_month_tokens": current_month_tok, - "avg_month_tokens": round(avg_month_tok), - "n_months": n_months, - "avg_per_agent": round(avg_agent, 4), - "agent_invocations": agent_n, - "total_all_time": round(total, 2), - } - - -def main(): - result = build() - tmp = CACHE_PATH + ".tmp" - with open(tmp, "w", encoding="utf-8") as fh: - json.dump(result, fh) - os.replace(tmp, CACHE_PATH) - json.dump(result, sys.stdout) - sys.stdout.write("\n") - - -if __name__ == "__main__": - main() From b7c3509630d889c6ef5a46ea7e58d6ce85ac0ea0 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 13:25:22 +0200 Subject: [PATCH 2/7] refactor(statusline): split the renderer into modules, put dir before the dials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer had reached 1022 lines against the 500-line cap (rules/coding-standards.md §4.1). It is now a composition root plus ten modules under statusline-lib/, one concern per file, largest 249 lines: platform.sh BSD/GNU spelling differences (stat, date) palette.sh colour tokens, the heat-track bar fit.sh visible-width measurement and trimming severity.sh the one ok/warn/danger scale and its thresholds format.sh numbers and times as the reader sees them config.sh the two JSON config files gitctx.sh the repository facts session_state.sh cost ledger, transcript telemetry, subagent tracker layout.sh terminal width probe, verbosity preset render.sh one function per status line statusline-command.sh now only wires: it reads the statusLine JSON, asks each collector for its facts, asks each renderer for its line, and emits what fits. Modules resolve relative to the script's own path ($STATUSLINE_LIB overrides), and a missing one exits non-zero naming the file — a silently blank status bar would read as "nothing is happening". The split is behaviour-preserving: verified byte-identical against a 34-configuration preset x width golden render. One deliberate behaviour change rides along, isolated in its own golden diff (only identity lines move): the identity line renders model | dir | effort | thinking rather than putting dir last. Segment order IS priority order — fit_line drops from the tail — so the old order volunteered the working directory as the first thing to lose on a narrow terminal, while the effort and thinking dials, which the reader chose and already knows, survived. Confirmed against the old golden: at 50 columns the previous build rendered "model Opus 5 | effort high | thinking on" with no directory at all. Tests. The static checks had been reading a single file, so every one of them would have passed vacuously on a rule a module was the one breaking. They now sweep the whole source set (SOURCES_UNDER_TEST = the script plus its statusline-lib/*.sh), and five tests are added: the §4.1 size cap, every module loading and defining its exports, the loader's missing-module failure path, and the two identity-order regressions. Each was mutation-checked — cap raised past a padded module, token_color duplicated into a second module, a bare BSD-only stat call added to a module, the loader made to continue silently, and the identity order reverted: all six mutants killed. 22/22 and 35/35 pass, shellcheck clean on the renderer and every module. SC2034/SC2154 are disabled per file with a written rationale: shellcheck analyses one file at a time and cannot follow a sourced-module boundary, so it reads every cross-module constant as unused. Scoped to those two codes, never by excluding a file from the check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- .../statusline/assets/statusline-command.sh | 721 ++++-------------- .../assets/statusline-lib/config.sh | 77 ++ .../statusline/assets/statusline-lib/fit.sh | 125 +++ .../assets/statusline-lib/format.sh | 58 ++ .../assets/statusline-lib/gitctx.sh | 88 +++ .../assets/statusline-lib/layout.sh | 106 +++ .../assets/statusline-lib/palette.sh | 115 +++ .../assets/statusline-lib/platform.sh | 37 + .../assets/statusline-lib/render.sh | 260 +++++++ .../assets/statusline-lib/session_state.sh | 139 ++++ .../assets/statusline-lib/severity.sh | 153 ++++ tests/statusline/measure_widths.sh | 78 ++ tests/statusline/test_fit_and_pace.sh | 450 +++++++++++ tests/statusline/test_heat_rgb.sh | 31 +- 14 files changed, 1840 insertions(+), 598 deletions(-) create mode 100644 plugins/statusline/assets/statusline-lib/config.sh create mode 100644 plugins/statusline/assets/statusline-lib/fit.sh create mode 100644 plugins/statusline/assets/statusline-lib/format.sh create mode 100644 plugins/statusline/assets/statusline-lib/gitctx.sh create mode 100644 plugins/statusline/assets/statusline-lib/layout.sh create mode 100644 plugins/statusline/assets/statusline-lib/palette.sh create mode 100644 plugins/statusline/assets/statusline-lib/platform.sh create mode 100644 plugins/statusline/assets/statusline-lib/render.sh create mode 100644 plugins/statusline/assets/statusline-lib/session_state.sh create mode 100644 plugins/statusline/assets/statusline-lib/severity.sh create mode 100755 tests/statusline/measure_widths.sh create mode 100755 tests/statusline/test_fit_and_pace.sh diff --git a/plugins/statusline/assets/statusline-command.sh b/plugins/statusline/assets/statusline-command.sh index 21439ad..62101dc 100755 --- a/plugins/statusline/assets/statusline-command.sh +++ b/plugins/statusline/assets/statusline-command.sh @@ -1,636 +1,173 @@ #!/usr/bin/env bash -# Claude Code statusLine — zetetic partner view (persistent, two-line) +# shellcheck disable=SC2034 # The facts extracted from the statusLine JSON below +# are consumed by statusline-lib/render.sh, which shellcheck does not follow +# across the source boundary; it therefore reads every one of them as unused. +# The golden render diff and tests/statusline are what prove they are live. +# Claude Code statusLine — zetetic partner view (persistent, multi-line) # -# Line 1: [model] [effort] · dir · git:(branch)✗ · ⎇worktree · PR#n -# (branch falls back to the most-recently-modified sub-repo of cwd, -# shown as git:(branch)@repo, when cwd itself is not a git repo) -# Line 2: ▓▓▓░░░░░░░ ctx:N% tokens:Nk · $cost · ⏱duration · 5h:N% 7d:N% · +adds/-dels -# Line 3: 💰 mois:$Xk (moy $Yk/mo) · agent~$Z/run (aggregated, cached 1h) -# source data: statusline-costs.py over ~/.claude/projects/**/*.jsonl +# This file is the composition root: it loads the modules, reads the statusLine +# JSON, asks each collector for its facts, asks each renderer for its line, and +# emits what fits. It holds no display logic and no thresholds of its own — see +# statusline-lib/ for those, one concern per file: # -# Context/token color tracks the per-model checkpoint thresholds (orchestrator -# rule, shared with stop-context-guard.py via ~/.claude/ctxguard-thresholds.json): -# Fable 5 / Mythos : warn 120K, save 160K (2x rent + 2x cache-expiry penalty) -# Opus 4.x : warn 180K, save 200K (cost discipline; window is 1M) -# Sonnet 4.6 : warn 180K, save 200K (cost discipline; window is 1M) -# Haiku 4.5 : warn 120K, save 170K (200K IS the window; keep headroom) -# < warn green — healthy -# >= warn yellow — getting full, plan a save -# >= save red ⚠ — save memory + start fresh with a recall +# platform.sh BSD/GNU spelling differences (stat, date) +# palette.sh colour tokens, the heat-track bar +# fit.sh visible-width measurement and trimming +# severity.sh the one ok/warn/danger scale and its thresholds +# format.sh numbers and times as the reader sees them +# config.sh the two JSON config files +# gitctx.sh the repository facts +# session_state.sh cost ledger, transcript telemetry, subagent tracker +# layout.sh terminal width probe, verbosity preset +# render.sh one function per status line # -# No DIM attribute anywhere — it renders unreadable on black terminals. -# Secondary text uses light grey; primary uses bright white. - -# --- Colors: AI Architect DS — ink (instrument) surface palette (no DIM) --- -# source: AI Architect Design System tokens/colors.css (:root ink-surface -# primitives), each oklch value converted with CSS Color 4 math (scripted -# oklch->srgb). DS gate G3/G4 (SKILL.md): chrome is GREYSCALE — colour comes -# only from data tokens (heat/stage/valence/tool families) or the single -# terracotta accent, never both, never decoratively. None of this statusline's -# segments (model/dir/worktree/subagent-count/throughput/compaction-count) are -# DS "data families" — they are chrome — so they render in the neutral fg -# scale (TEXT/SUBTEXT/OVERLAY), not in stage/valence hues. Status colours -# (ok/warn/danger) are reserved for actual threshold-driven state. Terracotta -# is used in exactly one place (the effort badge — the one user-selected, -# accent-worthy dial) per G4 ("accent = selection only, never a category"). -# Fixes a prior 1:1 Catppuccin-Mocha->token remap (PR #2) that kept 12 -# competing hues (rainbow chrome) instead of DS's neutral-chrome-plus-one-accent -# hierarchy — see session-optimizer PR "design(statusline): AI Architect DA -# compliance — neutral chrome, single accent, no data-hue borrowing". -RESET="\033[0m" -TEXT="\033[38;2;243;241;238m" # #f3f1ee — primary text · DS --fg-0 oklch(96% 0.005 80) · scripted oklch->srgb -SUBTEXT="\033[38;2;192;189;186m" # #c0bdba — secondary text / labels · DS --fg-1 oklch(80% 0.006 70) · scripted oklch->srgb -OVERLAY="\033[38;2;136;134;130m" # #888682 — separators / muted / decorative icons · DS --fg-2 oklch(62% 0.006 70) · scripted oklch->srgb -GREEN="\033[38;2;101;201;140m" # #65c98c — DS --ok oklch(76% 0.13 155) · scripted oklch->srgb -YELLOW="\033[38;2;232;170;78m" # #e8aa4e — DS --warn oklch(78% 0.13 75) · scripted oklch->srgb -RED="\033[38;2;232;97;84m" # #e86154 — DS --danger oklch(66% 0.17 28) · scripted oklch->srgb -PEACH="\033[38;2;207;110;57m" # #cf6e39 — DS --accent (terracotta) oklch(64% 0.14 47) · scripted oklch->srgb · two legitimate consumers: the effort badge (G4 selection-only accent) AND the heat-track palier 4 (HEAT_4 below, G6 heat-track exception) — never a third, decorative use -# back-compat aliases used below -WHITE="$TEXT"; LGREY="$SUBTEXT" -SEP="${OVERLAY}│${RESET}" - -# Heat-track palette (DS gate G6: "No gradients except the heat track" — -# this bar IS the heat track, so a 4-palier discrete ramp is the compliant -# form; no interpolation). Thresholds HEAT_T2/T3/T4 are named constants, -# format matches the TEXT/SUBTEXT/OVERLAY/PEACH block above: hex + DS token + -# derivation on each line. -HEAT_T2=50 # palier 1->2 boundary (%) -HEAT_T3=75 # palier 2->3 boundary (%) -HEAT_T4=90 # palier 3->4 boundary (%); matches WARN/SAVE ratio for - # sonnet/opus (180000/200000 = 0.90) in ctxguard-thresholds.json - # (see the Checkpoint thresholds block below) — the heat track's - # critical palier starts exactly where the checkpoint hook's own - # hard-cap ratio does, so the visual and the enforcement - # threshold cannot drift apart by design. -HEAT_1="136;134;130" # #888682 — DS --fg-2 oklch(62% 0.006 70) · scripted oklch->srgb · reused from OVERLAY (0-49%) -HEAT_2="192;189;186" # #c0bdba — DS --fg-1 oklch(80% 0.006 70) · scripted oklch->srgb · reused from SUBTEXT (50-74%) -HEAT_3="181;125;98" # #b57d62 — terracotta atténué, source: scripted oklch->srgb, oklch(64% 0.08 47), Ottosson OKLab (75-89%) -HEAT_4="207;110;57" # #cf6e39 — DS --accent (terracotta) oklch(64% 0.14 47) · scripted oklch->srgb · reused from PEACH (90-100%) - -# heat_rgb — pure heat-track color selector. -# pre: $1 is an integer (any sign, any magnitude); non-integer input is a -# contract violation and the function fails (exit 1, no output). -# post: on success, prints exactly one of {HEAT_1, HEAT_2, HEAT_3, HEAT_4} -# as "r;g;b" (never interpolated) and returns 0. Input is clamped to -# [0,100] before palier lookup, so out-of-range integers always -# succeed and resolve to the nearest boundary palier (HEAT_1 or -# HEAT_4). No I/O, no global mutation beyond reading the HEAT_* -# constants above — total function once input is validated numeric. -heat_rgb() { - local p="$1" - case "$p" in - ''|*[!0-9-]*) return 1 ;; # reject empty / non-numeric (incl. "abc") - esac - [ "$p" -lt 0 ] && p=0 - [ "$p" -gt 100 ] && p=100 - if [ "$p" -lt "$HEAT_T2" ]; then printf '%s' "$HEAT_1" - elif [ "$p" -lt "$HEAT_T3" ]; then printf '%s' "$HEAT_2" - elif [ "$p" -lt "$HEAT_T4" ]; then printf '%s' "$HEAT_3" - else printf '%s' "$HEAT_4" - fi -} - -# render a heat-track block bar of $2 cells filled to $1 percent (clamped -# 0..width). Each filled cell is colored by its POSITION along the full -# width via heat_rgb (4 discrete paliers, no interpolation — G6 heat-track -# exception), so a longer (more-full) bar visibly accumulates paliers left -# to right. Empty cells stay muted. The bar is self-coloring: callers must -# NOT wrap the result in a single color (trailing RESET closes it). -make_bar() { - local p="$1" w="$2" filled empty i pos rgb b="" - filled=$(( (p * w + 50) / 100 )) # round to nearest cell - [ "$filled" -gt "$w" ] && filled="$w" - [ "$filled" -lt 0 ] && filled=0 - empty=$(( w - filled )) - i=0 - while [ "$i" -lt "$filled" ]; do - pos=$(( w > 1 ? i * 100 / (w - 1) : 0 )) # cell position 0..100 - rgb=$(heat_rgb "$pos") - b="${b}\033[38;2;${rgb}m█" - i=$(( i + 1 )) - done - [ "$filled" -gt 0 ] && b="${b}${RESET}" - [ "$empty" -gt 0 ] && { printf -v e "%${empty}s"; b="${b}${OVERLAY}${e// /░}${RESET}"; } - printf '%s' "$b" -} - -# pick color for a given token count -token_color() { - local t="$1" - if [ "$t" -ge "$SAVE_TOKENS" ]; then echo "$RED" - elif [ "$t" -ge "$WARN_TOKENS" ]; then echo "$YELLOW" - else echo "$GREEN"; fi -} - -# Test-sourcing guard: allows `source statusline-command.sh` to load the -# constant and function definitions above (heat_rgb, make_bar, token_color, -# HEAT_*, the color palette) without reading stdin or running the rest of -# the script. Set by test harnesses only (tests/statusline/test_heat_rgb.sh); -# unset/0 in normal invocation. Placed after the pure, input-independent -# definitions so tests can source and call them; everything below this line -# depends on stdin ($input) or on values derived from it. +# Every line opens with a word naming its concern, and every value is preceded +# by the word for what it measures. No emoji, no pictographs, no glyph-encoded +# state: a word is unambiguous across terminal fonts and needs no legend. +# Status is carried by colour and number only. +# +# model model NAME | dir NAME | effort LEVEL | thinking on +# branch branch NAME modified | ahead N | behind N | mod N add N del N +# | worktree NAME | pr N approved +# (branch falls back to the most-recently-modified sub-repo of +# cwd, shown as branch NAME@repo, when cwd is not a git repo) +# context ███░░░ N% Nk tokens | session $N | elapsed NmNs | edits +N/-N +# | subagents N Nk tokens +# throughput N tokens/s | idle NmNs | cache warm NmNs | compactions N +# quota 5h ███░░░ N% | pace N.Nx | resets in NhNm +# quota 7d ███░░░ N% | pace N.Nx | resets Day HH:MM +# spend today $N | month $N | average $N/day +# +# Every line is fitted to the terminal: the verbosity preset chooses how many +# lines render (and drops to "s" on a terminal too narrow for the default), and +# fit_line then drops each line's lowest-priority trailing segments until it +# fits. Lines are built most-important-first, so the tail is what goes. Override +# the probed width with $STATUSLINE_COLS and the preset with $STATUSLINE_SIZE. +# +# "pace" is the burn rate against the window's own clock: used% divided by the +# share of the window elapsed, which is also the linear projection of usage at +# reset. 1.0x projects landing exactly on the cap. The percentage carries the +# worse of the absolute and pace readings; the pace figure carries its own. +# +# Every dollar figure on this statusline comes from ONE ledger, +# ~/.claude/costs.sh over ~/.claude/statusline-costs.jsonl, and every one of +# them includes subagent spend (Task, worktree-isolated, and workflow agents). +# +# Context/token colour tracks the per-model checkpoint thresholds shared with +# stop-context-guard.py via ~/.claude/ctxguard-thresholds.json (see config.sh): +# green below the warn threshold, yellow at it, red at the save threshold. + +# --- Modules --------------------------------------------------------------- +# Resolved relative to this file, so the renderer works from a plugin directory, +# a checkout or ~/.claude without a hardcoded path. $STATUSLINE_LIB overrides it +# (test harnesses pointing at a working copy). +# Load order is dependency order: platform and palette own no dependencies, +# everything else builds on them. +STATUSLINE_LIB="${STATUSLINE_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/statusline-lib}" +for _mod in platform palette fit severity format config gitctx session_state layout render; do + if [ -r "${STATUSLINE_LIB}/${_mod}.sh" ]; then + # shellcheck source=/dev/null + . "${STATUSLINE_LIB}/${_mod}.sh" + else + # A missing module cannot be rendered around: without palette.sh there are + # no colours to print an error in, and without render.sh there is nothing to + # print. Say which file is missing, on the statusline itself — a silently + # blank status bar would read as "nothing is happening". + printf 'statusline: cannot read %s\n' "${STATUSLINE_LIB}/${_mod}.sh" >&2 + printf 'statusline: module %s.sh missing from %s\n' "$_mod" "$STATUSLINE_LIB" + exit 1 + fi +done + +# Test-sourcing guard: allows `source statusline-command.sh` to load every +# module above (the pure functions and constants: heat_rgb, make_bar, +# token_color, vislen, fit_line, quota_reading, file_mtime, the palette …) +# without reading stdin or rendering. Set by test harnesses only; unset/0 in +# normal invocation. Everything below this line depends on stdin ($input) or on +# values derived from it. [ "${STATUSLINE_SOURCE_ONLY:-0}" = "1" ] && return 0 2>/dev/null input=$(cat) j() { echo "$input" | jq -r "$1"; } +now_epoch=$(date +%s) -# --- Core --- +# --- Facts from the statusLine JSON --- model=$(j '.model.display_name // ""') effort=$(j '.effort.level // ""') thinking=$(j '.thinking.enabled // false') used_pct=$(j '.context_window.used_percentage // empty') in_tokens=$(j '.context_window.total_input_tokens // empty') transcript_path=$(j '.transcript_path // empty') +session_id=$(j '.session_id // empty') -# --- Checkpoint thresholds (tokens of input context) --- -# Single source of truth: ~/.claude/ctxguard-thresholds.json (shared with the -# stop-context-guard.py hook so passive display and active enforcement cannot -# drift). The case block below is the embedded fallback and MUST mirror the -# hook's FALLBACK_THRESHOLDS table. First substring match wins. -# WARN = checkpoint threshold (save now); SAVE = soft cap (save + recall, fresh session). -CTXGUARD_CONFIG="${HOME}/.claude/ctxguard-thresholds.json" -model_lc=$(printf '%s' "$model" | tr '[:upper:]' '[:lower:]') - -WARN_TOKENS="" -SAVE_TOKENS="" -if [ -r "$CTXGUARD_CONFIG" ]; then - # First entry whose .match is a substring of the lowercased display name; - # falls back to .default. Emits "warn save" or nothing on any jq failure. - read -r WARN_TOKENS SAVE_TOKENS < <( - jq -r --arg m "$model_lc" ' - ( [ .models[]? | select(.match != null) - | select(.match as $p | $m | contains($p)) ][0] - // .default // empty ) - | select(.warn != null and .hard != null) - | "\(.warn) \(.hard)" - ' "$CTXGUARD_CONFIG" 2>/dev/null - ) || true -fi -case "$WARN_TOKENS" in ''|*[!0-9]*) WARN_TOKENS="" ;; esac -case "$SAVE_TOKENS" in ''|*[!0-9]*) SAVE_TOKENS="" ;; esac - -if [ -z "$WARN_TOKENS" ] || [ -z "$SAVE_TOKENS" ]; then - case "$model_lc" in - *fable*|*mythos*) WARN_TOKENS=120000; SAVE_TOKENS=160000 ;; - *haiku*) WARN_TOKENS=120000; SAVE_TOKENS=170000 ;; - *) WARN_TOKENS=180000; SAVE_TOKENS=200000 ;; - esac -fi - -# --- Cost / duration / churn --- cost=$(j '.cost.total_cost_usd // empty') dur_ms=$(j '.cost.total_duration_ms // empty') adds=$(j '.cost.total_lines_added // 0') dels=$(j '.cost.total_lines_removed // 0') -# --- Rate limits (Pro/Max only) --- +# Rate limits (Pro/Max only) rl_5h=$(j '.rate_limits.five_hour.used_percentage // empty') rl_7d=$(j '.rate_limits.seven_day.used_percentage // empty') rl_5h_at=$(j '.rate_limits.five_hour.resets_at // empty') rl_7d_at=$(j '.rate_limits.seven_day.resets_at // empty') -# --- Subagent spend (from the SubagentStop tracker; not in the statusline -# input, so read the per-session aggregate the hook maintains) --- -session_id=$(j '.session_id // empty') -sub_count="" -sub_tokens="" -sub_cost="" -if [ -n "$session_id" ]; then - SUB_STATE="/tmp/zetetic-subagents-${session_id}.json" - if [ -r "$SUB_STATE" ]; then - read -r sub_count sub_tokens sub_cost < <( - jq -r '.totals as $t - | "\($t.count // 0) " - + "\(((($t.input_tokens // 0) + ($t.output_tokens // 0) + ($t.cache_tokens // 0)))) " - + "\($t.cost_usd // 0)"' "$SUB_STATE" 2>/dev/null - ) || true - case "$sub_count" in ''|0|*[!0-9]*) sub_count="" ;; esac - fi -fi - -# --- PR / worktree --- pr_num=$(j '.pr.number // empty') pr_state=$(j '.pr.review_state // empty') wt_name=$(j '.worktree.name // .workspace.git_worktree // empty') -# --- Git context --- cwd=$(j '.workspace.current_dir // .cwd // ""') dir=$(basename "$cwd") -git_branch="" -git_dirty="" -git_repo="" # set to the sub-repo name when the branch is resolved by fallback -git_root="" # resolved repo root (cwd, or the fallback sub-repo) -# git extras, filled by compute_git_extra below -git_ahead=""; git_behind=""; git_conf=0; nM=0; nA=0; nD=0; nU=0 -branch_of() { # echo "branch dirty" for repo root $1 - local root="$1" b d="" - b=$(git -C "$root" -c core.useBuiltinFSMonitor=false symbolic-ref --short HEAD 2>/dev/null \ - || git -C "$root" -c core.useBuiltinFSMonitor=false rev-parse --short HEAD 2>/dev/null) - if ! git -C "$root" -c core.useBuiltinFSMonitor=false diff --quiet 2>/dev/null \ - || ! git -C "$root" -c core.useBuiltinFSMonitor=false diff --cached --quiet 2>/dev/null; then - d="✗" - fi - printf '%s\t%s' "$b" "$d" -} - -# Fill ahead/behind vs upstream and a porcelain file-stat breakdown for repo $1. -# Pure git; one rev-list + one status call. Sets the git_* / n* globals above. -compute_git_extra() { - local root="$1" lr line xy - # shellcheck disable=SC1083 # @{u} is git revision syntax (upstream), not a brace expansion - lr=$(git -C "$root" -c core.useBuiltinFSMonitor=false \ - rev-list --left-right --count HEAD...@{u} 2>/dev/null) - if [ -n "$lr" ]; then - git_ahead=$(printf '%s' "$lr" | awk '{print $1}') - git_behind=$(printf '%s' "$lr" | awk '{print $2}') - fi - while IFS= read -r line; do - [ -z "$line" ] && continue - xy="${line:0:2}" - case "$xy" in - '??') nU=$((nU+1)) ;; - U*|?U|AA|DD) git_conf=$((git_conf+1)) ;; # unmerged / conflict - *) - case "$xy" in *M*) nM=$((nM+1)) ;; esac - case "$xy" in A*) nA=$((nA+1)) ;; esac - case "$xy" in *D*) nD=$((nD+1)) ;; esac - ;; - esac - done < <(git -C "$root" -c core.useBuiltinFSMonitor=false status --porcelain 2>/dev/null) -} - -if git -C "$cwd" rev-parse --git-dir > /dev/null 2>&1; then - IFS=$'\t' read -r git_branch git_dirty < <(branch_of "$cwd") - git_root="$cwd" -else - # cwd is not a repo (e.g. a workspace root): show the branch of the sub-repo - # under cwd whose .git was touched most recently. Bounded to depth 2 for speed. - newest_git=$(find "$cwd" -maxdepth 2 -name .git -type d -prune 2>/dev/null \ - | while IFS= read -r g; do - printf '%s %s\n' "$(stat -f '%m' "$g" 2>/dev/null || echo 0)" "${g%/.git}" - done | sort -rn | head -1 | cut -d' ' -f2-) - if [ -n "$newest_git" ]; then - IFS=$'\t' read -r git_branch git_dirty < <(branch_of "$newest_git") - git_repo=$(basename "$newest_git") - git_root="$newest_git" - fi -fi -[ -n "$git_root" ] && compute_git_extra "$git_root" - -# --- Aggregated costs (cached; slow scan runs in background on a TTL) --- -COST_CACHE="${HOME}/.claude/.statusline-cost-cache.json" -COST_SCRIPT="${HOME}/.claude/statusline-costs.py" -COST_LOCK="${HOME}/.claude/.statusline-cost.lock" -COST_TTL=3600 # refresh the aggregate at most once an hour -COST_LOCK_TTL=180 # a scan in flight must finish (or go stale) before respawn -now_epoch=$(date +%s) - -cache_age=99999 -[ -r "$COST_CACHE" ] && cache_age=$(( now_epoch - $(stat -f '%m' "$COST_CACHE" 2>/dev/null || echo 0) )) -if [ "$cache_age" -ge "$COST_TTL" ] && [ -r "$COST_SCRIPT" ]; then - lock_age=99999 - [ -f "$COST_LOCK" ] && lock_age=$(( now_epoch - $(stat -f '%m' "$COST_LOCK" 2>/dev/null || echo 0) )) - if [ "$lock_age" -ge "$COST_LOCK_TTL" ]; then - ( touch "$COST_LOCK"; python3 "$COST_SCRIPT" >/dev/null 2>&1; rm -f "$COST_LOCK" ) & - fi -fi - -cost_cur=""; cost_avg=""; cost_agent=""; tok_cur="" -if [ -r "$COST_CACHE" ]; then - # shellcheck disable=SC2034 # tok_cur is a field sink: it absorbs the 4th jq - # field so cost_agent cannot swallow the rest of the line via read's remainder rule - read -r cost_cur cost_avg cost_agent tok_cur < <( - jq -r '"\(.current_month // "") \(.avg_month // "") \(.avg_per_agent // "") \(.current_month_tokens // "")"' "$COST_CACHE" 2>/dev/null - ) || true -fi - -# --- Per-session transcript telemetry (tok/s, compactions, last-response age, -# prompt-cache TTL). Same backgrounded-cache pattern as the cost scan but on a -# short TTL: the script reads only the transcript tail + appended bytes, so it is -# cheap, and time-relative values (age, TTL) are recomputed live in bash from the -# cached last_ts so the countdown stays second-accurate between refreshes. --- -TXT_CACHE="${HOME}/.claude/.statusline-transcript-cache.json" -TXT_SCRIPT="${HOME}/.claude/statusline-transcript.py" -TXT_LOCK="${HOME}/.claude/.statusline-transcript.lock" -TXT_TTL=15 # refresh telemetry at most ~once per refresh-and-a-half -TXT_LOCK_TTL=60 -if [ -n "$transcript_path" ] && [ -r "$TXT_SCRIPT" ]; then - txt_age=99999 - [ -r "$TXT_CACHE" ] && txt_age=$(( now_epoch - $(stat -f '%m' "$TXT_CACHE" 2>/dev/null || echo 0) )) - if [ "$txt_age" -ge "$TXT_TTL" ]; then - txt_lock_age=99999 - [ -f "$TXT_LOCK" ] && txt_lock_age=$(( now_epoch - $(stat -f '%m' "$TXT_LOCK" 2>/dev/null || echo 0) )) - if [ "$txt_lock_age" -ge "$TXT_LOCK_TTL" ]; then - ( touch "$TXT_LOCK"; python3 "$TXT_SCRIPT" "$transcript_path" >/dev/null 2>&1; rm -f "$TXT_LOCK" ) & - fi - fi -fi - -txt_path=""; txt_last_ts=""; txt_tok_s=""; txt_compactions="" -if [ -r "$TXT_CACHE" ]; then - read -r txt_path txt_last_ts txt_tok_s txt_compactions < <( - jq -r '"\(.path // "") \(.last_ts // "") \(.tok_per_s // "") \(.compactions // "")"' "$TXT_CACHE" 2>/dev/null - ) || true - # Only trust the cache if it belongs to THIS session's transcript. - [ "$txt_path" != "$transcript_path" ] && { txt_last_ts=""; txt_tok_s=""; txt_compactions=""; } -fi - -# --- Monthly targets (per-person accountability), configurable --- -BUDGET_CONFIG="${HOME}/.claude/statusline-budget.json" -cache_ttl_min=5 # prompt-cache lifetime: 5 (Pro default) | 60 (Max). Source: - # docs.anthropic.com/.../prompt-caching — 5-minute default TTL. -if [ -r "$BUDGET_CONFIG" ]; then - read -r b_ttl < <( - jq -r '"\(.cache_ttl_min // 5)"' "$BUDGET_CONFIG" 2>/dev/null - ) || true - case "$b_ttl" in ''|*[!0-9]*) ;; *) cache_ttl_min="$b_ttl" ;; esac -fi - -# --- Verbosity preset (xs|s|m|l|xl) ----------------------------------------- -# Controls how many lines render and how wide the bars are. Resolution order: -# 1. $STATUSLINE_SIZE env 2. .size in statusline-budget.json 3. default "l". -# Tiers are monotonic — each larger size is a superset of the smaller one: -# xs 1 line : identity + context bar (CTX 6) -# s 2 lines: + git, session (tokens + cost) (CTX 8) -# m 3 lines: + rate limits + churn (CTX 10) -# l 5 lines: + $ & token budget gauges + resets (CTX 10, BW 12) [default] -# xl 5 lines: everything, widest bars + avg/mo (CTX 16, BW 20) -SIZE="${STATUSLINE_SIZE:-}" -if [ -z "$SIZE" ] && [ -r "$BUDGET_CONFIG" ]; then - SIZE=$(jq -r '.size // empty' "$BUDGET_CONFIG" 2>/dev/null) || SIZE="" -fi -case "$SIZE" in xs|s|m|l|xl) ;; *) SIZE="l" ;; esac -case "$SIZE" in - xs) RANK=0; CTX_W=6; BW=10 ;; - s) RANK=1; CTX_W=8; BW=10 ;; - m) RANK=2; CTX_W=10; BW=12 ;; - l) RANK=3; CTX_W=10; BW=12 ;; - xl) RANK=4; CTX_W=16; BW=20 ;; -esac - -fmt_tokens() { - local t="$1" - if [ "$t" -ge 1000000000 ]; then LC_NUMERIC=C awk "BEGIN{printf \"%.1fG\",$t/1000000000}" - elif [ "$t" -ge 1000000 ]; then LC_NUMERIC=C awk "BEGIN{printf \"%.1fM\",$t/1000000}" - elif [ "$t" -ge 1000 ]; then LC_NUMERIC=C awk "BEGIN{printf \"%.0fk\",$t/1000}" - else echo "$t"; fi -} - -# --- Rate-limit reset times ----------------------------------------------- -# .rate_limits.*.resets_at is an epoch in SECONDS (verified against both the -# large and xlarge variants of github.com/AwesomeJun/CC-statusline, which -# subtract it from `date +%s` directly). Guard: only treat all-digit values as -# an epoch so a future ISO string can never crash the arithmetic. -# Cross-platform date: BSD/macOS `date -j -f %s` | `date -r` | GNU `date -d @`. -_date_fmt() { - local epoch="$1" fmt="$2" out="" - out=$(date -j -f "%s" "$epoch" "+$fmt" 2>/dev/null) && [ -n "$out" ] && { echo "$out"; return; } - out=$(date -r "$epoch" "+$fmt" 2>/dev/null) && [ -n "$out" ] && { echo "$out"; return; } - date -d "@$epoch" "+$fmt" 2>/dev/null -} -# 5h window: "Xh Ym" remaining until reset -fmt_reset_in() { - local e="$1" - case "$e" in ''|*[!0-9]*) return ;; esac - local rem=$(( e - $(date +%s) )); [ "$rem" -lt 0 ] && rem=0 - printf '%dh%dm' "$(( rem / 3600 ))" "$(( (rem % 3600) / 60 ))" -} - -# compact duration: "Xh Ym" / "Xm Ys" / "Xs" from a seconds count -fmt_dur() { - local s="$1" - case "$s" in ''|*[!0-9]*) s=0 ;; esac - if [ "$s" -ge 3600 ]; then printf '%dh%dm' "$(( s / 3600 ))" "$(( (s % 3600) / 60 ))" - elif [ "$s" -ge 60 ]; then printf '%dm%ds' "$(( s / 60 ))" "$(( s % 60 ))" - else printf '%ds' "$s"; fi -} - -# 7d window: "Wed 14:00" wall-clock of reset -fmt_reset_at() { - local e="$1" - case "$e" in ''|*[!0-9]*) return ;; esac - LC_TIME=C _date_fmt "$e" "%a %H:%M" -} - -# Lines are grouped one concern per line (identity / git / session / $ target / -# token target). Empty groups are skipped at emit time, so the statusline grows -# and shrinks with what is actually present instead of cramming everything. - -# ========================================================================= -# LINE: identity — model, effort, thinking, directory -# ========================================================================= -l_id="${OVERLAY}🤖 ${WHITE}${model}${RESET}" -if [ -n "$effort" ]; then - # sole accent usage (DS G4): effort is the one user-selected dial per session - l_id="${l_id} ${SEP} ${PEACH}⚡ ${effort}${RESET}" - [ "$thinking" = "true" ] && l_id="${l_id} ${SUBTEXT}💡${RESET}" -fi -l_id="${l_id} ${SEP} ${OVERLAY}📂 ${TEXT}${dir}${RESET}" - -# ========================================================================= -# LINE: git — branch (+dirty +fallback repo), worktree, PR -# ========================================================================= -l_git="" -if [ -n "$git_branch" ]; then - dirty_part="" - [ -n "$git_dirty" ] && dirty_part=" ${RED}${git_dirty}${RESET}" - repo_part="" - [ -n "$git_repo" ] && repo_part="${OVERLAY}@${git_repo}${RESET}" - l_git="${GREEN}🌿 ${WHITE}${git_branch}${RESET}${dirty_part}${repo_part}" - - # ahead/behind vs upstream + conflicts (s+). Only nonzero counts render. - if [ "$RANK" -ge 1 ]; then - { [ -n "$git_ahead" ] && [ "$git_ahead" -gt 0 ]; } && l_git="${l_git} ${GREEN}↑${git_ahead}${RESET}" - { [ -n "$git_behind" ] && [ "$git_behind" -gt 0 ]; } && l_git="${l_git} ${YELLOW}↓${git_behind}${RESET}" - [ "$git_conf" -gt 0 ] && l_git="${l_git} ${RED}⚠${git_conf}${RESET}" - fi - # file-stat breakdown (m+): !modified +added ✘deleted ?untracked, nonzero only. - if [ "$RANK" -ge 2 ]; then - fs="" - [ "$nM" -gt 0 ] && fs="${fs:+$fs }${YELLOW}!${nM}${RESET}" - [ "$nA" -gt 0 ] && fs="${fs:+$fs }${GREEN}+${nA}${RESET}" - [ "$nD" -gt 0 ] && fs="${fs:+$fs }${RED}✘${nD}${RESET}" - [ "$nU" -gt 0 ] && fs="${fs:+$fs }${OVERLAY}?${nU}${RESET}" - [ -n "$fs" ] && l_git="${l_git} ${SEP} ${fs}" - fi -fi -if [ -n "$wt_name" ]; then - l_git="${l_git:+$l_git ${SEP} }${OVERLAY}⎇ ${TEXT}${wt_name}${RESET}" -fi -if [ -n "$pr_num" ]; then - pr_color="$SUBTEXT" - case "$pr_state" in - approved) pr_color="$GREEN" ;; - changes_requested) pr_color="$RED" ;; - pending) pr_color="$YELLOW" ;; - esac - l_git="${l_git:+$l_git ${SEP} }${pr_color}🔀 PR#${pr_num}${RESET}" -fi - -# ========================================================================= -# LINE: session — context bar, tokens, cost, duration, rate limits, churn -# ========================================================================= -line2="" - -if [ -n "$used_pct" ] || [ -n "$in_tokens" ]; then - # color by absolute token count against the save threshold (works for 1M window) - tok="${in_tokens:-0}" - cc=$(token_color "$tok") - - if [ -n "$used_pct" ]; then - pct_int=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$used_pct}") - bar=$(make_bar "$pct_int" "$CTX_W") - line2="${SUBTEXT}🧠 ${RESET}${bar} ${cc}${pct_int}%${RESET}" - fi - - if [ -n "$in_tokens" ] && [ "$RANK" -ge 1 ]; then - line2="${line2:+$line2 }${cc}$(fmt_tokens "$in_tokens") tok${RESET}" - # explicit checkpoint hint once past the save threshold - [ "$in_tokens" -ge "$SAVE_TOKENS" ] && line2="${line2} ${RED}⚠ save+recall${RESET}" - fi -fi - -DOLLAR='$' -if [ -n "$cost" ] && [ "$RANK" -ge 1 ]; then - cost_fmt=$(LC_NUMERIC=C awk "BEGIN{printf \"%.2f\",$cost}") - # informational readout, no threshold behind it — neutral chrome, not warn - line2="${line2:+$line2 ${SEP} }${SUBTEXT}💰 ${DOLLAR}${cost_fmt}${RESET}" -fi - -if [ -n "$dur_ms" ] && [ "$RANK" -ge 2 ]; then - dur_s=$((dur_ms / 1000)); mins=$((dur_s / 60)); secs=$((dur_s % 60)) - line2="${line2:+$line2 ${SEP} }${SUBTEXT}⏱ ${mins}m${secs}s${RESET}" -fi - -# Inline rate limits only at the m preset; l/xl render full quota bars below. -if { [ -n "$rl_5h" ] || [ -n "$rl_7d" ]; } && [ "$RANK" -eq 2 ]; then - rl_seg="" - if [ -n "$rl_5h" ]; then - v=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$rl_5h}") - if [ "$v" -ge 80 ]; then c="$RED"; elif [ "$v" -ge 50 ]; then c="$YELLOW"; else c="$SUBTEXT"; fi - rp=""; [ "$RANK" -ge 3 ] && r=$(fmt_reset_in "$rl_5h_at") && [ -n "$r" ] && rp=" ${OVERLAY}↻${r}${RESET}" - rl_seg="${c}🚀 5h ${v}%${RESET}${rp}" - fi - if [ -n "$rl_7d" ]; then - v=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$rl_7d}") - if [ "$v" -ge 80 ]; then c="$RED"; elif [ "$v" -ge 50 ]; then c="$YELLOW"; else c="$SUBTEXT"; fi - rp=""; [ "$RANK" -ge 3 ] && r=$(fmt_reset_at "$rl_7d_at") && [ -n "$r" ] && rp=" ${OVERLAY}↻${r}${RESET}" - rl_seg="${rl_seg:+$rl_seg }${c}🌟 7d ${v}%${RESET}${rp}" - fi - line2="${line2:+$line2 ${SEP} }${rl_seg}" -fi - -if { [ "$adds" -gt 0 ] || [ "$dels" -gt 0 ]; } && [ "$RANK" -ge 2 ]; then - line2="${line2:+$line2 ${SEP} }${SUBTEXT}✏️ ${GREEN}+${adds}${OVERLAY}/${RED}-${dels}${RESET}" -fi - -# Subagents: count · tokens · cost — surfaces work the statusline input omits. -if [ -n "$sub_count" ]; then - sub_seg="${OVERLAY}🤖${sub_count}${RESET}" - if [ -n "$sub_tokens" ] && [ "$sub_tokens" -gt 0 ] 2>/dev/null; then - sub_seg="${sub_seg} ${LGREY}$(fmt_tokens "$sub_tokens")${RESET}" - fi - if [ -n "$sub_cost" ]; then - sub_cost_fmt=$(LC_NUMERIC=C awk "BEGIN{printf \"%.2f\",$sub_cost}" 2>/dev/null) - [ -n "$sub_cost_fmt" ] && sub_seg="${sub_seg} ${SUBTEXT}${DOLLAR}${sub_cost_fmt}${RESET}" - fi - line2="${line2:+$line2 ${SEP} }${sub_seg}" -fi - -# ========================================================================= -# LINE: telemetry — turn throughput, last-response age, compactions, cache TTL -# (from the backgrounded transcript scan; age + TTL recomputed live here). -# ========================================================================= -l_tele="" -if [ "$RANK" -ge 2 ]; then - # turn throughput (tok/s) — wall-clock, includes tool latency (lower bound) - if [ -n "$txt_tok_s" ] && [ "$txt_tok_s" != "null" ]; then - ts_int=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$txt_tok_s}") - [ "$ts_int" -gt 0 ] && l_tele="${SUBTEXT}⚡ ${ts_int} t/s${RESET}" - fi +# --- Facts from everywhere else --- +resolve_ctx_thresholds "$model" # -> WARN_TOKENS SAVE_TOKENS +cache_ttl_min=$(read_cache_ttl_min) +collect_git "$cwd" # -> git_* n* +read_cost_ledger "$session_id" "$input" # -> cost_session cost_today cost_month cost_avg_day +read_transcript_telemetry "$transcript_path" "$now_epoch" # -> txt_* +read_subagent_totals "$session_id" # -> sub_count sub_tokens - # last-response age + prompt-cache TTL countdown, both from last_ts - if [ -n "$txt_last_ts" ] && [ "$txt_last_ts" != "null" ]; then - age_s=$(LC_NUMERIC=C awk "BEGIN{a=$now_epoch-$txt_last_ts; printf \"%d\", (a>0)?a:0}") - l_tele="${l_tele:+$l_tele ${SEP} }${SUBTEXT}🕑 $(fmt_dur "$age_s")${RESET}" +COLS=$(probe_cols) +FIT_W=$(( COLS * FIT_RATIO / 100 )) +resolve_preset "$COLS" # -> SIZE RANK CTX_W BW - # cache warm until last_ts + cache_ttl_min; show remaining, red when cold. - ttl_s=$(( cache_ttl_min * 60 )) - rem_s=$(( ttl_s - age_s )) - if [ "$rem_s" -gt 0 ]; then - cc="$GREEN"; [ "$rem_s" -lt 60 ] && cc="$YELLOW" - l_tele="${l_tele:+$l_tele ${SEP} }${cc}❄ $(fmt_dur "$rem_s")${RESET}" - else - l_tele="${l_tele:+$l_tele ${SEP} }${RED}❄ cold${RESET}" - fi - fi - - # context compactions this session (only when any have happened) - if [ -n "$txt_compactions" ] && [ "$txt_compactions" != "null" ] && [ "$txt_compactions" -gt 0 ] 2>/dev/null; then - l_tele="${l_tele:+$l_tele ${SEP} }${OVERLAY}🗜 ${txt_compactions}${RESET}" - fi -fi - -# ========================================================================= -# LINE: quota — Pro/Max rate-limit windows: the REAL "don't overshoot" cap. -# .used_percentage is already a ratio of the quota the account is bound to, -# so 100% = the cap (lockout). 5h = burst window, 7d = sustained window. -# This replaces the old arbitrary $/token monthly budgets: on a flat-rate -# Pro/Max plan the binding constraint is the quota, not an absolute spend. -# LINE: cost reference — informational $/month + $/run (NOT a cap), kept on -# the user's request alongside the quota gauges. -# ========================================================================= -fmt_usd() { - local v="$1" - LC_NUMERIC=C awk "BEGIN{ if($v>=1000) printf \"\$%.1fk\",$v/1000; else printf \"\$%.2f\",$v }" +# --- Emit (%b interprets ANSI escapes; data is in args, not the format) --- +# One concern per line; empty lines are skipped so the block stays compact. +# Line count grows with the verbosity preset (RANK): xs collapses identity + +# context onto a single line; git appears at s+; quota and spend at l+. +# +# Every line goes through fit_line, which drops its lowest-priority trailing +# segments when the terminal is too narrow for the whole line. The preset is the +# coarse adjustment (how many LINES) and this is the fine one (how many SEGMENTS +# per line) — the preset cannot know how long this session's branch name or +# directory happens to be. +emit() { + local fitted + fitted=$(fit_line "$1" "$FIT_W") + printf '%b\n' "$fitted" } -# color a quota ratio: green < 50, yellow 50–79, red >= 80 (warn before lockout) -quota_color() { - local p="$1" - if [ "$p" -ge 80 ]; then echo "$RED" - elif [ "$p" -ge 50 ]; then echo "$YELLOW" - else echo "$GREEN"; fi -} +l_id=$(render_identity) +l_session=$(render_session) -# BW (bar width) is set by the verbosity preset above. -l_quota5h="" -if [ -n "$rl_5h" ]; then - v=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$rl_5h}") - qc=$(quota_color "$v"); qb=$(make_bar "$v" "$BW") - qover=""; [ "$v" -ge 100 ] && qover="${RED} ⚠${RESET}" - rp=""; r=$(fmt_reset_in "$rl_5h_at") && [ -n "$r" ] && rp=" ${OVERLAY}↻${r}${RESET}" - l_quota5h="${OVERLAY}🎯 🚀 5h${RESET} ${qb} ${qc}${v}%${RESET}${rp}${qover}" +if [ "$RANK" -le 0 ]; then + emit "${l_id}${l_session:+ ${SEP} ${l_session}}" + exit 0 fi -l_quota7d="" -if [ -n "$rl_7d" ]; then - v=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$rl_7d}") - qc=$(quota_color "$v"); qb=$(make_bar "$v" "$BW") - qover=""; [ "$v" -ge 100 ] && qover="${RED} ⚠${RESET}" - rp=""; r=$(fmt_reset_at "$rl_7d_at") && [ -n "$r" ] && rp=" ${OVERLAY}↻${r}${RESET}" - l_quota7d="${OVERLAY}🎯 🌟 7d${RESET} ${qb} ${qc}${v}%${RESET}${rp}${qover}" -fi +l_git=$(render_git) +l_tele=$(render_telemetry) -l_costref="" -[ -n "$cost_cur" ] && l_costref="${SUBTEXT}💰 $(fmt_usd "$cost_cur") ce mois${RESET}" -[ -n "$cost_agent" ] && l_costref="${l_costref:+$l_costref ${SEP} }${OVERLAY}🤖 ${SUBTEXT}$(fmt_usd "$cost_agent")${OVERLAY}/run${RESET}" -[ "$RANK" -ge 4 ] && [ -n "$cost_avg" ] && l_costref="${l_costref:+$l_costref ${SEP} }${OVERLAY}(moy $(fmt_usd "$cost_avg")/mo)${RESET}" +emit "$l_id" +[ -n "$l_git" ] && emit "$l_git" +[ -n "$l_session" ] && emit "$l_session" +[ -n "$l_tele" ] && emit "$l_tele" -# --- Emit (%b interprets ANSI escapes; data is in args, not the format) --- -# One concern per line; empty groups are skipped so the block stays compact. -# Line count grows with the verbosity preset (RANK): xs collapses identity + -# context onto a single line; git appears at s+; budget gauges at l+. -if [ "$RANK" -le 0 ]; then - printf '%b\n' "${l_id}${line2:+ ${SEP} ${line2}}" - exit 0 +if [ "$RANK" -ge 3 ]; then + l_quota5h=$(render_quota "5h" "$rl_5h" "$rl_5h_at" "$RL_5H_WINDOW_S") + l_quota7d=$(render_quota "7d" "$rl_7d" "$rl_7d_at" "$RL_7D_WINDOW_S") + l_spend=$(render_spend) + [ -n "$l_quota5h" ] && emit "$l_quota5h" + [ -n "$l_quota7d" ] && emit "$l_quota7d" + [ -n "$l_spend" ] && emit "$l_spend" fi -printf '%b\n' "$l_id" -[ -n "$l_git" ] && printf '%b\n' "$l_git" -[ -n "$line2" ] && printf '%b\n' "$line2" -[ -n "$l_tele" ] && printf '%b\n' "$l_tele" -[ "$RANK" -ge 3 ] && [ -n "$l_quota5h" ] && printf '%b\n' "$l_quota5h" -[ "$RANK" -ge 3 ] && [ -n "$l_quota7d" ] && printf '%b\n' "$l_quota7d" -[ "$RANK" -ge 3 ] && [ -n "$l_costref" ] && printf '%b\n' "$l_costref" exit 0 diff --git a/plugins/statusline/assets/statusline-lib/config.sh b/plugins/statusline/assets/statusline-lib/config.sh new file mode 100644 index 0000000..8ae8896 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/config.sh @@ -0,0 +1,77 @@ +# shellcheck shell=bash +# statusline-lib/config.sh — the two JSON files this renderer reads. +# +# Single responsibility: knowing the on-disk config schema. Changes when a +# config file's shape changes. Each reader validates what it read and falls back +# to a documented default, so a missing, unreadable or malformed config degrades +# to the built-in behaviour rather than rendering a wrong number. +# +# Depends on: nothing (jq). + +# Single source of truth for the checkpoint thresholds, shared with the +# stop-context-guard.py hook so passive display and active enforcement cannot +# drift. +CTXGUARD_CONFIG="${HOME}/.claude/ctxguard-thresholds.json" +BUDGET_CONFIG="${HOME}/.claude/statusline-budget.json" + +# resolve_ctx_thresholds — context-window thresholds for the model named $1. +# pre: $1 the model's display name (any case). +# post: sets WARN_TOKENS and SAVE_TOKENS to positive integers, always. Returns 0. +# WARN = checkpoint threshold (save now); SAVE = soft cap (save + recall, +# fresh session). +# The case block is the embedded fallback and MUST mirror the hook's +# FALLBACK_THRESHOLDS table: +# Fable 5 / Mythos : warn 120K, save 160K (2x rent + 2x cache-expiry penalty) +# Opus 4.x : warn 180K, save 200K (cost discipline; window is 1M) +# Sonnet 4.6 : warn 180K, save 200K (cost discipline; window is 1M) +# Haiku 4.5 : warn 120K, save 170K (200K IS the window; keep headroom) +resolve_ctx_thresholds() { + local model_lc + model_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') + + WARN_TOKENS="" + SAVE_TOKENS="" + if [ -r "$CTXGUARD_CONFIG" ]; then + # First entry whose .match is a substring of the lowercased display name; + # falls back to .default. Emits "warn save" or nothing on any jq failure. + read -r WARN_TOKENS SAVE_TOKENS < <( + jq -r --arg m "$model_lc" ' + ( [ .models[]? | select(.match != null) + | select(.match as $p | $m | contains($p)) ][0] + // .default // empty ) + | select(.warn != null and .hard != null) + | "\(.warn) \(.hard)" + ' "$CTXGUARD_CONFIG" 2>/dev/null + ) || true + fi + case "$WARN_TOKENS" in ''|*[!0-9]*) WARN_TOKENS="" ;; esac + case "$SAVE_TOKENS" in ''|*[!0-9]*) SAVE_TOKENS="" ;; esac + + if [ -z "$WARN_TOKENS" ] || [ -z "$SAVE_TOKENS" ]; then + case "$model_lc" in + *fable*|*mythos*) WARN_TOKENS=120000; SAVE_TOKENS=160000 ;; + *haiku*) WARN_TOKENS=120000; SAVE_TOKENS=170000 ;; + *) WARN_TOKENS=180000; SAVE_TOKENS=200000 ;; + esac + fi +} + +# read_cache_ttl_min — the prompt-cache lifetime in minutes. +# post: prints 5 (Pro default) or the configured value. Source for the default: +# docs.anthropic.com/.../prompt-caching — 5-minute default TTL; 60 on Max. +read_cache_ttl_min() { + local b_ttl="" + if [ -r "$BUDGET_CONFIG" ]; then + read -r b_ttl < <(jq -r '"\(.cache_ttl_min // 5)"' "$BUDGET_CONFIG" 2>/dev/null) || true + fi + case "$b_ttl" in ''|*[!0-9]*) printf '5' ;; *) printf '%s' "$b_ttl" ;; esac +} + +# read_configured_size — the verbosity preset the config asks for. +# post: prints one of xs|s|m|l|xl, or nothing when the config is absent or does +# not name a valid preset. It is a PREFERENCE: layout.sh caps it by width. +read_configured_size() { + local s="" + [ -r "$BUDGET_CONFIG" ] && { s=$(jq -r '.size // empty' "$BUDGET_CONFIG" 2>/dev/null) || s=""; } + case "$s" in xs|s|m|l|xl) printf '%s' "$s" ;; esac +} diff --git a/plugins/statusline/assets/statusline-lib/fit.sh b/plugins/statusline/assets/statusline-lib/fit.sh new file mode 100644 index 0000000..f0435b9 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/fit.sh @@ -0,0 +1,125 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 # This module's constants and outputs are consumed by +# sibling modules and by the composition root that sources them all; shellcheck +# analyses one file at a time and cannot follow that boundary. What actually +# proves each name is live is tests/statusline (which sources the whole set and +# asserts the exports exist) and the golden render diff, not this warning. +# statusline-lib/fit.sh — measuring and trimming a rendered line. +# +# Single responsibility: how much terminal a rendered string occupies, and what +# to drop when it occupies too much. This module changes when the trimming +# policy changes. It does not probe the terminal (layout.sh does that) and does +# not know what any segment means — it only knows they are joined by " │ " and +# ordered most-important-first. Everything here is pure. +# +# Depends on: palette.sh (SEP, RESET). +# +# Claude Code truncates a status line that reaches the terminal's right margin, +# and a line long enough to wrap costs the whole block a row. Rather than let +# the terminal cut mid-word, the renderer measures each line and drops its +# lowest-priority segments itself. + +# Share of the terminal width a line may occupy. Lines are fitted to a fraction +# of the width rather than to the width itself: a line that reaches the right +# margin is truncated by the host and can cost the block a row. +# source: pictet-tech claude-statusline assets/statusline.sh:98-108 ("If line1 +# approaches cols, Claude Code renders '…' at the right margin AND drops the +# second status line"), whose 85% target this reuses unchanged — an inherited +# measured claim, not one re-measured here. +FIT_RATIO=85 + +# vislen — terminal columns a rendered segment will occupy. +# pre: $1 is a segment as built by this renderer: literal "\033[m" SGR +# sequences (the four characters backslash-0-3-3 followed by params and +# "m", NOT interpreted escapes — printf '%b' interprets them at emit +# time) interleaved with printable text. +# post: prints the column count. SGR sequences count 0; every printable +# character counts 1. Pure: no I/O, no global mutation. +# Two known deviations, both documented rather than corrected: +# - The block/box glyphs used here (│ █ ░ …) are East-Asian-Ambiguous width. +# They are counted as 1, which matches Terminal.app, iTerm2 and Warp in +# their default configuration. A terminal explicitly configured to render +# ambiguous characters double-width will be under-measured; the FIT_RATIO +# headroom absorbs the common case and STATUSLINE_COLS overrides it. +# - Under a non-UTF-8 locale bash counts bytes, so multi-byte characters +# over-measure. That can only make fit_line trim earlier, never overflow. +vislen() { + local s="$1" out="" pre rest + while [ -n "$s" ]; do + case "$s" in + *'\033['*) pre=${s%%'\033['*} ;; + *) out="$out$s"; break ;; + esac + out="$out$pre" + rest=${s#*'\033['} + case "$rest" in + *m*) s=${rest#*m} ;; + *) break ;; # unterminated SGR: stop, count nothing more + esac + done + printf '%s' "${#out}" +} + +# vistrunc — cut a segment to at most $2 columns, ending in an ellipsis. +# pre: $1 a segment (same shape as vislen's input), $2 a positive column budget. +# post: prints a segment of at most $2 columns, closed with RESET so the cut +# cannot leak a colour into the rest of the terminal. SGR sequences are +# copied through intact — the cut never lands inside one. Prints nothing +# for a non-positive budget. +vistrunc() { + local s="$1" max="$2" out="" n=0 pre rest esc + case "$max" in ''|*[!0-9]*|0) return ;; esac + while [ -n "$s" ]; do + case "$s" in + *'\033['*) pre=${s%%'\033['*} ;; + *) pre=$s ;; + esac + if [ $(( n + ${#pre} )) -gt "$max" ]; then + # one column is spent on the ellipsis itself + out="${out}${pre:0:$(( max - n - 1 ))}…" + printf '%s' "${out}${RESET}" + return + fi + out="$out$pre"; n=$(( n + ${#pre} )) + s=${s#"$pre"} + [ -z "$s" ] && break + rest=${s#'\033['} + case "$rest" in + *m*) esc=${rest%%m*}; out="${out}\\033[${esc}m"; s=${rest#*m} ;; + *) break ;; + esac + done + printf '%s' "$out" +} + +# fit_line — make one status line fit $2 columns. +# pre: $1 a rendered line whose segments are joined by " ${SEP} ", $2 a column +# budget. Lines in this renderer are built most-important-first, so the +# tail is by construction the lowest-priority material. +# post: prints the longest leading run of whole segments that fits, closed with +# RESET. A line already within budget is returned untouched (fast path, +# one vislen call). When even the first segment overflows it is truncated +# in place rather than dropped, so a line never renders empty. Pure. +fit_line() { + local line="$1" max="$2" d=" ${SEP} " out="" rest seg first=1 n=0 dw sw + case "$max" in ''|*[!0-9]*|0) printf '%s' "$line"; return ;; esac + [ "$(vislen "$line")" -le "$max" ] && { printf '%s' "$line"; return ; } + dw=$(vislen "$d") + rest="$line" + while :; do + case "$rest" in + *"$d"*) seg=${rest%%"$d"*}; rest=${rest#*"$d"} ;; + *) seg=$rest; rest="" ;; + esac + sw=$(vislen "$seg") + if [ "$first" = "1" ]; then + [ "$sw" -gt "$max" ] && { vistrunc "$seg" "$max"; return; } + out="$seg"; n="$sw"; first=0 + else + [ $(( n + dw + sw )) -gt "$max" ] && break + out="${out}${d}${seg}"; n=$(( n + dw + sw )) + fi + [ -z "$rest" ] && break + done + printf '%s' "${out}${RESET}" +} diff --git a/plugins/statusline/assets/statusline-lib/format.sh b/plugins/statusline/assets/statusline-lib/format.sh new file mode 100644 index 0000000..7299445 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/format.sh @@ -0,0 +1,58 @@ +# shellcheck shell=bash +# statusline-lib/format.sh — numbers and times as the reader sees them. +# +# Single responsibility: turning a raw quantity into the shortest string that +# still reads unambiguously. Changes when a display format changes. Knows +# nothing about severity, colour or layout — none of these functions emit an +# escape sequence. +# +# Depends on: platform.sh (_date_fmt). + +# fmt_tokens — a token count in engineering shorthand (1234 -> "1k"). +fmt_tokens() { + local t="$1" + if [ "$t" -ge 1000000000 ]; then LC_NUMERIC=C awk "BEGIN{printf \"%.1fG\",$t/1000000000}" + elif [ "$t" -ge 1000000 ]; then LC_NUMERIC=C awk "BEGIN{printf \"%.1fM\",$t/1000000}" + elif [ "$t" -ge 1000 ]; then LC_NUMERIC=C awk "BEGIN{printf \"%.0fk\",$t/1000}" + else echo "$t"; fi +} + +# fmt_usd — a dollar amount, thousands folded to "$1.2k". +fmt_usd() { + local v="$1" + LC_NUMERIC=C awk "BEGIN{ if($v>=1000) printf \"\$%.1fk\",$v/1000; else printf \"\$%.2f\",$v }" +} + +# fmt_dur — compact duration: "Xh Ym" / "Xm Ys" / "Xs" from a seconds count. +fmt_dur() { + local s="$1" + case "$s" in ''|*[!0-9]*) s=0 ;; esac + if [ "$s" -ge 3600 ]; then printf '%dh%dm' "$(( s / 3600 ))" "$(( (s % 3600) / 60 ))" + elif [ "$s" -ge 60 ]; then printf '%dm%ds' "$(( s / 60 ))" "$(( s % 60 ))" + else printf '%ds' "$s"; fi +} + +# --- Rate-limit reset times ----------------------------------------------- +# .rate_limits.*.resets_at is an epoch in SECONDS (verified against both the +# large and xlarge variants of github.com/AwesomeJun/CC-statusline, which +# subtract it from `date +%s` directly). Guard: only treat all-digit values as +# an epoch so a future ISO string can never crash the arithmetic. + +# fmt_reset_in — "XhYm" remaining until the epoch $1. Prints nothing for a +# non-epoch input. Used for the 5h burst window, which turns over often enough +# that a countdown is what the reader wants. +fmt_reset_in() { + local e="$1" + case "$e" in ''|*[!0-9]*) return ;; esac + local rem=$(( e - $(date +%s) )); [ "$rem" -lt 0 ] && rem=0 + printf '%dh%dm' "$(( rem / 3600 ))" "$(( (rem % 3600) / 60 ))" +} + +# fmt_reset_at — "Wed 14:00" wall-clock of the epoch $1. Prints nothing for a +# non-epoch input. Used for the 7d window, where a countdown would read +# "138h12m" and mean nothing. +fmt_reset_at() { + local e="$1" + case "$e" in ''|*[!0-9]*) return ;; esac + LC_TIME=C _date_fmt "$e" "%a %H:%M" +} diff --git a/plugins/statusline/assets/statusline-lib/gitctx.sh b/plugins/statusline/assets/statusline-lib/gitctx.sh new file mode 100644 index 0000000..9fc94b1 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/gitctx.sh @@ -0,0 +1,88 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 # This module's constants and outputs are consumed by +# sibling modules and by the composition root that sources them all; shellcheck +# analyses one file at a time and cannot follow that boundary. What actually +# proves each name is live is tests/statusline (which sources the whole set and +# asserts the exports exist) and the golden render diff, not this warning. +# statusline-lib/gitctx.sh — the repository facts the git line shows. +# +# Single responsibility: asking git where we are. Changes when the git facts on +# display change. Every call is read-only and bounded (one symbolic-ref, one +# rev-list, one status); nothing here writes to a repository. +# +# Depends on: platform.sh (file_mtime). +# +# Sets, via collect_git: git_branch git_dirty git_repo git_root +# git_ahead git_behind git_conf nM nA nD nU + +# branch_of — echo "branchdirty" for the repo rooted at $1. +# post: branch is the symbolic ref, or the short SHA when detached; dirty is "1" +# when the worktree or the index differs from HEAD, empty otherwise (a +# boolean flag only — the render prints the word "modified"). +branch_of() { + local root="$1" b d="" + b=$(git -C "$root" -c core.useBuiltinFSMonitor=false symbolic-ref --short HEAD 2>/dev/null \ + || git -C "$root" -c core.useBuiltinFSMonitor=false rev-parse --short HEAD 2>/dev/null) + if ! git -C "$root" -c core.useBuiltinFSMonitor=false diff --quiet 2>/dev/null \ + || ! git -C "$root" -c core.useBuiltinFSMonitor=false diff --cached --quiet 2>/dev/null; then + d="1" + fi + printf '%s\t%s' "$b" "$d" +} + +# compute_git_extra — ahead/behind vs upstream and a porcelain file-stat +# breakdown for the repo rooted at $1. Pure git; one rev-list + one status call. +# post: sets git_ahead git_behind git_conf nM nA nD nU. +compute_git_extra() { + local root="$1" lr line xy + lr=$(git -C "$root" -c core.useBuiltinFSMonitor=false \ + rev-list --left-right --count 'HEAD...@{u}' 2>/dev/null) + if [ -n "$lr" ]; then + git_ahead=$(printf '%s' "$lr" | awk '{print $1}') + git_behind=$(printf '%s' "$lr" | awk '{print $2}') + fi + while IFS= read -r line; do + [ -z "$line" ] && continue + xy="${line:0:2}" + case "$xy" in + '??') nU=$((nU+1)) ;; + U*|?U|AA|DD) git_conf=$((git_conf+1)) ;; # unmerged / conflict + *) + case "$xy" in *M*) nM=$((nM+1)) ;; esac + case "$xy" in A*) nA=$((nA+1)) ;; esac + case "$xy" in *D*) nD=$((nD+1)) ;; esac + ;; + esac + done < <(git -C "$root" -c core.useBuiltinFSMonitor=false status --porcelain 2>/dev/null) +} + +# collect_git — resolve every git fact for the working directory $1. +# pre: $1 the session's current directory (may not be a repository). +# post: sets the git_* and n* globals listed in this file's header; all of them +# are set to a defined value (empty or 0) even when $1 is not a repo, so +# no caller has to distinguish "unset" from "none". +# When $1 is not itself a repository (a workspace root, say) the branch shown is +# that of the sub-repo under it whose .git was touched most recently — the one +# being worked in. Bounded to depth 2 for speed. +collect_git() { + local cwd="$1" newest_git="" + git_branch=""; git_dirty=""; git_repo=""; git_root="" + git_ahead=""; git_behind=""; git_conf=0; nM=0; nA=0; nD=0; nU=0 + + if git -C "$cwd" rev-parse --git-dir > /dev/null 2>&1; then + IFS=$'\t' read -r git_branch git_dirty < <(branch_of "$cwd") + git_root="$cwd" + else + newest_git=$(find "$cwd" -maxdepth 2 -name .git -type d -prune 2>/dev/null \ + | while IFS= read -r g; do + printf '%s %s\n' "$(file_mtime "$g")" "${g%/.git}" + done | sort -rn | head -1 | cut -d' ' -f2-) + if [ -n "$newest_git" ]; then + IFS=$'\t' read -r git_branch git_dirty < <(branch_of "$newest_git") + git_repo=$(basename "$newest_git") + git_root="$newest_git" + fi + fi + [ -n "$git_root" ] && compute_git_extra "$git_root" + return 0 +} diff --git a/plugins/statusline/assets/statusline-lib/layout.sh b/plugins/statusline/assets/statusline-lib/layout.sh new file mode 100644 index 0000000..4200ef2 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/layout.sh @@ -0,0 +1,106 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 # This module's constants and outputs are consumed by +# sibling modules and by the composition root that sources them all; shellcheck +# analyses one file at a time and cannot follow that boundary. What actually +# proves each name is live is tests/statusline (which sources the whole set and +# asserts the exports exist) and the golden render diff, not this warning. +# statusline-lib/layout.sh — how wide the terminal is and how much to show. +# +# Single responsibility: the coarse adjustment — how many LINES render and how +# wide the bars are. (The fine one, how many SEGMENTS survive on each line, is +# fit.sh's job.) Changes when the preset ladder or the width probe changes. +# +# Depends on: config.sh (read_configured_size). + +# probe_cols — the terminal's width in columns. +# post: prints a positive integer, always. +# The statusLine JSON carries no terminal size, so it is probed. Every source +# below is a real measurement of a real terminal; when none of them can answer, +# the fallback is deliberately WIDE. An over-generous guess reproduces exactly +# the pre-width-aware behaviour, whereas an over-tight one hides information +# that would have fitted — so a guess is never preferred to no answer. +# In particular `tput cols` is consulted only when stdout is a terminal: with +# stdout on a pipe (which is how the host captures this script) tput cannot +# query anything and returns terminfo's blind default of 80, which would +# silently downgrade the preset on every IDE and web host. +probe_cols() { + local cols="${STATUSLINE_COLS:-}" + if [ -n "$cols" ]; then printf '%s' "$cols"; return; fi + # stdin is the JSON pipe, so the size is read from the controlling tty. The + # stderr redirect wraps the whole group, not just stty: with no controlling + # terminal it is the "< /dev/tty" REDIRECTION that fails, and the shell + # reports that itself before stty ever runs, so a 2>/dev/null on the command + # alone would still leak "Device not configured" on every refresh. + cols=$( { stty size < /dev/tty | awk '{print $2}'; } 2>/dev/null ) + case "$cols" in ''|*[!0-9]*|0) cols="${COLUMNS:-}" ;; esac + case "$cols" in ''|*[!0-9]*|0) [ -t 1 ] && cols=$(tput cols 2>/dev/null) ;; esac + case "$cols" in ''|*[!0-9]*|0) cols=200 ;; esac + printf '%s' "$cols" +} + +# --- Verbosity preset (xs|s|m|l|xl) ----------------------------------------- +# Resolution: +# $STATUSLINE_SIZE env — a hard pin, honoured at any width. The escape hatch: +# forcing a preset for testing or for a terminal whose +# width cannot be probed correctly. +# .size in the budget config, else "l" — a PREFERENCE, capped by the width +# rule below. It is what the config ships with, so +# treating it as a pin would leave the width rule dead +# for every default installation. +# Tiers are monotonic — each larger size is a superset of the smaller one: +# xs 1 line : identity + context bar (CTX 6) +# s 2 lines: + git, session (tokens + cost) (CTX 8) +# m 3 lines: + rate limits + churn (CTX 10) +# l 5 lines: + $ & token budget gauges + resets (CTX 10, BW 12) [default] +# xl 5 lines: everything, widest bars + avg/mo (CTX 16, BW 20) +# +# Width caps the preference, and only downward. Width can prove that a preset +# does not fit; it cannot prove the user wants a bigger one — the ladder encodes +# how much detail they asked for, not just how much the terminal can hold. So a +# wide terminal never silently promotes the preference, and a narrow one lowers +# it at most to "s". +# +# Widest rendered line per preset, source: measured 2026-07-26 with +# tests/statusline/measure_widths.sh over a representative populated payload — +# s 64 | xs 85 | l 89 | xl 95 | m 139 +# The ladder is monotonic in CONTENT but NOT in width: xs crams identity and +# context onto one line and m inlines both quota windows into the session line, +# so both are wider than l, which spreads strictly more material over more rows. +# Auto-selection therefore has exactly two useful rungs — l, and s when l cannot +# fit — and never picks xs or m, which are line-count preferences to be pinned +# explicitly, not narrow-terminal fallbacks. +# +# The threshold is l's widest measured line rounded up (89 -> 90), compared +# against the RAW width rather than the fitted budget: above it, l is at worst +# lightly trimmed inside the FIT_RATIO headroom, which is precisely fit_line's +# job; below it the preset is structurally too wide and a real downgrade is the +# honest answer. The measurement anchors the threshold; it does not guarantee it +# — a long branch name or a deep directory widens any preset, and fit_line is +# what actually holds the line to the terminal. +SIZE_L_MIN_COLS=90 + +# resolve_preset — choose the verbosity preset for a terminal $1 columns wide. +# pre: $1 a positive integer column count. +# post: sets SIZE (xs|s|m|l|xl), RANK (0..4, the preset as an ordered level), +# CTX_W (context bar cells) and BW (quota bar cells). Returns 0. +resolve_preset() { + local cols="$1" + SIZE="${STATUSLINE_SIZE:-}" + case "$SIZE" in + xs|s|m|l|xl) ;; # env pin: honoured at any width + *) + SIZE=$(read_configured_size) + case "$SIZE" in xs|s|m|l|xl) ;; *) SIZE="l" ;; esac + # width cap, downward only: "s" is the one preset narrower than "l" + # (measured 64 cols vs 89 — see the table above). + [ "$cols" -lt "$SIZE_L_MIN_COLS" ] && case "$SIZE" in m|l|xl) SIZE="s" ;; esac + ;; + esac + case "$SIZE" in + xs) RANK=0; CTX_W=6; BW=10 ;; + s) RANK=1; CTX_W=8; BW=10 ;; + m) RANK=2; CTX_W=10; BW=12 ;; + l) RANK=3; CTX_W=10; BW=12 ;; + xl) RANK=4; CTX_W=16; BW=20 ;; + esac +} diff --git a/plugins/statusline/assets/statusline-lib/palette.sh b/plugins/statusline/assets/statusline-lib/palette.sh new file mode 100644 index 0000000..d8e2a36 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/palette.sh @@ -0,0 +1,115 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 # This module's constants and outputs are consumed by +# sibling modules and by the composition root that sources them all; shellcheck +# analyses one file at a time and cannot follow that boundary. What actually +# proves each name is live is tests/statusline (which sources the whole set and +# asserts the exports exist) and the golden render diff, not this warning. +# statusline-lib/palette.sh — colour tokens and the heat-track bar. +# +# Single responsibility: what things look like. This module changes when the +# design system changes, and for no other reason — it knows nothing about +# quotas, tokens, terminals or git. Everything here is pure: no I/O, no reads of +# any global defined elsewhere. +# +# Depends on: nothing. +# +# --- Colors: AI Architect DS — ink (instrument) surface palette (no DIM) --- +# source: AI Architect Design System tokens/colors.css (:root ink-surface +# primitives), each oklch value converted with CSS Color 4 math (scripted +# oklch->srgb). DS gate G3/G4 (SKILL.md): chrome is GREYSCALE — colour comes +# only from data tokens (heat/stage/valence/tool families) or the single +# terracotta accent, never both, never decoratively. None of this statusline's +# segments (model/dir/worktree/subagent-count/throughput/compaction-count) are +# DS "data families" — they are chrome — so they render in the neutral fg +# scale (TEXT/SUBTEXT/OVERLAY), not in stage/valence hues. Status colours +# (ok/warn/danger) are reserved for actual threshold-driven state. Terracotta +# is used in exactly one place (the effort badge — the one user-selected, +# accent-worthy dial) per G4 ("accent = selection only, never a category"). +# Fixes a prior 1:1 Catppuccin-Mocha->token remap (PR #2) that kept 12 +# competing hues (rainbow chrome) instead of DS's neutral-chrome-plus-one-accent +# hierarchy — see session-optimizer PR "design(statusline): AI Architect DA +# compliance — neutral chrome, single accent, no data-hue borrowing". +# +# No DIM attribute anywhere — it renders unreadable on black terminals. +# Secondary text uses light grey; primary uses bright white. +RESET="\033[0m" +TEXT="\033[38;2;243;241;238m" # #f3f1ee — primary text · DS --fg-0 oklch(96% 0.005 80) · scripted oklch->srgb +SUBTEXT="\033[38;2;192;189;186m" # #c0bdba — secondary text / labels · DS --fg-1 oklch(80% 0.006 70) · scripted oklch->srgb +OVERLAY="\033[38;2;136;134;130m" # #888682 — separators / muted / decorative icons · DS --fg-2 oklch(62% 0.006 70) · scripted oklch->srgb +GREEN="\033[38;2;101;201;140m" # #65c98c — DS --ok oklch(76% 0.13 155) · scripted oklch->srgb +YELLOW="\033[38;2;232;170;78m" # #e8aa4e — DS --warn oklch(78% 0.13 75) · scripted oklch->srgb +RED="\033[38;2;232;97;84m" # #e86154 — DS --danger oklch(66% 0.17 28) · scripted oklch->srgb +PEACH="\033[38;2;207;110;57m" # #cf6e39 — DS --accent (terracotta) oklch(64% 0.14 47) · scripted oklch->srgb · two legitimate consumers: the effort badge (G4 selection-only accent) AND the heat-track palier 4 (HEAT_4 below, G6 heat-track exception) — never a third, decorative use +# back-compat aliases used by the renderer +WHITE="$TEXT"; LGREY="$SUBTEXT" +SEP="${OVERLAY}│${RESET}" + +# Semantic roles for the two kinds of word the renderer prints. Defined here +# rather than at the call site so a theme change is one file, not two. +LABEL="$SUBTEXT" # the leading concern word of a line +KEY="$OVERLAY" # the word naming an individual value inside a line + +# Heat-track palette (DS gate G6: "No gradients except the heat track" — +# this bar IS the heat track, so a 4-palier discrete ramp is the compliant +# form; no interpolation). Thresholds HEAT_T2/T3/T4 are named constants, +# format matches the TEXT/SUBTEXT/OVERLAY/PEACH block above: hex + DS token + +# derivation on each line. +HEAT_T2=50 # palier 1->2 boundary (%) +HEAT_T3=75 # palier 2->3 boundary (%) +HEAT_T4=90 # palier 3->4 boundary (%); matches WARN/SAVE ratio for + # sonnet/opus (180000/200000 = 0.90) in ctxguard-thresholds.json + # (see config.sh) — the heat track's critical palier starts + # exactly where the checkpoint hook's own hard-cap ratio does, so + # the visual and the enforcement threshold cannot drift apart by + # design. +HEAT_1="136;134;130" # #888682 — DS --fg-2 oklch(62% 0.006 70) · scripted oklch->srgb · reused from OVERLAY (0-49%) +HEAT_2="192;189;186" # #c0bdba — DS --fg-1 oklch(80% 0.006 70) · scripted oklch->srgb · reused from SUBTEXT (50-74%) +HEAT_3="181;125;98" # #b57d62 — terracotta atténué, source: scripted oklch->srgb, oklch(64% 0.08 47), Ottosson OKLab (75-89%) +HEAT_4="207;110;57" # #cf6e39 — DS --accent (terracotta) oklch(64% 0.14 47) · scripted oklch->srgb · reused from PEACH (90-100%) + +# heat_rgb — pure heat-track color selector. +# pre: $1 is an integer (any sign, any magnitude); non-integer input is a +# contract violation and the function fails (exit 1, no output). +# post: on success, prints exactly one of {HEAT_1, HEAT_2, HEAT_3, HEAT_4} +# as "r;g;b" (never interpolated) and returns 0. Input is clamped to +# [0,100] before palier lookup, so out-of-range integers always +# succeed and resolve to the nearest boundary palier (HEAT_1 or +# HEAT_4). No I/O, no global mutation beyond reading the HEAT_* +# constants above — total function once input is validated numeric. +heat_rgb() { + local p="$1" + case "$p" in + ''|*[!0-9-]*) return 1 ;; # reject empty / non-numeric (incl. "abc") + esac + [ "$p" -lt 0 ] && p=0 + [ "$p" -gt 100 ] && p=100 + if [ "$p" -lt "$HEAT_T2" ]; then printf '%s' "$HEAT_1" + elif [ "$p" -lt "$HEAT_T3" ]; then printf '%s' "$HEAT_2" + elif [ "$p" -lt "$HEAT_T4" ]; then printf '%s' "$HEAT_3" + else printf '%s' "$HEAT_4" + fi +} + +# render a heat-track block bar of $2 cells filled to $1 percent (clamped +# 0..width). Each filled cell is colored by its POSITION along the full +# width via heat_rgb (4 discrete paliers, no interpolation — G6 heat-track +# exception), so a longer (more-full) bar visibly accumulates paliers left +# to right. Empty cells stay muted. The bar is self-coloring: callers must +# NOT wrap the result in a single color (trailing RESET closes it). +make_bar() { + local p="$1" w="$2" filled empty i pos rgb e b="" + filled=$(( (p * w + 50) / 100 )) # round to nearest cell + [ "$filled" -gt "$w" ] && filled="$w" + [ "$filled" -lt 0 ] && filled=0 + empty=$(( w - filled )) + i=0 + while [ "$i" -lt "$filled" ]; do + pos=$(( w > 1 ? i * 100 / (w - 1) : 0 )) # cell position 0..100 + rgb=$(heat_rgb "$pos") + b="${b}\033[38;2;${rgb}m█" + i=$(( i + 1 )) + done + [ "$filled" -gt 0 ] && b="${b}${RESET}" + [ "$empty" -gt 0 ] && { printf -v e "%${empty}s"; b="${b}${OVERLAY}${e// /░}${RESET}"; } + printf '%s' "$b" +} diff --git a/plugins/statusline/assets/statusline-lib/platform.sh b/plugins/statusline/assets/statusline-lib/platform.sh new file mode 100644 index 0000000..30e3a49 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/platform.sh @@ -0,0 +1,37 @@ +# shellcheck shell=bash +# statusline-lib/platform.sh — the BSD/GNU spelling differences, in one place. +# +# Single responsibility: hide the two mutually exclusive spellings of the +# coreutils this renderer needs. `stat` and `date` are the only two commands it +# calls whose flags differ between macOS (BSD) and Linux (GNU); every other +# external it uses (jq, awk, git) takes the same arguments on both. This module +# changes when a new platform is supported, and for no other reason. +# +# Depends on: nothing but the shell and coreutils. + +# file_mtime — modification time of $1 as epoch seconds, 0 when unavailable. +# pre: $1 is a path (may not exist). +# post: prints a non-negative integer and returns 0. 0 is the documented +# "unavailable" answer — never an empty string, because every call site +# feeds the result straight into `now - mtime` arithmetic. +# BSD/macOS `stat -f %m` and GNU/Linux `stat -c %Y` are mutually exclusive +# spellings; trying both keeps every cache-age and lock-age comparison in this +# renderer working on either platform instead of silently reading 0 (which +# forces a refresh on every invocation) on Linux. +file_mtime() { + local f="$1" m="" + m=$(stat -f '%m' "$f" 2>/dev/null) && [ -n "$m" ] && { printf '%s' "$m"; return; } + m=$(stat -c '%Y' "$f" 2>/dev/null) && [ -n "$m" ] && { printf '%s' "$m"; return; } + printf '0' +} + +# _date_fmt — format epoch seconds $1 with strftime format $2. +# pre: $1 all-digit epoch seconds, $2 a strftime format string. +# post: prints the formatted time, or nothing when no spelling works. +# Cross-platform date: BSD/macOS `date -j -f %s` | `date -r` | GNU `date -d @`. +_date_fmt() { + local epoch="$1" fmt="$2" out="" + out=$(date -j -f "%s" "$epoch" "+$fmt" 2>/dev/null) && [ -n "$out" ] && { echo "$out"; return; } + out=$(date -r "$epoch" "+$fmt" 2>/dev/null) && [ -n "$out" ] && { echo "$out"; return; } + date -d "@$epoch" "+$fmt" 2>/dev/null +} diff --git a/plugins/statusline/assets/statusline-lib/render.sh b/plugins/statusline/assets/statusline-lib/render.sh new file mode 100644 index 0000000..95e961c --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/render.sh @@ -0,0 +1,260 @@ +# shellcheck shell=bash +# shellcheck disable=SC2154,SC2153 # Every renderer here is a projection of the +# facts the collectors resolved (model, git_*, cost_*, RANK, the palette). Those +# globals are assigned in sibling modules, which shellcheck does not follow, so +# it reads every one of them as unassigned. Each function's `reads:` line is the +# contract; the golden render diff is what enforces it. +# statusline-lib/render.sh — one function per status line. +# +# Single responsibility: composing the collected facts into text. Changes when +# what is displayed, or in what order, changes. Nothing here reads a file, runs +# git, or probes the terminal — each function is a pure projection of globals +# the collectors already resolved, and each PRINTS its line rather than +# assigning one, so the composition root decides what to do with it. +# +# Lines are grouped one concern per line (identity / git / session / telemetry / +# quota / spend). Empty lines are skipped at emit time, so the statusline grows +# and shrinks with what is actually present instead of cramming everything. +# +# Every line is built MOST-IMPORTANT-FIRST: fit_line drops whole trailing +# segments when the terminal is too narrow, so segment order IS the priority +# order. A segment moved right is a segment volunteered for deletion. +# +# Typography: no emoji or pictographs anywhere. Every line opens with an +# explicit lowercase word label naming its concern, and every value inside a +# line is preceded by the word for what it measures. Rationale: a glyph has to +# be learned and is ambiguous across terminal fonts (and several rendered as +# double-width, breaking column budgets); a word is self-describing and reads +# the same everywhere. Status is carried by colour + number, never by an icon. +# +# Depends on: palette.sh, severity.sh, format.sh. + +DOLLAR='$' + +# render_identity — model, directory, effort, thinking. +# reads: model dir effort thinking +# The directory sits second, immediately after the model, because segment order +# IS priority order here: fit_line drops from the tail, and on a narrow terminal +# "which tree am I in" is the segment worth keeping. Effort and thinking are +# session settings the reader chose and can recall; the directory changes under +# them, and mistaking it is how work lands in the wrong repository. The previous +# order put dir last and therefore volunteered it first. +render_identity() { + local line="${LABEL}model ${WHITE}${model}${RESET}" + line="${line} ${SEP} ${KEY}dir ${TEXT}${dir}${RESET}" + if [ -n "$effort" ]; then + # sole accent usage (DS G4): effort is the one user-selected dial per session + line="${line} ${SEP} ${KEY}effort ${PEACH}${effort}${RESET}" + [ "$thinking" = "true" ] && line="${line} ${SEP} ${KEY}thinking ${SUBTEXT}on${RESET}" + fi + printf '%s' "$line" +} + +# render_git — branch (+dirty +fallback repo), ahead/behind, file stats, +# worktree, PR. Prints nothing when there is no repository and no worktree/PR. +# reads: git_branch git_dirty git_repo git_ahead git_behind git_conf +# nM nA nD nU wt_name pr_num pr_state RANK +render_git() { + local line="" dirty_part repo_part fs pr_color pr_word + if [ -n "$git_branch" ]; then + dirty_part="" + [ -n "$git_dirty" ] && dirty_part=" ${RED}modified${RESET}" + repo_part="" + [ -n "$git_repo" ] && repo_part="${OVERLAY}@${git_repo}${RESET}" + line="${LABEL}branch ${WHITE}${git_branch}${RESET}${dirty_part}${repo_part}" + + # ahead/behind vs upstream + conflicts (s+). Only nonzero counts render. + if [ "$RANK" -ge 1 ]; then + { [ -n "$git_ahead" ] && [ "$git_ahead" -gt 0 ]; } && line="${line} ${SEP} ${KEY}ahead ${GREEN}${git_ahead}${RESET}" + { [ -n "$git_behind" ] && [ "$git_behind" -gt 0 ]; } && line="${line} ${SEP} ${KEY}behind ${YELLOW}${git_behind}${RESET}" + [ "$git_conf" -gt 0 ] && line="${line} ${SEP} ${KEY}conflicts ${RED}${git_conf}${RESET}" + fi + # file-stat breakdown (m+): nonzero counts only, each named in full. + if [ "$RANK" -ge 2 ]; then + fs="" + [ "$nM" -gt 0 ] && fs="${fs:+$fs }${KEY}mod ${YELLOW}${nM}${RESET}" + [ "$nA" -gt 0 ] && fs="${fs:+$fs }${KEY}add ${GREEN}${nA}${RESET}" + [ "$nD" -gt 0 ] && fs="${fs:+$fs }${KEY}del ${RED}${nD}${RESET}" + [ "$nU" -gt 0 ] && fs="${fs:+$fs }${KEY}untracked ${SUBTEXT}${nU}${RESET}" + [ -n "$fs" ] && line="${line} ${SEP} ${fs}" + fi + fi + if [ -n "$wt_name" ]; then + line="${line:+$line ${SEP} }${KEY}worktree ${TEXT}${wt_name}${RESET}" + fi + if [ -n "$pr_num" ]; then + pr_color="$SUBTEXT" + pr_word="" + case "$pr_state" in + approved) pr_color="$GREEN"; pr_word=" approved" ;; + changes_requested) pr_color="$RED"; pr_word=" changes requested" ;; + pending) pr_color="$YELLOW"; pr_word=" pending" ;; + esac + line="${line:+$line ${SEP} }${KEY}pr ${pr_color}${pr_num}${pr_word}${RESET}" + fi + printf '%s' "$line" +} + +# render_session — context bar, tokens, cost, duration, rate limits, churn, +# subagents. +# reads: used_pct in_tokens CTX_W RANK SAVE_TOKENS cost_session cost dur_ms +# rl_5h rl_7d rl_5h_at rl_7d_at now_epoch adds dels sub_count sub_tokens +render_session() { + local line="" tok cc pct_int bar cost_fmt dur_s mins secs + local rl_seg v vrank vprank vratio sub_seg + + if [ -n "$used_pct" ] || [ -n "$in_tokens" ]; then + # color by absolute token count against the save threshold (works for 1M window) + tok="${in_tokens:-0}" + cc=$(token_color "$tok") + + if [ -n "$used_pct" ]; then + pct_int=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$used_pct}") + bar=$(make_bar "$pct_int" "$CTX_W") + line="${LABEL}context ${RESET}${bar} ${cc}${pct_int}%${RESET}" + fi + + if [ -n "$in_tokens" ] && [ "$RANK" -ge 1 ]; then + line="${line:+$line }${cc}$(fmt_tokens "$in_tokens") tokens${RESET}" + # explicit checkpoint hint once past the save threshold + [ "$in_tokens" -ge "$SAVE_TOKENS" ] && line="${line} ${RED}save and recall now${RESET}" + fi + fi + + # Session spend. Prefers the ledger row (main thread + every subagent, deduped + # and priced from this session's own transcript) over .cost.total_cost_usd, + # which counts the main thread only and additionally carries prior spend + # forward on a resumed session. Falls back to the harness figure, labelled + # "main" so an understated number is never shown as if it were the total. + if [ "$RANK" -ge 1 ]; then + if [ -n "$cost_session" ]; then + cost_fmt=$(LC_NUMERIC=C awk "BEGIN{printf \"%.2f\",$cost_session}") + line="${line:+$line ${SEP} }${KEY}session ${SUBTEXT}${DOLLAR}${cost_fmt}${RESET}" + elif [ -n "$cost" ]; then + cost_fmt=$(LC_NUMERIC=C awk "BEGIN{printf \"%.2f\",$cost}") + line="${line:+$line ${SEP} }${KEY}session main ${SUBTEXT}${DOLLAR}${cost_fmt}${RESET}" + fi + fi + + if [ -n "$dur_ms" ] && [ "$RANK" -ge 2 ]; then + dur_s=$((dur_ms / 1000)); mins=$((dur_s / 60)); secs=$((dur_s % 60)) + line="${line:+$line ${SEP} }${KEY}elapsed ${SUBTEXT}${mins}m${secs}s${RESET}" + fi + + # Inline rate limits only at the m preset; l/xl render full quota bars below. + # Percentage and colour come from quota_reading, the same resolver the full + # quota lines use, so the compact and the expanded rendering of one window can + # never show different severities. No reset time here — at m the line is + # already carrying context, cost, elapsed and churn. + if { [ -n "$rl_5h" ] || [ -n "$rl_7d" ]; } && [ "$RANK" -eq 2 ]; then + rl_seg="" + if read -r v vrank vprank vratio < <(quota_reading "$rl_5h" "$rl_5h_at" "$RL_5H_WINDOW_S" "$now_epoch"); then + rl_seg="${KEY}quota 5h $(rank_color "$vrank")${v}%${RESET}" + [ -n "$vratio" ] && rl_seg="${rl_seg} ${KEY}pace $(rank_color "$vprank")$(fmt_pace "$vratio")${RESET}" + fi + if read -r v vrank vprank vratio < <(quota_reading "$rl_7d" "$rl_7d_at" "$RL_7D_WINDOW_S" "$now_epoch"); then + rl_seg="${rl_seg:+$rl_seg ${SEP} }${KEY}quota 7d $(rank_color "$vrank")${v}%${RESET}" + [ -n "$vratio" ] && rl_seg="${rl_seg} ${KEY}pace $(rank_color "$vprank")$(fmt_pace "$vratio")${RESET}" + fi + line="${line:+$line ${SEP} }${rl_seg}" + fi + + if { [ "$adds" -gt 0 ] || [ "$dels" -gt 0 ]; } && [ "$RANK" -ge 2 ]; then + line="${line:+$line ${SEP} }${KEY}edits ${GREEN}+${adds}${OVERLAY}/${RED}-${dels}${RESET}" + fi + + # Subagents: live count and token volume for THIS session, from the + # SubagentStop tracker. Their dollar cost is not repeated here — it is already + # inside the "session" figure above, which the ledger prices from the + # subagents/ subtree. Showing it twice would read as additive. + if [ -n "$sub_count" ]; then + sub_seg="${KEY}subagents ${SUBTEXT}${sub_count}${RESET}" + if [ -n "$sub_tokens" ] && [ "$sub_tokens" -gt 0 ] 2>/dev/null; then + sub_seg="${sub_seg} ${LGREY}$(fmt_tokens "$sub_tokens") tokens${RESET}" + fi + line="${line:+$line ${SEP} }${sub_seg}" + fi + printf '%s' "$line" +} + +# render_telemetry — turn throughput, last-response age, cache TTL, compactions. +# The age and the TTL countdown are recomputed here from the cached last_ts, so +# they stay second-accurate between the backgrounded scans. +# reads: RANK txt_tok_s txt_last_ts txt_compactions now_epoch cache_ttl_min +render_telemetry() { + local line="" ts_int age_s ttl_s rem_s cc + [ "$RANK" -ge 2 ] || { printf ''; return; } + + # turn throughput (tok/s) — wall-clock, includes tool latency (lower bound) + if [ -n "$txt_tok_s" ] && [ "$txt_tok_s" != "null" ]; then + ts_int=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$txt_tok_s}") + [ "$ts_int" -gt 0 ] && line="${LABEL}throughput ${SUBTEXT}${ts_int} tokens/s${RESET}" + fi + + # last-response age + prompt-cache TTL countdown, both from last_ts + if [ -n "$txt_last_ts" ] && [ "$txt_last_ts" != "null" ]; then + age_s=$(LC_NUMERIC=C awk "BEGIN{a=$now_epoch-$txt_last_ts; printf \"%d\", (a>0)?a:0}") + line="${line:+$line ${SEP} }${KEY}idle ${SUBTEXT}$(fmt_dur "$age_s")${RESET}" + + # cache warm until last_ts + cache_ttl_min; show remaining, red when cold. + ttl_s=$(( cache_ttl_min * 60 )) + rem_s=$(( ttl_s - age_s )) + if [ "$rem_s" -gt 0 ]; then + cc="$GREEN"; [ "$rem_s" -lt 60 ] && cc="$YELLOW" + line="${line:+$line ${SEP} }${KEY}cache warm ${cc}$(fmt_dur "$rem_s")${RESET}" + else + line="${line:+$line ${SEP} }${KEY}cache ${RED}cold${RESET}" + fi + fi + + # context compactions this session (only when any have happened) + if [ -n "$txt_compactions" ] && [ "$txt_compactions" != "null" ] && [ "$txt_compactions" -gt 0 ] 2>/dev/null; then + line="${line:+$line ${SEP} }${KEY}compactions ${SUBTEXT}${txt_compactions}${RESET}" + fi + printf '%s' "$line" +} + +# render_quota — one rate-limit window: the REAL "don't overshoot" cap. +# .used_percentage is already a ratio of the quota the account is bound to, so +# 100% = the cap (lockout). 5h = burst window, 7d = sustained window. This +# replaces the old arbitrary $/token monthly budgets: on a flat-rate Pro/Max +# plan the binding constraint is the quota, not an absolute spend. +# pre: $1 the window label as displayed ("5h"|"7d") — the statusLine input +# reports exactly these two windows, so the set is closed by the host's +# own contract, not open-ended; $2 raw used_percentage, $3 resets_at +# epoch, $4 window length in seconds. +# post: prints the line and returns 0, or prints nothing and returns 1 when the +# window is absent or unreadable. +# reads: BW now_epoch +# The bar stays on the heat track (coloured by position, DS gate G6) and is not +# a status colour; the percentage carries the combined absolute+pace severity. +render_quota() { + local label="$1" raw="$2" at="$3" win="$4" v vrank vprank vratio qb line r + read -r v vrank vprank vratio < <(quota_reading "$raw" "$at" "$win" "$now_epoch") || return 1 + qb=$(make_bar "$v" "$BW") + line="${LABEL}quota ${label}${RESET} ${qb} $(rank_color "$vrank")${v}%${RESET}" + [ -n "$vratio" ] && line="${line} ${SEP} ${KEY}pace $(rank_color "$vprank")$(fmt_pace "$vratio")${RESET}" + # A countdown is what the reader wants on a window that turns over five times + # a day; on the 7-day one it would read "138h12m" and mean nothing, so that + # window shows the wall-clock instant instead. + case "$label" in + 5h) r=$(fmt_reset_in "$at") && [ -n "$r" ] && line="${line} ${SEP} ${KEY}resets in ${SUBTEXT}${r}${RESET}" ;; + *) r=$(fmt_reset_at "$at") && [ -n "$r" ] && line="${line} ${SEP} ${KEY}resets ${SUBTEXT}${r}${RESET}" ;; + esac + [ "$v" -ge 100 ] && line="${line} ${SEP} ${RED}over cap${RESET}" + printf '%s' "$line" +} + +# render_spend — today / month-to-date / average per day, all read from the ONE +# ledger. Informational, not a cap: on a flat-rate Pro/Max plan the binding +# constraint is the quota above, not an absolute spend. Every figure includes +# subagent spend, and all three come from the same ledger rows as the "session" +# segment on the context line, so they are consistent by construction. +# reads: cost_today cost_month cost_avg_day RANK +render_spend() { + local line="" + [ -n "$cost_today" ] && line="${LABEL}spend today ${SUBTEXT}$(fmt_usd "$cost_today")${RESET}" + [ -n "$cost_month" ] && line="${line:+$line ${SEP} }${KEY}month ${SUBTEXT}$(fmt_usd "$cost_month")${RESET}" + [ "$RANK" -ge 4 ] && [ -n "$cost_avg_day" ] && line="${line:+$line ${SEP} }${KEY}average ${SUBTEXT}$(fmt_usd "$cost_avg_day")${OVERLAY}/day${RESET}" + printf '%s' "$line" +} diff --git a/plugins/statusline/assets/statusline-lib/session_state.sh b/plugins/statusline/assets/statusline-lib/session_state.sh new file mode 100644 index 0000000..2320972 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/session_state.sh @@ -0,0 +1,139 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 # This module's constants and outputs are consumed by +# sibling modules and by the composition root that sources them all; shellcheck +# analyses one file at a time and cannot follow that boundary. What actually +# proves each name is live is tests/statusline (which sources the whole set and +# asserts the exports exist) and the golden render diff, not this warning. +# statusline-lib/session_state.sh — the session facts the statusLine JSON omits. +# +# Single responsibility: reading per-session state out of the local files that +# the ledger and the hooks maintain. Everything in this module answers the same +# question — "what does this session look like beyond what the host told us?" — +# and changes when one of those on-disk contracts changes. Every reader is +# failure-tolerant by construction: an absent or malformed file leaves its +# outputs empty, and an empty output renders as an absent segment. +# +# Depends on: platform.sh (file_mtime). + +# --- Costs: ONE ledger, ~/.claude/costs.sh over ~/.claude/statusline-costs.jsonl +# Single source of truth for every dollar figure on this statusline (session, +# today, month). Replaces the former statusline-costs.py aggregate, which +# summed every assistant line of every transcript and therefore over-counted +# ~2.2x: Claude Code re-logs one API response 2-3 times (streaming / +# tool-continuation), so a correct total MUST deduplicate on +# message.id:requestId before pricing. Measured 2026-07-26 over 172 local +# transcripts: deduped $3438.84 vs raw $7645.04 (2.22x). +# source: benchmark scratchpad/price.sh (both engines, same pricing.json). +# +# costs.sh prices each session from its OWN transcript plus the full recursive +# /subagents/ subtree, so Task subagents, worktree-isolated agents +# and workflow agents are all billed — none of them appear in Claude Code's +# .cost.total_cost_usd, which covers the main thread only. Verified 2026-07-26: +# all 121 local agent transcripts live under /subagents/, including +# the 42 whose cwd is a .claude/worktrees/agent-* path. +COST_LEDGER_SH="${HOME}/.claude/costs.sh" +COST_LOG="${STATUSLINE_COST_LOG:-${HOME}/.claude/statusline-costs.jsonl}" + +# read_cost_ledger — price this session and read back the ledger totals. +# pre: $1 the session id, $2 the raw statusLine JSON (fed to the ledger's +# update, which is idempotent per session id and internally rate-limits +# its jq pricing pass, so this stays cheap at a 10s refresh interval). +# post: sets cost_session cost_today cost_month cost_avg_day, each either a +# numeric string or empty. Any non-numeric result (jq failure, empty +# ledger) is normalised to empty, which renders as an absent segment +# rather than as a wrong number. +read_cost_ledger() { + local session_id="$1" payload="$2" _v + cost_session=""; cost_today=""; cost_month=""; cost_avg_day="" + if [ -x "$COST_LEDGER_SH" ] && [ -n "$session_id" ]; then + printf '%s' "$payload" | "$COST_LEDGER_SH" update 2>/dev/null || true + cost_today=$("$COST_LEDGER_SH" today 2>/dev/null) || true + read -r cost_month cost_avg_day < <("$COST_LEDGER_SH" month 2>/dev/null) || true + # This session's own row: main + subagents, the figure .cost.total_cost_usd + # cannot give. Read back rather than recomputed — one grep, no second + # pricing pass. + if [ -r "$COST_LOG" ]; then + cost_session=$(grep -F "\"session_id\":\"${session_id}\"" "$COST_LOG" 2>/dev/null \ + | tail -1 | jq -r '.cost_usd // empty' 2>/dev/null) || true + fi + fi + # Numeric guard. Indirect read (${!v}) + printf -v write, never eval: the loop + # must not be able to execute whatever a malformed ledger row put in the + # variable. + for _v in cost_session cost_today cost_month cost_avg_day; do + case "${!_v}" in ''|*[!0-9.]*) printf -v "$_v" '%s' '' ;; esac + done +} + +# --- Per-session transcript telemetry (tok/s, compactions, last-response age, +# prompt-cache TTL). Backgrounded scan on a short TTL: the scanner reads only +# the transcript tail + appended bytes, so it is cheap, and the time-relative +# values (age, TTL) are recomputed live in bash from the cached last_ts so the +# countdown stays second-accurate between refreshes. --- +TXT_CACHE="${HOME}/.claude/.statusline-transcript-cache.json" +TXT_SCRIPT="${HOME}/.claude/statusline-transcript.py" +TXT_LOCK="${HOME}/.claude/.statusline-transcript.lock" +TXT_TTL=15 # refresh telemetry at most ~once per refresh-and-a-half +TXT_LOCK_TTL=60 # a scan that died mid-flight cannot block the next one + # for longer than this + +# read_transcript_telemetry — last-response timestamp, throughput and compaction +# count for the transcript at $1. +# pre: $1 the session's transcript path (may be empty), $2 current epoch seconds. +# post: sets txt_last_ts txt_tok_s txt_compactions, each either the cached value +# or empty. Empty when the cache is missing, stale-but-unrefreshed, or +# belongs to a DIFFERENT transcript — showing another session's throughput +# would be worse than showing none. May start one backgrounded scan. +read_transcript_telemetry() { + local transcript_path="$1" now_epoch="$2" txt_age txt_lock_age txt_path + txt_last_ts=""; txt_tok_s=""; txt_compactions="" + + if [ -n "$transcript_path" ] && [ -r "$TXT_SCRIPT" ]; then + txt_age=99999 + [ -r "$TXT_CACHE" ] && txt_age=$(( now_epoch - $(file_mtime "$TXT_CACHE") )) + if [ "$txt_age" -ge "$TXT_TTL" ]; then + txt_lock_age=99999 + [ -f "$TXT_LOCK" ] && txt_lock_age=$(( now_epoch - $(file_mtime "$TXT_LOCK") )) + if [ "$txt_lock_age" -ge "$TXT_LOCK_TTL" ]; then + ( touch "$TXT_LOCK"; python3 "$TXT_SCRIPT" "$transcript_path" >/dev/null 2>&1; rm -f "$TXT_LOCK" ) & + fi + fi + fi + + if [ -r "$TXT_CACHE" ]; then + read -r txt_path txt_last_ts txt_tok_s txt_compactions < <( + jq -r '"\(.path // "") \(.last_ts // "") \(.tok_per_s // "") \(.compactions // "")"' "$TXT_CACHE" 2>/dev/null + ) || true + # Only trust the cache if it belongs to THIS session's transcript. + [ "$txt_path" != "$transcript_path" ] && { txt_last_ts=""; txt_tok_s=""; txt_compactions=""; } + fi + return 0 +} + +# --- Subagent activity, from the SubagentStop tracker. Not in the statusline +# input, so read the per-session aggregate the hook maintains. Count and token +# volume only: the tracker's cost_usd is deliberately NOT read, because subagent +# dollars already sit inside the ledger's session figure (costs.sh prices the +# subagents/ subtree). Two independently priced numbers for the same spend can +# only diverge, so there is exactly one. --- + +# read_subagent_totals — how many subagents ran this session, and their tokens. +# pre: $1 the session id (may be empty). +# post: sets sub_count and sub_tokens. sub_count is empty when no tracker state +# exists OR when the count is zero — the segment is about activity, and +# "subagents 0" is noise on every session that never spawned one. +read_subagent_totals() { + local session_id="$1" state + sub_count=""; sub_tokens="" + [ -z "$session_id" ] && return 0 + state="/tmp/zetetic-subagents-${session_id}.json" + [ -r "$state" ] || return 0 + read -r sub_count sub_tokens < <( + jq -r '.totals as $t + | "\($t.count // 0) " + + "\(((($t.input_tokens // 0) + ($t.output_tokens // 0) + ($t.cache_tokens // 0))))"' \ + "$state" 2>/dev/null + ) || true + case "$sub_count" in ''|0|*[!0-9]*) sub_count="" ;; esac + return 0 +} diff --git a/plugins/statusline/assets/statusline-lib/severity.sh b/plugins/statusline/assets/statusline-lib/severity.sh new file mode 100644 index 0000000..e50ee81 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/severity.sh @@ -0,0 +1,153 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 # This module's constants and outputs are consumed by +# sibling modules and by the composition root that sources them all; shellcheck +# analyses one file at a time and cannot follow that boundary. What actually +# proves each name is live is tests/statusline (which sources the whole set and +# asserts the exports exist) and the golden render diff, not this warning. +# statusline-lib/severity.sh — the one severity scale and its thresholds. +# +# Single responsibility: deciding how alarming a number is. Every +# threshold-driven colour on this statusline resolves through this module, so +# two readings of the same quantity can never disagree about what "warn" means. +# It changes when a threshold policy changes, and for no other reason. Pure +# except for reading the context thresholds config.sh resolved. +# +# Depends on: palette.sh (the status colours). + +# --- Severity ranks -------------------------------------------------------- +# One ordered scale (0 ok / 1 warn / 2 danger), so the worse of two readings is +# a plain integer max instead of a colour-string comparison. +RANK_OK=0 +RANK_WARN=1 +RANK_DANGER=2 + +rank_color() { + case "$1" in + "$RANK_DANGER") echo "$RED" ;; + "$RANK_WARN") echo "$YELLOW" ;; + *) echo "$GREEN" ;; + esac +} + +# pick color for a given token count. +# pre: $1 an integer token count; WARN_TOKENS/SAVE_TOKENS resolved by +# config.sh (resolve_ctx_thresholds) before the first call. +token_color() { + local t="$1" + if [ "$t" -ge "$SAVE_TOKENS" ]; then echo "$RED" + elif [ "$t" -ge "$WARN_TOKENS" ]; then echo "$YELLOW" + else echo "$GREEN"; fi +} + +# Absolute quota reading: how much of the window's allowance is already spent. +# danger at 80% — the last fifth is the margin left to notice and slow down +# before lockout; warn at half. +QUOTA_DANGER_PCT=80 +QUOTA_WARN_PCT=50 +quota_rank() { + local p="$1" + if [ "$p" -ge "$QUOTA_DANGER_PCT" ]; then echo "$RANK_DANGER" + elif [ "$p" -ge "$QUOTA_WARN_PCT" ]; then echo "$RANK_WARN" + else echo "$RANK_OK"; fi +} +quota_color() { rank_color "$(quota_rank "$1")"; } + +# --- Pace: burn rate measured against the window's own clock ---------------- +# A quota reading alone cannot say whether it is a problem: 60% used is fine +# four hours into a five-hour window and alarming ten minutes in. The pace +# ratio divides the share spent by the share of the window elapsed. +# +# ratio = used% / elapsed% (printed here as an integer percent: 130 = 1.3x) +# +# Because elapsed% is the fraction of the window gone, used%/elapsed% IS the +# linear projection of usage at reset time: a ratio of 100 projects landing +# exactly on the cap. So the pace thresholds are not new constants — +# PACE_DANGER_RATIO is the cap itself, and PACE_WARN_RATIO is the point where +# the projection lands inside the absolute reading's own danger band. +PACE_DANGER_RATIO=100 # projects >= 100% used at reset +PACE_WARN_RATIO="$QUOTA_DANGER_PCT" # projects into the absolute danger band +PACE_MIN_ELAPSED_PCT=10 # Below this the denominator is under half an hour on + # the 5h window, so one burst dominates and the + # extrapolation swings wildly. Judgement, not a + # measured or published figure: it errs toward + # printing nothing rather than a number that flips + # colour every refresh. + +# Window lengths of the two rate-limit windows the statusLine input reports. +# source: docs.anthropic.com/en/docs/claude-code/costs + Anthropic usage-limit +# docs — Pro/Max usage is bounded by a 5-hour rolling window and a 7-day window. +RL_5H_WINDOW_S=18000 +RL_7D_WINDOW_S=604800 + +# pace_ratio — burn rate relative to the window clock. +# pre: $1 used percentage (non-negative integer), $2 resets_at epoch seconds, +# $3 window length in seconds, $4 current epoch seconds. +# post: on success prints the ratio as an integer percent (130 = 1.3x) and +# returns 0. Returns 1 and prints nothing when the inputs are not usable +# (non-numeric, zero-length window) or when too little of the window has +# elapsed for the projection to be informative (< PACE_MIN_ELAPSED_PCT). +# Elapsed time is clamped into [0, window] so a clock skew or a stale +# resets_at can only yield a boundary value, never a negative divisor. +pace_ratio() { + local used="$1" resets_at="$2" window="$3" now="$4" elapsed elapsed_pct + case "$used" in ''|*[!0-9]*) return 1 ;; esac + case "$resets_at" in ''|*[!0-9]*) return 1 ;; esac + case "$window" in ''|*[!0-9]*|0) return 1 ;; esac + case "$now" in ''|*[!0-9]*) return 1 ;; esac + elapsed=$(( window - ( resets_at - now ) )) + [ "$elapsed" -lt 0 ] && elapsed=0 + [ "$elapsed" -gt "$window" ] && elapsed="$window" + elapsed_pct=$(( elapsed * 100 / window )) + [ "$elapsed_pct" -lt "$PACE_MIN_ELAPSED_PCT" ] && return 1 + echo $(( used * 100 / elapsed_pct )) +} + +pace_rank() { + local r="$1" + if [ "$r" -ge "$PACE_DANGER_RATIO" ]; then echo "$RANK_DANGER" + elif [ "$r" -ge "$PACE_WARN_RATIO" ]; then echo "$RANK_WARN" + else echo "$RANK_OK"; fi +} + +# quota_reading — resolve one rate-limit window into everything the render needs. +# pre: $1 raw used_percentage (any numeric form jq returned), $2 resets_at +# epoch seconds, $3 window length seconds, $4 current epoch seconds. +# post: on success prints " " and returns 0: +# pct — percentage rounded to an integer +# rank — the WORSE of the absolute and pace severities; this is the +# window's overall state, carried by the percentage +# pace_rank — the pace severity alone, carried by the pace figure so a +# harmless burn rate is not painted red just because the window +# happens to be nearly spent (and vice versa) +# ratio — pace ratio as an integer percent, empty when not informative. +# Last field on purpose: it is the only one that can be empty, and +# `read` with default IFS collapses interior blank fields but +# leaves a trailing one empty, so a caller's variables cannot shift. +# Returns 1 and prints nothing when $1 is not numeric. +# The worse-of-two rule is what makes the two readings safe to combine: a window +# at 95% with the clock nearly up has a harmless pace but is still nearly spent, +# and a window at 30% ten minutes in has a harmless absolute but is burning at +# 3x. Either alone would stay green in one of those cases. +quota_reading() { + local raw="$1" at="$2" win="$3" now="$4" pct ratio rank prank + # Validate BEFORE awk: awk reads an unquoted non-numeric token as an + # uninitialised variable and prints 0, so "abc" would silently render as a + # healthy 0% instead of being reported as unusable. + case "$raw" in ''|*[!0-9.]*) return 1 ;; esac + pct=$(LC_NUMERIC=C awk "BEGIN{printf \"%.0f\",$raw}" 2>/dev/null) + case "$pct" in ''|*[!0-9]*) return 1 ;; esac + rank=$(quota_rank "$pct") + prank="$RANK_OK" + if ratio=$(pace_ratio "$pct" "$at" "$win" "$now"); then + prank=$(pace_rank "$ratio") + [ "$prank" -gt "$rank" ] && rank="$prank" + else + ratio="" + fi + # newline-terminated: `read` reports failure on an unterminated line, which + # would make every successful reading look like a failed one at the call site. + printf '%s %s %s %s\n' "$pct" "$rank" "$prank" "$ratio" +} + +# pace ratio as the multiple it is: 130 -> "1.3x" +fmt_pace() { LC_NUMERIC=C awk "BEGIN{printf \"%.1fx\",$1/100}"; } diff --git a/tests/statusline/measure_widths.sh b/tests/statusline/measure_widths.sh new file mode 100755 index 0000000..bb48b62 --- /dev/null +++ b/tests/statusline/measure_widths.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# tests/statusline/measure_widths.sh — measure the rendered width of each +# verbosity preset, so the width→preset cap in statusline-command.sh rests on a +# measurement instead of a guess. +# +# Method: render the script against one representative populated payload at an +# effectively unlimited terminal width (so fit_line never trims), then measure +# every emitted line with the script's OWN vislen — the same function that +# decides trimming at runtime, so the measurement and the decision cannot use +# different rulers (in particular the same treatment of the ambiguous-width +# block glyphs). Emitted lines carry INTERPRETED escapes (printf '%b' already +# ran), whereas vislen's contract is the uninterpreted form, so the real SGR +# sequences are stripped first and vislen counts the remaining characters. +# Reports the widest line per preset. +# +# The cap threshold for a preset is that preset's widest line divided by +# FIT_RATIO (the fraction of the terminal a line is allowed to occupy), i.e. +# the narrowest terminal in which the preset still renders untrimmed. +# +# Isolation: STATUSLINE_COST_LOG points at a throwaway ledger under a temp dir, +# so a measurement run never touches ~/.claude/statusline-costs.jsonl. All +# fixture data is synthetic. +set -uo pipefail + +SCRIPT_UNDER_TEST="${SCRIPT_UNDER_TEST:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/plugins/statusline/assets/statusline-command.sh}" +[ -r "$SCRIPT_UNDER_TEST" ] || { echo "introuvable: $SCRIPT_UNDER_TEST" >&2; exit 1; } + +# vislen(), FIT_RATIO and the palette come from the script under test itself. +STATUSLINE_SOURCE_ONLY=1 source "$SCRIPT_UNDER_TEST" +command -v vislen >/dev/null || { echo "vislen absent de $SCRIPT_UNDER_TEST" >&2; exit 1; } + +TMPDIR_M="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_M"' EXIT + +# Representative payload: every optional group populated, with field values at +# the long end of what is realistic (a 17-character directory, a six-figure +# token count, three-digit churn). A payload with empty groups would measure a +# preset narrower than it renders in practice, and the cap would then let a +# preset through into a terminal that cannot hold it. +now=$(date +%s) +cat > "$TMPDIR_M/payload.json" <&2; return 1; } + return 0 +} +assert_le() { + local actual="$1" bound="$2" msg="${3:-assert_le}" + [ "$actual" -le "$bound" ] || { echo "FAIL: ${msg} — ${actual} > ${bound}" >&2; return 1; } + return 0 +} + +setup() { TEST_TMPDIR="$(mktemp -d)"; export TEST_TMPDIR; } +teardown() { [ -n "${TEST_TMPDIR:-}" ] && rm -rf "$TEST_TMPDIR"; unset TEST_TMPDIR; } + +run_test() { + local test_name="$1" + ( setup; trap teardown EXIT; STATUSLINE_SOURCE_ONLY=1 source "$SCRIPT_UNDER_TEST"; "$test_name" ) + local status=$? + [ $status -eq 0 ] && echo "PASS: ${test_name}" || echo "FAIL: ${test_name}" + return $status +} + +# =========================== vislen ====================================== + +function test_vislen_plain_ascii() { + assert_eq "$(vislen "abc")" "3" "ascii" || return 1 + assert_eq "$(vislen "")" "0" "chaine vide" +} + +# SGR sequences are zero-width: the same visible text must measure the same +# whether or not it is coloured. +function test_vislen_ignores_sgr() { + assert_eq "$(vislen "${RED}abc${RESET}")" "3" "texte colore" || return 1 + assert_eq "$(vislen "${RED}${RESET}")" "0" "sequences seules" || return 1 + assert_eq "$(vislen "a${GREEN}b${RESET}c")" "3" "sequence au milieu" +} + +# The block/box glyphs this renderer uses are multi-byte but single-column; +# counting their bytes would over-measure every bar by 20 columns and make +# fit_line trim lines that fit. +function test_vislen_multibyte_glyphs_count_one_column() { + assert_eq "$(vislen "█")" "1" "bloc plein" || return 1 + assert_eq "$(vislen "░")" "1" "bloc vide" || return 1 + assert_eq "$(vislen "│")" "1" "separateur" || return 1 + assert_eq "$(vislen "…")" "1" "ellipse" +} + +function test_vislen_measures_a_real_bar() { + # make_bar 62 10 is ten cells, each one column, whatever colouring it applies + assert_eq "$(vislen "$(make_bar 62 10)")" "10" "barre 10 cellules" +} + +# A truncated write (or a future palette bug) must not spin the scanner: an +# unterminated SGR ends the scan instead of looping on a string that never +# shortens. +function test_vislen_unterminated_sgr_terminates() { + assert_eq "$(vislen "abc\\033[38;2;1")" "3" "SGR non terminee" +} + +# =========================== vistrunc ==================================== + +function test_vistrunc_leaves_short_input_untouched() { + assert_eq "$(vistrunc "abc" 10)" "abc" "entree deja courte" +} + +function test_vistrunc_respects_budget_and_marks_the_cut() { + local out; out="$(vistrunc "abcdefghij" 5)" + assert_le "$(vislen "$out")" "5" "budget respecte" || return 1 + case "$out" in *…*) ;; *) echo "FAIL: coupe non signalee" >&2; return 1 ;; esac + return 0 +} + +# The cut must never land inside an escape sequence, and must close the colour +# it was in — otherwise the truncation bleeds into the rest of the terminal. +function test_vistrunc_never_splits_an_escape() { + local out; out="$(vistrunc "${RED}abcdefghij${RESET}" 5)" + assert_le "$(vislen "$out")" "5" "budget respecte" || return 1 + case "$out" in *"\\033[38;2;232;97;84m"*) ;; *) echo "FAIL: sequence tronquee" >&2; return 1 ;; esac + case "$out" in *"\\033[0m") ;; *) echo "FAIL: couleur non refermee" >&2; return 1 ;; esac + return 0 +} + +function test_vistrunc_zero_budget_is_empty() { + assert_eq "$(vistrunc "abcdef" 0)" "" "budget nul" +} + +# =========================== fit_line ==================================== + +# Helper: build a line from segments joined the way the renderer joins them. +_mkline() { + local out="" s + for s in "$@"; do out="${out:+$out ${SEP} }$s"; done + printf '%s' "$out" +} + +function test_fit_line_passthrough_when_it_fits() { + local line; line="$(_mkline aaa bbb ccc)" + assert_eq "$(fit_line "$line" 200)" "$line" "ligne deja dans le budget" +} + +# Overflow drops WHOLE trailing segments — the head of the line, which carries +# the most important material, survives intact. +function test_fit_line_drops_lowest_priority_tail() { + local line out; line="$(_mkline aaa bbb ccc ddd)" + out="$(fit_line "$line" 12)" + assert_le "$(vislen "$out")" "12" "budget respecte" || return 1 + case "$out" in aaa*) ;; *) echo "FAIL: tete perdue" >&2; return 1 ;; esac + case "$out" in *ddd*) echo "FAIL: queue conservee hors budget" >&2; return 1 ;; esac + return 0 +} + +# A line whose FIRST segment already overflows is truncated in place, never +# dropped: a status line that renders empty tells the reader nothing at all. +function test_fit_line_truncates_oversized_first_segment() { + local out; out="$(fit_line "$(_mkline aaaaaaaaaaaaaaaaaaaa bbb)" 8)" + assert_le "$(vislen "$out")" "8" "budget respecte" || return 1 + [ -n "$out" ] || { echo "FAIL: ligne vide" >&2; return 1; } + return 0 +} + +function test_fit_line_never_returns_empty() { + local w out + for w in 1 2 3 5 8 13 21 34; do + out="$(fit_line "$(_mkline "${RED}alpha${RESET}" "${GREEN}beta${RESET}" gamma)" "$w")" + [ -z "$out" ] && { echo "FAIL: vide a largeur ${w}" >&2; return 1; } + assert_le "$(vislen "$out")" "$w" "budget a largeur ${w}" || return 1 + done + return 0 +} + +# A budget the caller could not resolve must not silently blank the line. +function test_fit_line_invalid_budget_passes_through() { + local line; line="$(_mkline aaa bbb)" + assert_eq "$(fit_line "$line" "")" "$line" "budget vide" || return 1 + assert_eq "$(fit_line "$line" 0)" "$line" "budget nul" +} + +# =========================== pace_ratio ================================== +# Fixture clock: NOW is fixed so the tests do not depend on wall time. +NOW=1000000000 + +function test_pace_ratio_on_pace_is_100() { + # half the window gone, half the quota spent -> exactly on pace + assert_eq "$(pace_ratio 50 $((NOW + RL_5H_WINDOW_S / 2)) "$RL_5H_WINDOW_S" "$NOW")" "100" "50%/50%" +} + +function test_pace_ratio_under_and_over() { + assert_eq "$(pace_ratio 25 $((NOW + RL_5H_WINDOW_S / 2)) "$RL_5H_WINDOW_S" "$NOW")" "50" "sous le rythme" || return 1 + assert_eq "$(pace_ratio 75 $((NOW + RL_5H_WINDOW_S / 2)) "$RL_5H_WINDOW_S" "$NOW")" "150" "au-dessus du rythme" +} + +# Below PACE_MIN_ELAPSED_PCT the denominator is too small for the extrapolation +# to mean anything, so the function reports nothing rather than a number that +# would flip colour on every refresh. +function test_pace_ratio_silent_early_in_window() { + local remaining=$(( RL_5H_WINDOW_S - RL_5H_WINDOW_S * (PACE_MIN_ELAPSED_PCT - 1) / 100 )) + pace_ratio 5 $((NOW + remaining)) "$RL_5H_WINDOW_S" "$NOW" >/dev/null 2>&1 \ + && { echo "FAIL: doit rester muet sous le seuil" >&2; return 1; } + return 0 +} + +# Clock skew or a stale resets_at must clamp, never divide by a negative or +# zero elapsed time. +function test_pace_ratio_clamps_out_of_range_clock() { + # reset already in the past -> window fully elapsed -> ratio == used% + assert_eq "$(pace_ratio 42 $((NOW - 99999)) "$RL_5H_WINDOW_S" "$NOW")" "42" "reset passe" || return 1 + # reset further away than the window is long -> nothing elapsed -> silent + pace_ratio 42 $((NOW + RL_5H_WINDOW_S * 3)) "$RL_5H_WINDOW_S" "$NOW" >/dev/null 2>&1 \ + && { echo "FAIL: fenetre non entamee doit rester muette" >&2; return 1; } + return 0 +} + +function test_pace_ratio_rejects_bad_input() { + pace_ratio "abc" "$NOW" "$RL_5H_WINDOW_S" "$NOW" >/dev/null 2>&1 && { echo "FAIL: pct non numerique" >&2; return 1; } + pace_ratio 50 "later" "$RL_5H_WINDOW_S" "$NOW" >/dev/null 2>&1 && { echo "FAIL: reset non numerique" >&2; return 1; } + pace_ratio 50 "$NOW" 0 "$NOW" >/dev/null 2>&1 && { echo "FAIL: fenetre nulle" >&2; return 1; } + pace_ratio "" "" "" "" >/dev/null 2>&1 && { echo "FAIL: entrees vides" >&2; return 1; } + return 0 +} + +# =========================== ranks ======================================= + +function test_quota_rank_boundaries() { + assert_eq "$(quota_rank 0)" "$RANK_OK" "0%" || return 1 + assert_eq "$(quota_rank $((QUOTA_WARN_PCT - 1)))" "$RANK_OK" "sous warn" || return 1 + assert_eq "$(quota_rank "$QUOTA_WARN_PCT")" "$RANK_WARN" "warn" || return 1 + assert_eq "$(quota_rank $((QUOTA_DANGER_PCT - 1)))" "$RANK_WARN" "sous danger" || return 1 + assert_eq "$(quota_rank "$QUOTA_DANGER_PCT")" "$RANK_DANGER" "danger" +} + +function test_pace_rank_boundaries() { + assert_eq "$(pace_rank 0)" "$RANK_OK" "0x" || return 1 + assert_eq "$(pace_rank $((PACE_WARN_RATIO - 1)))" "$RANK_OK" "sous warn" || return 1 + assert_eq "$(pace_rank "$PACE_WARN_RATIO")" "$RANK_WARN" "warn" || return 1 + assert_eq "$(pace_rank $((PACE_DANGER_RATIO - 1)))" "$RANK_WARN" "sous cap" || return 1 + assert_eq "$(pace_rank "$PACE_DANGER_RATIO")" "$RANK_DANGER" "projette le cap" +} + +function test_rank_color_maps_the_scale() { + assert_eq "$(rank_color "$RANK_OK")" "$GREEN" "ok" || return 1 + assert_eq "$(rank_color "$RANK_WARN")" "$YELLOW" "warn" || return 1 + assert_eq "$(rank_color "$RANK_DANGER")" "$RED" "danger" +} + +# =========================== quota_reading =============================== + +# The pace ratio is the only field that can be empty, so it is emitted last: +# `read` collapses interior blank fields but leaves a trailing one empty. If the +# order ever changes, the caller's variables silently shift by one. +function test_quota_reading_fields_do_not_shift_when_pace_is_absent() { + local pct rank prank ratio + # reset far beyond the window -> nothing elapsed -> no pace + read -r pct rank prank ratio < <(quota_reading 42 $((NOW + RL_5H_WINDOW_S * 3)) "$RL_5H_WINDOW_S" "$NOW") + assert_eq "$pct" "42" "pct" || return 1 + assert_eq "$rank" "$RANK_OK" "rank" || return 1 + assert_eq "$prank" "$RANK_OK" "pace rank" || return 1 + assert_eq "$ratio" "" "ratio absent" +} + +function test_quota_reading_rounds_the_percentage() { + local pct rest + read -r pct rest < <(quota_reading 68.9 $((NOW + 1)) "$RL_7D_WINDOW_S" "$NOW") + assert_eq "$pct" "69" "arrondi" +} + +# The combined rank is the WORSE of the two readings — this is the whole point +# of showing pace at all. +function test_quota_reading_combines_worse_of_absolute_and_pace() { + local pct rank prank ratio + # low absolute (30%), burning at 3x: absolute alone would stay green + read -r pct rank prank ratio < <(quota_reading 30 $((NOW + RL_5H_WINDOW_S * 9 / 10)) "$RL_5H_WINDOW_S" "$NOW") + assert_eq "$ratio" "300" "rythme 3x" || return 1 + assert_eq "$prank" "$RANK_DANGER" "rythme dangereux" || return 1 + assert_eq "$rank" "$RANK_DANGER" "rang combine suit le pire" || return 1 + + # high absolute (85% = danger) with the window essentially over: the pace is + # only a warning (85% projected lands in the absolute danger band but not at + # the cap), so the combined rank has to come from the absolute side. + # Note a pace rank of OK is unreachable here by construction: with the window + # fully elapsed the ratio equals used%, so any percentage high enough to be an + # absolute danger is also at least a pace warning. A nearly-spent window can + # never read as healthy — which is the intended property. + read -r pct rank prank ratio < <(quota_reading 85 $((NOW + 1)) "$RL_5H_WINDOW_S" "$NOW") + assert_eq "$prank" "$RANK_WARN" "rythme en avertissement" || return 1 + assert_eq "$rank" "$RANK_DANGER" "rang combine suit l'absolu" +} + +function test_quota_reading_rejects_absent_window() { + quota_reading "" "" "$RL_5H_WINDOW_S" "$NOW" >/dev/null 2>&1 && { echo "FAIL: pourcentage absent" >&2; return 1; } + quota_reading "abc" "$NOW" "$RL_5H_WINDOW_S" "$NOW" >/dev/null 2>&1 && { echo "FAIL: non numerique" >&2; return 1; } + return 0 +} + +# =========================== file_mtime ================================== + +function test_file_mtime_reads_an_existing_file() { + local f="$TEST_TMPDIR/stamp"; : > "$f" + local m; m="$(file_mtime "$f")" + case "$m" in ''|*[!0-9]*) echo "FAIL: mtime non numerique [$m]" >&2; return 1 ;; esac + [ "$m" -gt 0 ] || { echo "FAIL: mtime nul sur fichier existant" >&2; return 1; } + return 0 +} + +# 0 is the documented "unavailable" answer; anything else (empty, an error +# string) would break the `now - mtime` arithmetic at every call site. +function test_file_mtime_missing_file_is_zero() { + assert_eq "$(file_mtime "$TEST_TMPDIR/absent")" "0" "fichier absent" +} + +# =========================== static checks =============================== + +# Every mtime read must go through file_mtime: a bare `stat -f` is BSD-only and +# silently reads 0 on Linux, which forces a cache refresh on every invocation. +function test_static_no_bare_stat_call() { + # Skip comment lines (which legitimately name both spellings) and the two + # assignments inside file_mtime itself; anything left is a bare call. + grep -nE "stat -[fc] " "${SOURCES_UNDER_TEST[@]}" \ + | grep -vE ':[[:space:]]*#' \ + | grep -vE ':[[:space:]]*m=\$\(stat' \ + && { echo "FAIL: appel stat hors de file_mtime" >&2; return 1; } + return 0 +} + +# Every emitted line must pass through fit_line (via emit), or it can overflow +# the terminal and cost the block a row. +function test_static_every_line_is_emitted_through_fit() { + grep -n "printf '%b\\\\n'" "${SOURCES_UNDER_TEST[@]}" | grep -v 'fitted' \ + && { echo "FAIL: ligne emise sans passer par fit_line" >&2; return 1; } + return 0 +} + +# =========================== identity line =============================== +# Segment order IS priority order: fit_line drops from the tail. These tests pin +# the ORDER, not the text, because the order is the design decision — the +# directory must outlive the session dials on a terminal too narrow for both. + +# Fixture: a populated identity line, rendered through the real function. +# `local` is visible to callees in bash, so render_identity reads these. +_identity_fixture() { + local model="Opus 5" dir="session-optimizer" effort="high" thinking="true" + render_identity +} + +function test_identity_puts_dir_before_the_session_dials() { + local line; line="$(_identity_fixture)" + case "$line" in + *dir*effort*) return 0 ;; + *) echo "FAIL: dir n'est pas avant effort [${line}]" >&2; return 1 ;; + esac +} + +# The regression this pins: at a budget too small for the whole line, the +# directory has to survive and the session dials are what go. +function test_identity_keeps_dir_when_the_terminal_is_narrow() { + local line out; line="$(_identity_fixture)" + [ "$(vislen "$line")" -gt 40 ] || { echo "FAIL: fixture trop courte pour serrer" >&2; return 1; } + out="$(fit_line "$line" 40)" + assert_le "$(vislen "$out")" "40" "budget respecte" || return 1 + case "$out" in *session-optimizer*) ;; *) echo "FAIL: dir perdu au serrage [${out}]" >&2; return 1 ;; esac + case "$out" in *thinking*) echo "FAIL: la queue a survecu au lieu d'etre coupee" >&2; return 1 ;; esac + return 0 +} + +# =========================== modules ===================================== + +# §4.1: no source file over 500 lines. The whole point of the split. +function test_static_no_file_exceeds_the_size_cap() { + local f n rc=0 + for f in "${SOURCES_UNDER_TEST[@]}"; do + n=$(wc -l < "$f" | tr -d ' ') + [ "$n" -gt "$MAX_FILE_LINES" ] && { echo "FAIL: $(basename "$f") ${n} lignes > ${MAX_FILE_LINES}" >&2; rc=1; } + done + return $rc +} + +# The renderer is only useful if the modules actually load: this asserts the +# whole set is present and sourceable, and that sourcing still defines a +# function from EACH module rather than silently loading a subset. +function test_modules_all_load_and_define_their_functions() { + local fn + for fn in file_mtime heat_rgb vislen quota_reading fmt_tokens \ + resolve_ctx_thresholds collect_git read_cost_ledger \ + probe_cols render_identity; do + declare -F "$fn" >/dev/null || { echo "FAIL: ${fn} non definie apres source" >&2; return 1; } + done + return 0 +} + +# A missing module must fail loudly. Rendering around it is impossible — without +# palette.sh there are no colours to print an error in — so the contract is: +# non-zero exit, and a line on the statusline itself naming the missing file. A +# silently blank status bar reads as "nothing is happening", which is the one +# outcome that must not happen. +function test_missing_module_fails_loudly() { + local out rc + out=$(STATUSLINE_LIB="$TEST_TMPDIR/absent-lib" bash "$SCRIPT_UNDER_TEST" /dev/null) + rc=$? + [ "$rc" -eq 0 ] && { echo "FAIL: sortie 0 malgre un module manquant" >&2; return 1; } + case "$out" in + *"platform.sh"*) ;; + *) echo "FAIL: le module manquant n'est pas nomme [${out}]" >&2; return 1 ;; + esac + return 0 +} + +# Portable Fisher-Yates shuffle using bash's builtin $RANDOM — no shuf/coreutils +# dependency, so the suite runs on macOS's stock bash 3.2 as well as GNU bash. +shuffle_tests() { + local -a arr=("$@") + local n=${#arr[@]} i j tmp + i=$n + while [ "$i" -gt 1 ]; do + i=$((i-1)) + j=$((RANDOM % (i+1))) + tmp="${arr[$i]}"; arr[$i]="${arr[$j]}"; arr[$j]="$tmp" + done + printf '%s\n' "${arr[@]}" +} + +main() { + local tests=( + test_vislen_plain_ascii test_vislen_ignores_sgr + test_vislen_multibyte_glyphs_count_one_column test_vislen_measures_a_real_bar + test_vislen_unterminated_sgr_terminates + test_vistrunc_leaves_short_input_untouched + test_vistrunc_respects_budget_and_marks_the_cut + test_vistrunc_never_splits_an_escape test_vistrunc_zero_budget_is_empty + test_fit_line_passthrough_when_it_fits + test_fit_line_drops_lowest_priority_tail + test_fit_line_truncates_oversized_first_segment + test_fit_line_never_returns_empty test_fit_line_invalid_budget_passes_through + test_pace_ratio_on_pace_is_100 test_pace_ratio_under_and_over + test_pace_ratio_silent_early_in_window + test_pace_ratio_clamps_out_of_range_clock test_pace_ratio_rejects_bad_input + test_quota_rank_boundaries test_pace_rank_boundaries test_rank_color_maps_the_scale + test_quota_reading_fields_do_not_shift_when_pace_is_absent + test_quota_reading_rounds_the_percentage + test_quota_reading_combines_worse_of_absolute_and_pace + test_quota_reading_rejects_absent_window + test_file_mtime_reads_an_existing_file test_file_mtime_missing_file_is_zero + test_static_no_bare_stat_call test_static_every_line_is_emitted_through_fit + test_identity_puts_dir_before_the_session_dials + test_identity_keeps_dir_when_the_terminal_is_narrow + test_static_no_file_exceeds_the_size_cap + test_modules_all_load_and_define_their_functions + test_missing_module_fails_loudly + ) + local shuffled=() line + while IFS= read -r line; do shuffled+=("$line"); done < <(shuffle_tests "${tests[@]}") + local fail_count=0 t + for t in "${shuffled[@]}"; do run_test "$t" || fail_count=$((fail_count+1)); done + echo "Total: ${#shuffled[@]} — Echecs: ${fail_count}" + [ "$fail_count" -eq 0 ] +} +main "$@" diff --git a/tests/statusline/test_heat_rgb.sh b/tests/statusline/test_heat_rgb.sh index 76700de..66b23e8 100755 --- a/tests/statusline/test_heat_rgb.sh +++ b/tests/statusline/test_heat_rgb.sh @@ -12,6 +12,17 @@ set -uo pipefail SCRIPT_UNDER_TEST="${SCRIPT_UNDER_TEST:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/plugins/statusline/assets/statusline-command.sh}" +# The renderer is a composition root plus a directory of modules (heat_rgb and +# make_bar live in statusline-lib/palette.sh, token_color in severity.sh). A +# static check that read only the main script would pass vacuously, so the +# checks below sweep the whole source set. +LIB_UNDER_TEST="${LIB_UNDER_TEST:-$(dirname "$SCRIPT_UNDER_TEST")/statusline-lib}" +SOURCES_UNDER_TEST=("$SCRIPT_UNDER_TEST") +for _f in "$LIB_UNDER_TEST"/*.sh; do + [ -r "$_f" ] && SOURCES_UNDER_TEST+=("$_f") +done +unset _f + # Expected palette values — asserted independently of the script's own HEAT_* # constants (except HEAT_3, whose exact terracotta-attenuated RGB is derived # from a scripted oklch->srgb conversion and is inherently the same computed @@ -173,24 +184,32 @@ function test_make_bar_concurrent_instances() { # --- static / integration checks against the script source --- function test_static_no_lerp_or_gradrgb_residue() { - grep -q -E "\\bgrad_rgb\\b" "$SCRIPT_UNDER_TEST" && { echo "FAIL: grad_rgb residuel" >&2; return 1; } - grep -q -E "lerp" "$SCRIPT_UNDER_TEST" && { echo "FAIL: lerp residuel" >&2; return 1; } + grep -q -E "\\bgrad_rgb\\b" "${SOURCES_UNDER_TEST[@]}" && { echo "FAIL: grad_rgb residuel" >&2; return 1; } + grep -q -E "lerp" "${SOURCES_UNDER_TEST[@]}" && { echo "FAIL: lerp residuel" >&2; return 1; } return 0 } function test_static_heat_track_no_semaphore_colors() { # GREEN/YELLOW/RED must not appear inside heat_rgb()/make_bar() bodies - grep -q -E "(GREEN|YELLOW|RED)" <(sed -n '/^heat_rgb()/,/^}/p;/^make_bar()/,/^}/p' "$SCRIPT_UNDER_TEST") && { echo "FAIL: ref semaphore dans la jauge" >&2; return 1; } + grep -q -E "(GREEN|YELLOW|RED)" <(sed -n '/^heat_rgb()/,/^}/p;/^make_bar()/,/^}/p' "${SOURCES_UNDER_TEST[@]}") && { echo "FAIL: ref semaphore dans la jauge" >&2; return 1; } return 0 } +# token_color must exist exactly once across the source set: the split moved it +# out of the main script, and two definitions would mean one silently shadows +# the other depending on module load order. function test_static_token_color_unaffected() { - grep -q -E "^token_color\\(\\)" "$SCRIPT_UNDER_TEST" || { echo "FAIL: token_color introuvable" >&2; return 1; } - return 0 + local n + n=$(grep -hcE "^token_color\\(\\)" "${SOURCES_UNDER_TEST[@]}" | awk '{s+=$1} END{print s+0}') + assert_eq "$n" "1" "definitions de token_color" } function test_static_no_eval_no_unquoted_expansion() { - grep -q -E "\\beval\\b" "$SCRIPT_UNDER_TEST" && { echo "FAIL: eval detecte" >&2; return 1; } + # Full-line comments are skipped: the rule against this builtin is worth + # explaining at the site that had to avoid it, and a comment cannot execute + # anything. Only lines that could actually run are checked. + grep -nE "\\beval\\b" "${SOURCES_UNDER_TEST[@]}" | grep -vE ':[[:space:]]*#' \ + && { echo "FAIL: eval detecte" >&2; return 1; } return 0 } From 8af5a742204bb89638b4f1324217213a0070b1f1 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 13:25:40 +0200 Subject: [PATCH 3/7] docs(statusline): install the module directory, retire the emoji layout, gate in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install path. The renderer is no longer one file: the skill and the SessionStart auto-update hook now place costs.sh, pricing.json and the whole statusline-lib/ directory, and delete a leftover ~/.claude/statusline-costs.py. The lib is installed as a WHOLE directory — the renderer refuses to start on a missing module, so a half-updated module set is worse than an absent one. Post-install verification checks each of the ten modules and both execute bits. CI. It ran one of the two statusline suites and shellchecked one file, so nothing added in this series would have been exercised. It now runs both suites and shellchecks the renderer, every module, and costs.sh — which is why that file's six pre-existing findings were fixed in the ledger commit rather than excluded from the step. pricing.json joins the JSON validation. Docs. Both READMEs still described an emoji layout (🌿 branch, 💰 cost, 🎯 gauges) that the word-based design-system rendering had already replaced, a `grad_rgb` continuous interpolation that the discrete 4-palier heat track replaced, and statusline-costs.py. They now document what ships: the segment map, the module table, pace, and width fitting. plugin.json and the marketplace entry still said "Catppuccin Mocha". Versions: statusline plugin and skill 2.0.0 -> 2.1.0, marketplace metadata to match. CHANGELOG entry records the removal of statusline-costs.py and the module directory as the two breaking changes for anyone installing manually. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- .claude-plugin/marketplace.json | 6 +- .github/workflows/ci.yml | 13 +- CHANGELOG.md | 70 +++++++++ README.md | 2 +- plugins/statusline/.claude-plugin/plugin.json | 4 +- plugins/statusline/README.fr.md | 138 ++++++++++++++---- plugins/statusline/README.md | 130 +++++++++++++---- plugins/statusline/hooks/hooks.json | 2 +- plugins/statusline/skills/statusline/SKILL.md | 65 +++++++-- 9 files changed, 352 insertions(+), 78 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a1572da..6d812a3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Session optimizer, split into three independently installable plugins: context-guard (Stop-hook context budget + memory-writer checkpoint subagent + subagent spend tracker), refine-gate (UserPromptSubmit prompt-binding gate + /refine skill), and statusline (multi-line status bar + install skill).", - "version": "2.0.0" + "version": "2.1.0" }, "plugins": [ { @@ -57,8 +57,8 @@ { "name": "statusline", "source": "./plugins/statusline", - "description": "Multi-line Claude Code statusline with RGB-gradient context bars tied to per-model checkpoint thresholds (shared with context-guard), monthly cost tracking, per-session telemetry, rate-limit gauges, and live subagent spend. Ships an install skill that copies the bundled assets into ~/.claude and wires settings.json.", - "version": "2.0.0", + "description": "Multi-line status bar: a discrete heat-track context bar tied to per-model checkpoint thresholds (shared with context-guard), one deduplicated cost ledger that prices each session from its own transcript plus its subagents, 5h/7d rate-limit gauges with burn-rate pacing (the projection of usage at reset, not just the percentage), per-session telemetry (tok/s, compactions, prompt-cache countdown), and terminal-width fitting that drops the lowest-priority segments instead of letting the host truncate. Words, not emoji. Ships an install skill.", + "version": "2.1.0", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a870f9..4755902 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,11 +28,17 @@ jobs: - name: Subagent tracker tests run: pytest tests/test_subagent_usage.py -v - - name: Statusline unit tests + - name: Statusline heat-track tests run: bash tests/statusline/test_heat_rgb.sh - - name: Shellcheck statusline script - run: shellcheck plugins/statusline/assets/statusline-command.sh + - name: Statusline fitting, pace and module tests + run: bash tests/statusline/test_fit_and_pace.sh + + - name: Shellcheck statusline renderer, modules and ledger + run: | + shellcheck plugins/statusline/assets/statusline-command.sh + shellcheck plugins/statusline/assets/statusline-lib/*.sh + shellcheck plugins/statusline/assets/costs.sh - name: Validate marketplace, plugin, and hook JSON run: | @@ -45,3 +51,4 @@ jobs: done python -m json.tool plugins/statusline/assets/statusline-budget.json > /dev/null python -m json.tool plugins/statusline/assets/ctxguard-thresholds.json > /dev/null + python -m json.tool plugins/statusline/assets/pricing.json > /dev/null diff --git a/CHANGELOG.md b/CHANGELOG.md index 53efc5e..f71f7a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,76 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2026-07-26 + +Statusline only. No change to context-guard or refine-gate. + +### BREAKING + +- **`statusline-costs.py` is removed.** Every dollar figure now comes from one + ledger, `costs.sh` over `~/.claude/statusline-costs.jsonl`. The Python + aggregator summed every assistant line of every transcript and over-counted + ~2.2x, because Claude Code re-logs one API response 2-3 times (streaming / + tool continuation); a correct total must deduplicate on + `message.id:requestId` before pricing. Measured over 172 local transcripts: + $3438.84 deduped vs $7645.04 raw. `costs.sh` also prices the full recursive + `/subagents/` subtree, so Task, worktree-isolated and workflow + agents are all billed — none of them appear in Claude Code's own + `.cost.total_cost_usd`. Installs carrying a stale `~/.claude/statusline-costs.py` + should delete it; the SessionStart hook now does so. +- **The renderer ships as a directory.** `statusline-command.sh` is a + composition root that sources `statusline-lib/*.sh`, which must be installed + next to it. A missing module is a hard failure that names the file rather + than a partial statusline. `$STATUSLINE_LIB` overrides the location. + +### Added + +- `costs.sh` + `pricing.json` as bundled assets (the ledger and its prices). +- Terminal-width fitting: each line is held to 85% of the probed width, and + `fit_line` drops whole trailing segments — lowest priority first — rather + than letting the host truncate mid-word and cost the block a row. Width is + probed from the controlling tty, `$COLUMNS`, then `tput cols` (only when + stdout is a terminal); `$STATUSLINE_COLS` overrides. +- Verbosity preset now falls back to `s` below 90 columns. The config's + `"size"` is a preference capped by width; `$STATUSLINE_SIZE` remains a hard + pin honoured at any width. +- **Pace** on both rate-limit windows: used% over the share of the window + elapsed, which is the linear projection of usage at reset (`1.0x` lands + exactly on the cap). The percentage carries the worse of the absolute and + pace severities; the pace figure carries its own. Nothing is printed below + 10% of the window elapsed, where the extrapolation is not informative. +- Tests: `tests/statusline/test_fit_and_pace.sh` (35 tests — fitting, pace, + severity, the module loader's failure path, and the §4.1 size cap), + `tests/statusline/measure_widths.sh` (per-preset width measurement). + +### Changed + +- The identity line renders `model | dir | effort | thinking`. Segment order is + priority order under width fitting, and the previous order put `dir` last, + which made the working directory the first thing dropped on a narrow + terminal. +- The renderer is split into ten modules, one concern per file, none over 500 + lines (`rules/coding-standards.md` §4.1; the single file had reached 1022). + Behaviour-preserving: verified byte-identical across a 34-configuration + preset x width golden render. +- Docs: both READMEs described an emoji-based layout that the word-based + design-system rendering had already replaced. + +### Fixed + +- `stat -f` is BSD-only; every mtime read now goes through `file_mtime`, which + tries the BSD and GNU spellings. On Linux the bare call silently read 0, + forcing a cache refresh on every invocation. +- A non-numeric `used_percentage` (`"n/a"`) rendered as a healthy 0%: awk reads + an unquoted non-numeric token as an uninitialised variable. Values are now + validated before awk sees them. +- `tput cols` returns terminfo's blind 80 when stdout is a pipe, which is how + the host captures the renderer — it is now consulted only when stdout is a + terminal, so IDE and web sessions no longer silently downgrade. +- The terminal-size probe leaked `Device not configured` on every refresh with + no controlling tty: the failing redirection is reported by the shell itself, + so the whole group is now redirected, not just the command. + ## [2.0.0] - 2026-07-22 ### BREAKING diff --git a/README.md b/README.md index f2dc22a..6209bf3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ones you want; none requires the others. |---|---|---| | [**context-guard**](plugins/context-guard) | `/plugin install context-guard@session-optimizer-marketplace` | A `Stop` hook enforces a per-model context budget: at the WARN threshold it writes a mechanical checkpoint stub and delegates persistence to a budgeted `memory-writer` subagent as a reflection pause; at the hard cap it forces checkpoint → `/clear` → resume. A `SubagentStop` tracker surfaces true session spend (main thread + subagents). | | [**refine-gate**](plugins/refine-gate) | `/plugin install refine-gate@session-optimizer-marketplace` | A `UserPromptSubmit` hook + `/refine` skill that bind vague prompt references ("the SSE solution", "like before", "still broken") to concrete artifacts with evidence, then select an execution strategy from a research-backed table — before any code is touched. | -| [**statusline**](plugins/statusline) | `/plugin install statusline@session-optimizer-marketplace` | A multi-line status bar: RGB-gradient context bar tied to per-model checkpoint thresholds, cost, telemetry (tok/s, compactions, cache countdown), rate-limit gauges, live subagent spend. Ships an install skill — after installing, ask Claude to "install the statusline" and it wires everything. | +| [**statusline**](plugins/statusline) | `/plugin install statusline@session-optimizer-marketplace` | A multi-line status bar: discrete heat-track context bar tied to per-model checkpoint thresholds, one deduplicated cost ledger covering subagent spend, telemetry (tok/s, compactions, cache countdown), rate-limit gauges with burn-rate pacing, and terminal-width fitting. Ships an install skill — after installing, ask Claude to "install the statusline" and it wires everything. | ## Why diff --git a/plugins/statusline/.claude-plugin/plugin.json b/plugins/statusline/.claude-plugin/plugin.json index 5713f2c..50e9211 100644 --- a/plugins/statusline/.claude-plugin/plugin.json +++ b/plugins/statusline/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "statusline", - "description": "Multi-line Claude Code statusline (Catppuccin Mocha) with RGB-gradient context bars tied to per-model checkpoint thresholds, monthly cost tracking, per-session telemetry (tok/s, compactions, prompt-cache countdown), rate-limit gauges, and live subagent spend. Ships an install skill that copies the bundled assets into ~/.claude and wires settings.json.", - "version": "2.0.0", + "description": "Multi-line Claude Code statusline with a discrete heat-track context bar tied to per-model checkpoint thresholds, one deduplicated cost ledger covering subagent spend, per-session telemetry (tok/s, compactions, prompt-cache countdown), rate-limit gauges with burn-rate pacing, and terminal-width fitting. Words, not emoji: every value is named. Ships an install skill that copies the bundled assets into ~/.claude and wires settings.json.", + "version": "2.1.0", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/plugins/statusline/README.fr.md b/plugins/statusline/README.fr.md index 882ade4..af324e5 100644 --- a/plugins/statusline/README.fr.md +++ b/plugins/statusline/README.fr.md @@ -2,8 +2,15 @@ # statusline — Claude Code -Statusline multi-lignes (Catppuccin Mocha) avec barres en dégradé RGB, suivi -de coûts mensuels, télémétrie par session et jauges de rate-limit. +Statusline multi-lignes avec barre heat-track discrète, un registre de coûts +unique, télémétrie par session et jauges de rate-limit avec rythme de +consommation. + +Chaque ligne s'ouvre sur un mot nommant son sujet, et chaque valeur est précédée +du mot qui dit ce qu'elle mesure — aucun emoji, aucun état encodé en glyphe. Un +glyphe s'apprend et reste ambigu d'une police de terminal à l'autre (plusieurs +s'affichent en double largeur et cassent les budgets de colonnes) ; un mot se lit +pareil partout. L'état est porté par la couleur et le nombre, rien d'autre. ## Installation @@ -25,8 +32,9 @@ ne sont jamais touchés automatiquement.
Installation manuelle (sans le système de plugins) -1. Copier les 5 fichiers de `assets/` dans `~/.claude/` puis - `chmod +x ~/.claude/statusline-command.sh`. +1. Copier tout le contenu de `assets/` dans `~/.claude/` — y compris le + répertoire `statusline-lib/` entier, qui doit se trouver à côté du moteur de + rendu — puis `chmod +x ~/.claude/statusline-command.sh ~/.claude/costs.sh`. 2. Déclarer la statusline dans `~/.claude/settings.json` : ```json { "statusLine": { "type": "command", "command": "bash ~/.claude/statusline-command.sh", "padding": 1, "refreshInterval": 10 } } @@ -39,31 +47,97 @@ ne sont jamais touchés automatiquement. | Fichier | Rôle | |---|---| -| `statusline-command.sh` | Script de rendu (appelé par Claude Code à chaque refresh). | -| `statusline-costs.py` | Agrégateur de coûts (scan `~/.claude/projects/**/*.jsonl`, cache 1 h). | +| `statusline-command.sh` | Point d'entrée du rendu — la racine de composition, appelée par Claude Code à chaque refresh. | +| `statusline-lib/*.sh` | Les modules du moteur de rendu, un sujet par fichier. À installer à côté du moteur. | +| `costs.sh` | CLI du registre de coûts sur `~/.claude/statusline-costs.jsonl` — source unique de chaque montant. | +| `pricing.json` | Prix par modèle utilisés par `costs.sh`. | | `statusline-transcript.py` | Télémétrie par session (tok/s, compactions, âge réponse, last_ts) — reverse-tail + scan incrémental, cache court (15 s, en arrière-plan). | | `statusline-budget.json` | Config **personnelle** : TTL cache, taille d'affichage. | | `ctxguard-thresholds.json` | Seuils de contexte par modèle — **partagés** avec le plugin context-guard (voir plus bas). | +Le moteur résout `statusline-lib/` relativement à son propre chemin (surchargeable +par `$STATUSLINE_LIB`) et s'arrête en nommant le fichier manquant si un module est +absent, plutôt que d'afficher une statusline partielle. + +| Module | Responsabilité unique | +|---|---| +| `platform.sh` | Différences d'orthographe BSD/GNU (`stat`, `date`) | +| `palette.sh` | Jetons de couleur, barre heat-track | +| `fit.sh` | Mesure de largeur visible et rognage | +| `severity.sh` | L'échelle ok/warn/danger unique et ses seuils | +| `format.sh` | Nombres et durées tels que lus | +| `config.sh` | Les deux fichiers de config JSON | +| `gitctx.sh` | Les faits du dépôt | +| `session_state.sh` | Registre de coûts, télémétrie transcript, tracker de sous-agents | +| `layout.sh` | Sonde de largeur du terminal, preset de verbosité | +| `render.sh` | Une fonction par ligne de statut | + ## Segments -- **Identité** : modèle, effort, thinking 💡, dossier. -- **Git** : branche 🌿 + dirty `✗`, `↑n ↓n` (avance/retard vs upstream), `⚠n` - (conflits), décompo `!M +A ✘D ?U` (m+). Repli `@repo` sur le sous-repo le plus - récent quand le cwd n'est pas un dépôt. -- **Session** : barre contexte 🧠, tokens, `💰` coût, `⏱` durée, rate-limits - 🚀/🌟, churn ✏️. -- **Sous-agents** : dépense en direct `🤖N · tokens · $coût`, lue depuis +``` +model model NOM | dir NOM | effort NIVEAU | thinking on +branch branch NOM modified | ahead N | behind N | mod N add N del N +context ███░░░ N% Nk tokens | session $N | elapsed NmNs | edits +N/-N +throughput N tokens/s | idle NmNs | cache warm NmNs | compactions N +quota 5h ███░░░ N% | pace N.Nx | resets in NhNm +spend today $N | month $N | average $N/day +``` + +- **Identité** : modèle, dossier, effort, thinking. Le dossier vient en second + volontairement — l'ordre des segments est l'ordre de priorité (voir *Rognage* + plus bas), et sur un terminal étroit « dans quel arbre suis-je » vaut plus que + les réglages de session. +- **Git** : branche + `modified`, avance/retard vs upstream, conflits, et une + décomposition `mod/add/del/untracked` (m+). Repli `NOM@repo` sur le sous-repo + le plus récemment touché quand le cwd n'est pas un dépôt. +- **Session** : barre de contexte, tokens, coût de la session, durée, churn. +- **Sous-agents** : nombre et volume de tokens pour cette session, lus depuis l'agrégat maintenu par le tracker `SubagentStop` du plugin context-guard - quand il est installé (segment vide sinon). -- **Télémétrie** (m+) : `⚡ t/s` (débit du dernier tour — wall-clock, inclut la - latence outils ⇒ borne basse), `🕑` âge dernière réponse, `❄` compte à rebours - du cache de prompt (rouge = `cold`), `🗜` compactions de contexte. -- **Quota** (l+) : jauges 🎯 `🚀 5h` et `🌟 7d` = % du quota rate-limit Pro/Max - consommé (la vraie contrainte « ne pas dépasser » ; 100 % = lockout), avec - reset. Couleurs : vert < 50, jaune 50–79, rouge ≥ 80. Au preset `m`, version - inline compacte sur la ligne session. Suivi d'une ligne **référence coût** - (informative, pas un plafond) : `💰 $/mois · 🤖 $/run`. + (segment vide quand ce plugin n'est pas installé). Leur **coût** n'est pas + affiché à part — il est déjà dans le montant de la session, que le registre + calcule depuis le sous-arbre `subagents/`. +- **Télémétrie** (m+) : débit du dernier tour (wall-clock, inclut la latence + outils ⇒ borne basse), temps d'inactivité depuis la dernière réponse, compte à + rebours du cache de prompt (`cache cold` en rouge une fois expiré), compactions + de contexte. +- **Quota** (l+) : `quota 5h` et `quota 7d` = % du quota rate-limit Pro/Max + consommé (la vraie contrainte « ne pas dépasser » ; 100 % = lockout), chacun + avec son **rythme** et son heure de reset. Au preset `m`, version inline + compacte sur la ligne session — même résolveur, donc les deux rendus d'une même + fenêtre ne peuvent pas diverger. +- **Dépense** (l+) : aujourd'hui / mois en cours / moyenne par jour, chaque + montant venant du registre unique et incluant les sous-agents. Informatif, pas + un plafond. + +## Rythme (pace) + +Un pourcentage de quota seul ne dit pas s'il y a un problème : 60 % consommés, +c'est sain à quatre heures d'une fenêtre de cinq heures, et alarmant au bout de +dix minutes. Le **rythme** est la vitesse de consommation rapportée à l'horloge +de la fenêtre — % consommé divisé par la part de fenêtre écoulée — ce qui est +aussi la projection linéaire de la consommation au reset : `1.0x` projette +d'atterrir exactement sur le plafond. + +Le pourcentage porte le **pire** des deux relevés (absolu et rythme) ; le chiffre +de rythme porte le sien. Couleurs : vert sous 50 % (ou sous 0.8x), jaune à partir +de 50 %, rouge à partir de 80 % ou dès qu'une projection atteint le plafond. Sous +10 % de fenêtre écoulée, aucun rythme n'est affiché — l'extrapolation partirait +dans tous les sens sur une seule rafale. + +## Rognage + +Chaque ligne est ajustée au terminal. Le preset de verbosité est le réglage +grossier (combien de **lignes**), `fit_line` le réglage fin : il retire les +segments de plus faible priorité en fin de ligne jusqu'à ce qu'elle rentre. Les +lignes sont construites du plus important au moins important, donc **c'est la +queue qui part**. Les lignes sont tenues à 85 % de la largeur et non 100 % : une +ligne qui atteint la marge droite est tronquée par l'hôte et peut coûter une +rangée au bloc entier. + +La largeur est sondée depuis le tty de contrôle, puis `$COLUMNS`, puis +`tput cols` (uniquement quand stdout est un terminal — sur un tube il renvoie le +80 aveugle de terminfo). Quand rien ne répond, le repli est volontairement large. +Surchargeable par `$STATUSLINE_COLS`. ## Seuils partagés avec context-guard @@ -84,14 +158,24 @@ Réglage : variable d'env `STATUSLINE_SIZE`, ou champ `"size"` de `statusline-bu ## Notes techniques - `.rate_limits.{five_hour,seven_day}` (comptes Pro/Max) : `used_percentage` est - déjà un ratio du quota → pilote directement les jauges 🎯 ; `resets_at` = epoch + déjà un ratio du quota → pilote directement les jauges ; `resets_at` = epoch en **secondes**. Pas de budget mensuel absolu : sur un forfait flat-rate, la contrainte est le quota, pas une dépense en $/tokens. -- Barres : interpolation RGB continue par cellule (`grad_rgb`) vert→jaune→pêche→rouge. +- Barres : heat-track **discret** à 4 paliers, chaque cellule colorée par sa + position sur la largeur totale — sans interpolation, donc une barre plus pleine + accumule visiblement les paliers de gauche à droite. +- Coûts : un seul registre. `costs.sh` calcule le prix de chaque session depuis + son propre transcript plus tout le sous-arbre récursif `subagents/`, après + déduplication sur `message.id:requestId` — Claude Code réenregistre une même + réponse d'API 2 à 3 fois (streaming / continuation d'outil), et sommer les + lignes assistant brutes surcompte ~2,2x (mesuré sur 172 transcripts locaux : + 3438,84 $ dédupliqués contre 7645,04 $ bruts). Le `.cost.total_cost_usd` de + Claude Code ne couvre que le fil principal et reporte la dépense antérieure sur + une session reprise : il ne sert que de repli, affiché `session main`. - Télémétrie : le `.py` tourne en arrière-plan (lock + TTL 15 s) et écrit un cache - par session (clé = `transcript_path`) ; `🕑` et `❄` sont recalculés en direct à - chaque refresh depuis `last_ts`, donc le décompte reste à la seconde entre deux - scans. JSONL append-only ⇒ le compte de compactions est incrémental (scan des + par session (clé = `transcript_path`) ; les décomptes d'inactivité et de cache + sont recalculés en direct à chaque refresh depuis `last_ts`, donc ils restent à + la seconde entre deux scans. JSONL append-only ⇒ le compte de compactions est incrémental (scan des octets ajoutés `[prev_size, size)` uniquement). - `cache_ttl_min` : 5 (défaut Pro) ou 60 (Max) — source : docs Anthropic prompt-caching (TTL 5 min par défaut). Inspirations : `CCometixLine` diff --git a/plugins/statusline/README.md b/plugins/statusline/README.md index aefb883..59d389f 100644 --- a/plugins/statusline/README.md +++ b/plugins/statusline/README.md @@ -2,8 +2,14 @@ # statusline — Claude Code -Multi-line statusline (Catppuccin Mocha) with RGB-gradient bars, monthly -cost tracking, per-session telemetry, and rate-limit gauges. +Multi-line statusline with a discrete heat-track bar, one cost ledger, +per-session telemetry, and rate-limit gauges with burn-rate pacing. + +Every line opens with a word naming its concern, and every value is preceded +by the word for what it measures — no emoji, no glyph-encoded state. A glyph +has to be learned and is ambiguous across terminal fonts (several render +double-width and break column budgets); a word reads the same everywhere. +Status is carried by colour and number only. ## Install @@ -24,8 +30,9 @@ touched automatically.
Manual install (without the plugin system) -1. Copy the 5 files from `assets/` into `~/.claude/` and - `chmod +x ~/.claude/statusline-command.sh`. +1. Copy everything in `assets/` into `~/.claude/` — including the whole + `statusline-lib/` directory, which must sit next to the renderer — then + `chmod +x ~/.claude/statusline-command.sh ~/.claude/costs.sh`. 2. Declare the statusline in `~/.claude/settings.json`: ```json { "statusLine": { "type": "command", "command": "bash ~/.claude/statusline-command.sh", "padding": 1, "refreshInterval": 10 } } @@ -38,31 +45,92 @@ touched automatically. | File | Role | |---|---| -| `statusline-command.sh` | Rendering script (called by Claude Code on every refresh). | -| `statusline-costs.py` | Cost aggregator (scans `~/.claude/projects/**/*.jsonl`, 1 h cache). | +| `statusline-command.sh` | Renderer entry point — the composition root, called by Claude Code on every refresh. | +| `statusline-lib/*.sh` | The renderer's modules, one concern per file. Must be installed next to the renderer. | +| `costs.sh` | Cost ledger CLI over `~/.claude/statusline-costs.jsonl` — the single source of every dollar figure. | +| `pricing.json` | Per-model token prices `costs.sh` prices with. | | `statusline-transcript.py` | Per-session telemetry (tok/s, compactions, response age, last_ts) — reverse-tail + incremental scan, short cache (15 s, in the background). | | `statusline-budget.json` | **Personal** config: cache TTL, display size. | | `ctxguard-thresholds.json` | Per-model context thresholds — **shared** with the context-guard plugin (see below). | +The renderer resolves `statusline-lib/` relative to its own path (override with +`$STATUSLINE_LIB`) and exits with a message naming the missing file if a module +is absent, rather than rendering a partial statusline. + +| Module | Single responsibility | +|---|---| +| `platform.sh` | BSD/GNU spelling differences (`stat`, `date`) | +| `palette.sh` | Colour tokens, the heat-track bar | +| `fit.sh` | Visible-width measurement and trimming | +| `severity.sh` | The one ok/warn/danger scale and its thresholds | +| `format.sh` | Numbers and times as the reader sees them | +| `config.sh` | The two JSON config files | +| `gitctx.sh` | The repository facts | +| `session_state.sh` | Cost ledger, transcript telemetry, subagent tracker | +| `layout.sh` | Terminal width probe, verbosity preset | +| `render.sh` | One function per status line | + ## Segments -- **Identity**: model, effort, thinking 💡, folder. -- **Git**: branch 🌿 + dirty `✗`, `↑n ↓n` (ahead/behind vs upstream), `⚠n` - (conflicts), breakdown `!M +A ✘D ?U` (m+). Falls back to `@repo` on the most +``` +model model NAME | dir NAME | effort LEVEL | thinking on +branch branch NAME modified | ahead N | behind N | mod N add N del N +context ███░░░ N% Nk tokens | session $N | elapsed NmNs | edits +N/-N +throughput N tokens/s | idle NmNs | cache warm NmNs | compactions N +quota 5h ███░░░ N% | pace N.Nx | resets in NhNm +spend today $N | month $N | average $N/day +``` + +- **Identity**: model, directory, effort, thinking. The directory comes second + on purpose — segment order is priority order (see *Fitting* below), and on a + narrow terminal "which tree am I in" is worth more than the session dials. +- **Git**: branch + `modified`, ahead/behind vs upstream, conflicts, and a + `mod/add/del/untracked` breakdown (m+). Falls back to `NAME@repo` on the most recently touched sub-repo when the cwd is not a repository. -- **Session**: context bar 🧠, tokens, `💰` cost, `⏱` duration, rate limits - 🚀/🌟, churn ✏️. -- **Subagents**: live spend `🤖N · tokens · $cost`, read from the aggregate - maintained by the context-guard plugin's `SubagentStop` tracker when that - plugin is installed (segment stays empty otherwise). -- **Telemetry** (m+): `⚡ t/s` (throughput of the last turn — wall-clock, - includes tool latency ⇒ lower bound), `🕑` age of the last response, `❄` - prompt-cache countdown (red = `cold`), `🗜` context compactions. -- **Quota** (l+): 🎯 gauges `🚀 5h` and `🌟 7d` = % of the Pro/Max rate-limit - quota consumed (the real "do not exceed" constraint; 100% = lockout), with - reset time. Colors: green < 50, yellow 50–79, red ≥ 80. At the `m` preset, - a compact inline version on the session line. Followed by a **cost - reference** line (informative, not a cap): `💰 $/month · 🤖 $/run`. +- **Session**: context bar, tokens, session cost, duration, churn. +- **Subagents**: count and token volume for this session, read from the + aggregate the context-guard plugin's `SubagentStop` tracker maintains + (segment stays empty when that plugin is not installed). Their **cost** is + not shown separately — it is already inside the session figure, which the + ledger prices from the `subagents/` subtree. +- **Telemetry** (m+): throughput of the last turn (wall-clock, includes tool + latency ⇒ a lower bound), idle time since the last response, prompt-cache + countdown (`cache cold` in red once it lapses), context compactions. +- **Quota** (l+): `quota 5h` and `quota 7d` = % of the Pro/Max rate-limit + quota consumed (the real "do not exceed" constraint; 100% = lockout), each + with its **pace** and its reset time. At the `m` preset, a compact inline + version on the session line — same resolver, so the two renderings of one + window can never disagree. +- **Spend** (l+): today / month-to-date / average per day, every figure from + the one ledger and including subagent spend. Informational, not a cap. + +## Pace + +A quota reading alone cannot say whether it is a problem: 60% used is fine four +hours into a five-hour window and alarming ten minutes in. **Pace** is the burn +rate against the window's own clock — used% divided by the share of the window +elapsed — which is also the linear projection of usage at reset: `1.0x` projects +landing exactly on the cap. + +The percentage carries the **worse** of the absolute and pace readings; the pace +figure carries its own. Colours: green below 50% used (or under 0.8x), yellow +from 50%, red from 80% used or a projection that reaches the cap. Below 10% of +the window elapsed no pace is printed at all — the extrapolation would swing +wildly on a single burst. + +## Fitting + +Every line is fitted to the terminal. The verbosity preset is the coarse +adjustment (how many **lines**), and `fit_line` is the fine one: it drops each +line's lowest-priority trailing segments until the line fits. Lines are built +most-important-first, so **the tail is what goes**. Lines are held to 85% of the +width rather than 100%: a line that reaches the right margin is truncated by the +host and can cost the block a row. + +Width is probed from the controlling tty, then `$COLUMNS`, then `tput cols` +(only when stdout is a terminal — on a pipe it returns terminfo's blind 80). +When nothing can answer, the fallback is deliberately wide. Override with +`$STATUSLINE_COLS`. ## Shared thresholds with context-guard @@ -83,13 +151,23 @@ Setting: `STATUSLINE_SIZE` env variable, or the `"size"` field of `statusline-bu ## Technical notes - `.rate_limits.{five_hour,seven_day}` (Pro/Max accounts): `used_percentage` is - already a ratio of the quota → drives the 🎯 gauges directly; `resets_at` = + already a ratio of the quota → drives the gauges directly; `resets_at` = epoch in **seconds**. No absolute monthly budget: on a flat-rate plan, the constraint is the quota, not a spend in $/tokens. -- Bars: continuous per-cell RGB interpolation (`grad_rgb`) green→yellow→peach→red. +- Bars: a 4-palier **discrete** heat track, each cell coloured by its position + along the full width — no interpolation, so a fuller bar visibly accumulates + paliers left to right. +- Costs: one ledger. `costs.sh` prices each session from its own transcript plus + the full recursive `subagents/` subtree, deduplicating on + `message.id:requestId` first — Claude Code re-logs one API response 2-3 times + (streaming / tool continuation), and summing raw assistant lines over-counts + ~2.2x (measured over 172 local transcripts: $3438.84 deduped vs $7645.04 raw). + Claude Code's own `.cost.total_cost_usd` covers the main thread only and + carries prior spend forward on a resumed session, so it is used only as a + labelled `session main` fallback. - Telemetry: the `.py` runs in the background (lock + 15 s TTL) and writes a - per-session cache (key = `transcript_path`); `🕑` and `❄` are recomputed live - on every refresh from `last_ts`, so the countdown stays second-accurate + per-session cache (key = `transcript_path`); idle and cache countdowns are + recomputed live on every refresh from `last_ts`, so they stay second-accurate between two scans. JSONL is append-only ⇒ the compaction count is incremental (scans only the appended bytes `[prev_size, size)`). - `cache_ttl_min`: 5 (Pro default) or 60 (Max) — source: Anthropic diff --git a/plugins/statusline/hooks/hooks.json b/plugins/statusline/hooks/hooks.json index bb7eb40..c6ecf3d 100644 --- a/plugins/statusline/hooks/hooks.json +++ b/plugins/statusline/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "echo '[statusline auto-update] Bundled statusline assets are in ${CLAUDE_PLUGIN_ROOT}/assets . Only if this statusline is already installed (~/.claude/statusline-command.sh exists): quietly compare the three CODE assets - statusline-command.sh, statusline-costs.py, statusline-transcript.py - against the copies in ~/.claude using your Read and Write tools; when any differ, copy the bundled version over, backing up each changed file first as name.bak.timestamp, then set the execute bit on statusline-command.sh. NEVER overwrite statusline-budget.json or ctxguard-thresholds.json - those hold user-tuned configuration. If no statusline is installed yet, do nothing. Report only if you actually updated a file.'", + "command": "echo '[statusline auto-update] Bundled statusline assets are in ${CLAUDE_PLUGIN_ROOT}/assets . Only if this statusline is already installed (~/.claude/statusline-command.sh exists): quietly compare the CODE assets - statusline-command.sh, costs.sh, pricing.json, statusline-transcript.py, and every statusline-lib/*.sh - against the copies in ~/.claude using your Read and Write tools; when any differ, copy the bundled version over, backing up each changed file first as name.bak.timestamp, then set the execute bit on statusline-command.sh and costs.sh. Install statusline-lib as a WHOLE directory (create ~/.claude/statusline-lib if absent, and delete any module the bundle no longer ships) - the renderer refuses to start when a module is missing, and a half-updated module set is worse than none. Also delete a leftover ~/.claude/statusline-costs.py: costs.sh supersedes it and it over-counted spend ~2.2x. NEVER overwrite statusline-budget.json or ctxguard-thresholds.json - those hold user-tuned configuration. If no statusline is installed yet, do nothing. Report only if you actually updated a file.'", "timeout": 10 } ] diff --git a/plugins/statusline/skills/statusline/SKILL.md b/plugins/statusline/skills/statusline/SKILL.md index 942c633..996ed20 100644 --- a/plugins/statusline/skills/statusline/SKILL.md +++ b/plugins/statusline/skills/statusline/SKILL.md @@ -1,35 +1,63 @@ --- name: statusline description: Install or update the session-optimizer statusline for Claude Code. Use when the user asks to "install the statusline", "set up the status bar", "configure the statusline", "update the statusline", or wants git/context/cost/rate-limit/telemetry info in the Claude Code status bar. -version: 2.0.0 +version: 2.1.0 --- # Statusline install -Installs the multi-line statusline that shows model + git context, an -RGB-gradient context bar tied to per-model checkpoint thresholds, session -cost and duration, 5h/7d rate-limit gauges, per-session telemetry (tok/s, -compactions, prompt-cache countdown), and live subagent spend. +Installs the multi-line statusline that shows model + git context, a discrete +heat-track context bar tied to per-model checkpoint thresholds, session cost and +duration from one deduplicated ledger, 5h/7d rate-limit gauges with burn-rate +pacing, per-session telemetry (tok/s, compactions, prompt-cache countdown), and +live subagent activity — every line fitted to the terminal width. The plugin **bundles** its assets under `assets/`: | File | Role | Update policy | |---|---|---| -| `statusline-command.sh` | Renderer (called by Claude Code every refresh) | overwrite on update (with backup) | -| `statusline-costs.py` | Cost aggregator (scans `~/.claude/projects/**/*.jsonl`, 1 h cache) | overwrite on update (with backup) | +| `statusline-command.sh` | Renderer entry point — the composition root, called by Claude Code every refresh | overwrite on update (with backup) | +| `statusline-lib/*.sh` | The renderer's modules, one concern per file (see below) | overwrite the whole directory on update | +| `costs.sh` | Cost ledger CLI — the single source of every dollar figure | overwrite on update (with backup) | +| `pricing.json` | Per-model token prices `costs.sh` prices with | overwrite on update (with backup) | | `statusline-transcript.py` | Per-session telemetry, backgrounded on a 15 s TTL | overwrite on update (with backup) | | `statusline-budget.json` | **Personal** config: display size, cache TTL | copy only if absent — never overwrite | | `ctxguard-thresholds.json` | Per-model checkpoint thresholds, **shared** with the context-guard plugin | copy only if absent — never overwrite | +The renderer is a composition root plus a module directory. `statusline-lib/` +must be installed **next to** `statusline-command.sh`: the script resolves its +modules relative to its own path, and exits with a message naming the missing +file if any is absent (`$STATUSLINE_LIB` overrides the location). + +| Module | Single responsibility | +|---|---| +| `platform.sh` | BSD/GNU spelling differences (`stat`, `date`) | +| `palette.sh` | Colour tokens, the heat-track bar | +| `fit.sh` | Visible-width measurement and trimming | +| `severity.sh` | The one ok/warn/danger scale and its thresholds | +| `format.sh` | Numbers and times as the reader sees them | +| `config.sh` | The two JSON config files | +| `gitctx.sh` | The repository facts | +| `session_state.sh` | Cost ledger, transcript telemetry, subagent tracker | +| `layout.sh` | Terminal width probe, verbosity preset | +| `render.sh` | One function per status line | + A `SessionStart` hook (`hooks/hooks.json`) injects a short maintenance -instruction each session start so Claude reconciles the three CODE assets -into `~/.claude` when the plugin updates — the two config files are never -touched automatically. +instruction each session start so Claude reconciles the CODE assets into +`~/.claude` when the plugin updates — the two config files are never touched +automatically. + +> **Superseded:** `statusline-costs.py` was removed in 2.1.0. It summed every +> assistant line of every transcript and over-counted spend ~2.2x, because +> Claude Code re-logs one API response 2-3 times (streaming / tool +> continuation). `costs.sh` deduplicates on `message.id:requestId` before +> pricing. An install that still has `~/.claude/statusline-costs.py` should +> delete it — nothing reads it any more. ## Requirements - `jq` — JSON parsing in the renderer (**required**) -- `python3` — cost aggregation and telemetry (**required**) +- `python3` — per-session telemetry (**required**; cost aggregation is `costs.sh`, pure bash + jq) - `git` — repository context (optional; segment degrades gracefully) ## Instructions for Claude @@ -70,9 +98,11 @@ If any **BLOCKING** check fails, stop and tell the user what to fix. Do this with your **Read/Write tools**: 1. Locate the bundled `assets/` dir: `find ~/.claude/plugins -type d -path '*/statusline/*/assets' 2>/dev/null | head -1` (dev checkout of this repo: `plugins/statusline/assets/` directly). -2. **Code assets** (`statusline-command.sh`, `statusline-costs.py`, `statusline-transcript.py`): for each, if a copy already exists in `~/.claude` and differs, back it up as `~/.claude/.bak.`, then write the bundled version to `~/.claude/`. -3. **Config assets** (`statusline-budget.json`, `ctxguard-thresholds.json`): copy to `~/.claude/` **only if the file does not exist yet** — these hold user-tuned values and must never be overwritten. -4. Set the execute bit: `chmod +x ~/.claude/statusline-command.sh`. +2. **Code assets** (`statusline-command.sh`, `costs.sh`, `pricing.json`, `statusline-transcript.py`): for each, if a copy already exists in `~/.claude` and differs, back it up as `~/.claude/.bak.`, then write the bundled version to `~/.claude/`. +3. **The module directory** (`statusline-lib/`): create `~/.claude/statusline-lib/` and write every bundled `*.sh` into it. Install the whole directory, not a subset — the renderer refuses to start if one module is missing, and a version-skewed module is worse than an absent one. Delete any `~/.claude/statusline-lib/*.sh` the bundle no longer ships. +4. **Config assets** (`statusline-budget.json`, `ctxguard-thresholds.json`): copy to `~/.claude/` **only if the file does not exist yet** — these hold user-tuned values and must never be overwritten. +5. Set the execute bit: `chmod +x ~/.claude/statusline-command.sh ~/.claude/costs.sh`. +6. **Remove the superseded aggregator** if present: `rm -f ~/.claude/statusline-costs.py` (back it up first if it differs from the last shipped copy). It over-counted spend ~2.2x and nothing reads it any more. ### Step 3 — Configure settings @@ -95,10 +125,15 @@ current while the session is idle. ### Step 4 — Post-install verification ```bash -for f in statusline-command.sh statusline-costs.py statusline-transcript.py statusline-budget.json ctxguard-thresholds.json; do +for f in statusline-command.sh costs.sh pricing.json statusline-transcript.py statusline-budget.json ctxguard-thresholds.json; do [ -f ~/.claude/$f ] && echo "OK: $f present" || echo "ERROR: $f missing" done +for m in platform palette fit severity format config gitctx session_state layout render; do + [ -r ~/.claude/statusline-lib/$m.sh ] && echo "OK: module $m.sh present" || echo "ERROR: module $m.sh missing" +done +[ -f ~/.claude/statusline-costs.py ] && echo "WARN: superseded statusline-costs.py still present — delete it" || echo "OK: no superseded aggregator" [ -x ~/.claude/statusline-command.sh ] && echo "OK: renderer executable" || echo "ERROR: renderer not executable" +[ -x ~/.claude/costs.sh ] && echo "OK: ledger executable" || echo "ERROR: costs.sh not executable" jq -e '.statusLine.command' ~/.claude/settings.json >/dev/null 2>&1 \ && echo "OK: statusLine registered in settings.json" \ || echo "ERROR: statusLine not found in settings.json" From bf8531529ed8738167a459128cc1241748a9499c Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 13:42:28 +0200 Subject: [PATCH 4/7] test(statusline): cover the width probe and preset resolution, gate the suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion ledger for this branch surfaced two load-bearing functions introduced by it with no asserting test: probe_cols and resolve_preset. A coverage gap in new code is blocking (rules/coding-standards.md §13.3), so it is closed here rather than deferred. probe_cols walks four sources and only the $STATUSLINE_COLS rung is reachable as-is from a test process — the rest depend on whether the runner owns a terminal, which differs between a developer's shell and CI. Each lower rung is reached by shadowing the command it calls (stty, tput) with a failing stub in the test's subshell. No production seam is added for the tests and no case depends on the runner. The tput rung gets the negative assertion the bug deserves: with stdout on a pipe the answer must be the wide fallback, never terminfo's blind 80. Writing those cases exposed a defect: probe_cols printed $STATUSLINE_COLS straight through, so a non-numeric override escaped its own postcondition ("a positive integer, always") and broke every arithmetic width comparison downstream. An invalid override now falls through to the probes. The four new cases were verified against injected mutants — unconditional tput, an off-by-one width cap, the unvalidated override, and a config size treated as a pin — each of which they kill. Boy-scout (§14), in the touched test tree: SC1090 x3, SC2004 x4, SC2034 x4, SC2153 and SC2329 x9 fixed or disabled per site with a written rationale, and CI extended to shellcheck tests/statusline/*.sh — it checked only the assets, which is how those findings survived. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 13 +- .../assets/statusline-lib/layout.sh | 5 +- tests/statusline/measure_widths.sh | 2 + tests/statusline/test_fit_and_pace.sh | 168 +++++++++++++++++- tests/statusline/test_heat_rgb.sh | 4 +- 6 files changed, 190 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4755902..6ed66e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,14 @@ jobs: - name: Statusline fitting, pace and module tests run: bash tests/statusline/test_fit_and_pace.sh - - name: Shellcheck statusline renderer, modules and ledger + - name: Shellcheck statusline renderer, modules, ledger and tests run: | shellcheck plugins/statusline/assets/statusline-command.sh shellcheck plugins/statusline/assets/statusline-lib/*.sh shellcheck plugins/statusline/assets/costs.sh + # The suites are shell too, and a checker that skips them lets the + # code that guards the renderer rot unwatched. + shellcheck tests/statusline/*.sh - name: Validate marketplace, plugin, and hook JSON run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index f71f7a5..c0b30f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,9 +43,12 @@ Statusline only. No change to context-guard or refine-gate. exactly on the cap). The percentage carries the worse of the absolute and pace severities; the pace figure carries its own. Nothing is printed below 10% of the window elapsed, where the extrapolation is not informative. -- Tests: `tests/statusline/test_fit_and_pace.sh` (35 tests — fitting, pace, - severity, the module loader's failure path, and the §4.1 size cap), - `tests/statusline/measure_widths.sh` (per-preset width measurement). +- Tests: `tests/statusline/test_fit_and_pace.sh` (46 tests — fitting, pace, + severity, the width probe, preset resolution, the module loader's failure + path, and the §4.1 size cap), `tests/statusline/measure_widths.sh` + (per-preset width measurement). +- CI now shellchecks `tests/statusline/*.sh` as well as the assets. A checker + that skips the suites lets the code guarding the renderer rot unwatched. ### Changed @@ -71,6 +74,10 @@ Statusline only. No change to context-guard or refine-gate. - `tput cols` returns terminfo's blind 80 when stdout is a pipe, which is how the host captures the renderer — it is now consulted only when stdout is a terminal, so IDE and web sessions no longer silently downgrade. +- A non-numeric `$STATUSLINE_COLS` was printed straight through, breaking every + arithmetic width comparison downstream. The override is an escape hatch, not + an exemption from `probe_cols`'s postcondition: an invalid value now falls + through to the probes. - The terminal-size probe leaked `Device not configured` on every refresh with no controlling tty: the failing redirection is reported by the shell itself, so the whole group is now redirected, not just the command. diff --git a/plugins/statusline/assets/statusline-lib/layout.sh b/plugins/statusline/assets/statusline-lib/layout.sh index 4200ef2..abe8e42 100644 --- a/plugins/statusline/assets/statusline-lib/layout.sh +++ b/plugins/statusline/assets/statusline-lib/layout.sh @@ -24,8 +24,11 @@ # query anything and returns terminfo's blind default of 80, which would # silently downgrade the preset on every IDE and web host. probe_cols() { + # The override is an escape hatch, not an exemption from the postcondition: a + # non-numeric or zero value falls through to the probes below rather than + # being printed, which would break every arithmetic comparison downstream. local cols="${STATUSLINE_COLS:-}" - if [ -n "$cols" ]; then printf '%s' "$cols"; return; fi + case "$cols" in ''|*[!0-9]*|0) ;; *) printf '%s' "$cols"; return ;; esac # stdin is the JSON pipe, so the size is read from the controlling tty. The # stderr redirect wraps the whole group, not just stty: with no controlling # terminal it is the "< /dev/tty" REDIRECTION that fails, and the shell diff --git a/tests/statusline/measure_widths.sh b/tests/statusline/measure_widths.sh index bb48b62..e6153d5 100755 --- a/tests/statusline/measure_widths.sh +++ b/tests/statusline/measure_widths.sh @@ -26,6 +26,8 @@ SCRIPT_UNDER_TEST="${SCRIPT_UNDER_TEST:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../ [ -r "$SCRIPT_UNDER_TEST" ] || { echo "introuvable: $SCRIPT_UNDER_TEST" >&2; exit 1; } # vislen(), FIT_RATIO and the palette come from the script under test itself. +# shellcheck source=/dev/null # The path is a variable BY DESIGN: +# $SCRIPT_UNDER_TEST points the suite at the repo copy or an installed one. STATUSLINE_SOURCE_ONLY=1 source "$SCRIPT_UNDER_TEST" command -v vislen >/dev/null || { echo "vislen absent de $SCRIPT_UNDER_TEST" >&2; exit 1; } diff --git a/tests/statusline/test_fit_and_pace.sh b/tests/statusline/test_fit_and_pace.sh index a72fa1b..d2156cd 100755 --- a/tests/statusline/test_fit_and_pace.sh +++ b/tests/statusline/test_fit_and_pace.sh @@ -49,6 +49,8 @@ teardown() { [ -n "${TEST_TMPDIR:-}" ] && rm -rf "$TEST_TMPDIR"; unset TEST_TMPD run_test() { local test_name="$1" + # shellcheck source=/dev/null # The path is a variable BY DESIGN: + # $SCRIPT_UNDER_TEST points the suite at the repo copy or an installed one. ( setup; trap teardown EXIT; STATUSLINE_SOURCE_ONLY=1 source "$SCRIPT_UNDER_TEST"; "$test_name" ) local status=$? [ $status -eq 0 ] && echo "PASS: ${test_name}" || echo "FAIL: ${test_name}" @@ -286,6 +288,157 @@ function test_quota_reading_rejects_absent_window() { return 0 } +# =========================== probe_cols ================================== +# The probe walks four sources in order. Only the $STATUSLINE_COLS rung is +# reachable as-is from a test process, because the others depend on whether the +# runner happens to own a terminal — which differs between a developer's shell +# and CI. Each lower rung is therefore reached by shadowing the command that +# rung calls with a failing stub: probe_cols invokes `stty` and `tput` as plain +# commands, so a function of the same name defined in the test's subshell wins. +# No production seam is added for the tests, and no case depends on the runner. + +function test_probe_cols_env_override_wins() { + assert_eq "$(STATUSLINE_COLS=42 COLUMNS=137 probe_cols)" "42" "override" || return 1 + assert_eq "$(STATUSLINE_COLS=300 probe_cols)" "300" "override large" +} + +# The postcondition is "a positive integer, always". The override is an escape +# hatch, not an exemption: garbage must fall through to the probes, never reach +# the caller, where it would break the arithmetic width comparisons. +# shellcheck disable=SC2329 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. +function test_probe_cols_rejects_a_non_numeric_override() { + local out + for bad in "abc" "0" "-5" "80x24" " "; do + out=$(stty() { return 1; }; tput() { return 1; } + STATUSLINE_COLS="$bad" COLUMNS=137 probe_cols) + assert_eq "$out" "137" "override invalide [$bad]" || return 1 + done + return 0 +} + +# shellcheck disable=SC2329 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. +function test_probe_cols_falls_back_to_columns_when_the_tty_is_unreadable() { + local out + out=$(stty() { return 1; }; unset STATUSLINE_COLS; COLUMNS=137; probe_cols) + assert_eq "$out" "137" "COLUMNS" || return 1 + # A zero or non-numeric COLUMNS is no better than none: keep walking. + out=$(stty() { return 1; }; tput() { return 1; } + unset STATUSLINE_COLS; COLUMNS=0; probe_cols) + assert_eq "$out" "200" "COLUMNS nul ignore" +} + +# The regression this rung exists for: with stdout on a pipe — which is how the +# host captures this renderer — `tput cols` cannot query anything and answers +# terminfo's blind 80, silently downgrading the preset on every IDE and web +# session. It must NOT be consulted, so the answer here is the wide fallback and +# never the stub's 80. Command substitution puts stdout on a pipe, so this is +# the real condition, not a simulated one. +# shellcheck disable=SC2329 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. +function test_probe_cols_ignores_tput_when_stdout_is_not_a_terminal() { + local out + out=$(stty() { return 1; }; tput() { printf '80'; } + unset STATUSLINE_COLS COLUMNS; probe_cols) + assert_eq "$out" "200" "tput consulte hors terminal" || return 1 + case "$out" in 80) echo "FAIL: 80 aveugle de terminfo" >&2; return 1 ;; esac + return 0 +} + +# When nothing can answer, the guess is deliberately WIDE: an over-generous +# width reproduces the pre-width-aware rendering, an over-tight one hides +# information that would have fitted. +# shellcheck disable=SC2329 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. +function test_probe_cols_final_fallback_is_wide() { + local out + out=$(stty() { return 1; }; tput() { return 1; } + unset STATUSLINE_COLS COLUMNS; probe_cols) + assert_eq "$out" "200" "repli large" || return 1 + [ "$out" -ge "$SIZE_L_MIN_COLS" ] || { echo "FAIL: repli sous le seuil l" >&2; return 1; } + return 0 +} + +# =========================== resolve_preset ============================== +# resolve_preset sets SIZE/RANK/CTX_W/BW rather than printing, so each case runs +# it directly and reads the globals. BUDGET_CONFIG is repointed at a temp file +# to drive the config rung without touching the developer's real config. + +# The env pin is the escape hatch for a terminal whose width cannot be probed, +# so width must never override it — not even at a width where the preset cannot +# structurally fit. +# shellcheck disable=SC2153 # RANK is an OUTPUT of resolve_preset, set in +# the sourced module; shellcheck cannot follow that boundary. +function test_resolve_preset_env_pin_is_honoured_at_any_width() { + STATUSLINE_SIZE=xl resolve_preset 40 + assert_eq "$SIZE" "xl" "pin xl etroit" || return 1 + assert_eq "$RANK" "4" "rang xl" || return 1 + assert_eq "$CTX_W" "16" "CTX_W xl" || return 1 + assert_eq "$BW" "20" "BW xl" || return 1 + STATUSLINE_SIZE=xs resolve_preset 300 + assert_eq "$SIZE" "xs" "pin xs large" || return 1 + assert_eq "$RANK" "0" "rang xs" +} + +function test_resolve_preset_ignores_an_invalid_env_pin() { + BUDGET_CONFIG="$TEST_TMPDIR/absent.json" + STATUSLINE_SIZE=huge resolve_preset 300 + assert_eq "$SIZE" "l" "pin invalide -> defaut" || return 1 + STATUSLINE_SIZE="" resolve_preset 300 + assert_eq "$SIZE" "l" "pin vide -> defaut" +} + +function test_resolve_preset_defaults_to_l_on_a_wide_terminal() { + BUDGET_CONFIG="$TEST_TMPDIR/absent.json" + unset STATUSLINE_SIZE + resolve_preset 300 + assert_eq "$SIZE" "l" "defaut" || return 1 + assert_eq "$RANK" "3" "rang l" || return 1 + assert_eq "$CTX_W" "10" "CTX_W l" || return 1 + assert_eq "$BW" "12" "BW l" +} + +# The threshold is l's widest measured line rounded up. Both sides are asserted: +# a cap that fires one column early is as wrong as one that never fires. +function test_resolve_preset_width_cap_boundary() { + BUDGET_CONFIG="$TEST_TMPDIR/absent.json" + unset STATUSLINE_SIZE + resolve_preset "$SIZE_L_MIN_COLS" + assert_eq "$SIZE" "l" "au seuil" || return 1 + resolve_preset $((SIZE_L_MIN_COLS - 1)) + assert_eq "$SIZE" "s" "sous le seuil" || return 1 + assert_eq "$RANK" "1" "rang s" || return 1 + assert_eq "$CTX_W" "8" "CTX_W s" +} + +# The config value is a PREFERENCE: honoured when it fits, capped when it does +# not. Treating it as a pin would leave the width rule dead for every default +# installation, since the shipped config names a size. +function test_resolve_preset_config_size_is_a_preference_capped_by_width() { + BUDGET_CONFIG="$TEST_TMPDIR/budget.json" + printf '{"size":"m"}' > "$BUDGET_CONFIG" + unset STATUSLINE_SIZE + resolve_preset 300 + assert_eq "$SIZE" "m" "preference respectee" || return 1 + resolve_preset 50 + assert_eq "$SIZE" "s" "preference plafonnee" +} + +# The cap is downward-only and lands on "s", the one preset measured narrower +# than l. A preset already at or below it is left alone — a narrow terminal must +# never promote xs to s, and a wide one must never promote s to l. +function test_resolve_preset_cap_never_promotes() { + BUDGET_CONFIG="$TEST_TMPDIR/budget.json" + unset STATUSLINE_SIZE + printf '{"size":"xs"}' > "$BUDGET_CONFIG" + resolve_preset 50 + assert_eq "$SIZE" "xs" "xs conserve en etroit" || return 1 + printf '{"size":"s"}' > "$BUDGET_CONFIG" + resolve_preset 300 + assert_eq "$SIZE" "s" "s non promu en large" +} + # =========================== file_mtime ================================== function test_file_mtime_reads_an_existing_file() { @@ -331,6 +484,8 @@ function test_static_every_line_is_emitted_through_fit() { # Fixture: a populated identity line, rendered through the real function. # `local` is visible to callees in bash, so render_identity reads these. +# shellcheck disable=SC2034 # These locals are READ by render_identity: +# bash scopes `local` dynamically, which is what makes this fixture work. _identity_fixture() { local model="Opus 5" dir="session-optimizer" effort="high" thinking="true" render_identity @@ -407,7 +562,7 @@ shuffle_tests() { while [ "$i" -gt 1 ]; do i=$((i-1)) j=$((RANDOM % (i+1))) - tmp="${arr[$i]}"; arr[$i]="${arr[$j]}"; arr[$j]="$tmp" + tmp="${arr[i]}"; arr[i]="${arr[j]}"; arr[j]="$tmp" done printf '%s\n' "${arr[@]}" } @@ -432,6 +587,17 @@ main() { test_quota_reading_rounds_the_percentage test_quota_reading_combines_worse_of_absolute_and_pace test_quota_reading_rejects_absent_window + test_probe_cols_env_override_wins + test_probe_cols_rejects_a_non_numeric_override + test_probe_cols_falls_back_to_columns_when_the_tty_is_unreadable + test_probe_cols_ignores_tput_when_stdout_is_not_a_terminal + test_probe_cols_final_fallback_is_wide + test_resolve_preset_env_pin_is_honoured_at_any_width + test_resolve_preset_ignores_an_invalid_env_pin + test_resolve_preset_defaults_to_l_on_a_wide_terminal + test_resolve_preset_width_cap_boundary + test_resolve_preset_config_size_is_a_preference_capped_by_width + test_resolve_preset_cap_never_promotes test_file_mtime_reads_an_existing_file test_file_mtime_missing_file_is_zero test_static_no_bare_stat_call test_static_every_line_is_emitted_through_fit test_identity_puts_dir_before_the_session_dials diff --git a/tests/statusline/test_heat_rgb.sh b/tests/statusline/test_heat_rgb.sh index 66b23e8..f01a2cc 100755 --- a/tests/statusline/test_heat_rgb.sh +++ b/tests/statusline/test_heat_rgb.sh @@ -46,6 +46,8 @@ teardown() { [ -n "${TEST_TMPDIR:-}" ] && rm -rf "$TEST_TMPDIR"; unset TEST_TMPD run_test() { local test_name="$1" + # shellcheck source=/dev/null # The path is a variable BY DESIGN: + # $SCRIPT_UNDER_TEST points the suite at the repo copy or an installed one. ( setup; trap teardown EXIT; STATUSLINE_SOURCE_ONLY=1 source "$SCRIPT_UNDER_TEST"; "$test_name" ) local status=$? [ $status -eq 0 ] && echo "PASS: ${test_name}" || echo "FAIL: ${test_name}" @@ -245,7 +247,7 @@ shuffle_tests() { while [ "$i" -gt 1 ]; do i=$((i-1)) j=$((RANDOM % (i+1))) - tmp="${arr[$i]}"; arr[$i]="${arr[$j]}"; arr[$j]="$tmp" + tmp="${arr[i]}"; arr[i]="${arr[j]}"; arr[j]="$tmp" done printf '%s\n' "${arr[@]}" } From 58ab8eb190fae8da9297ec706ea666a4ab5c29f1 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 13:45:49 +0200 Subject: [PATCH 5/7] fix(statusline): replace the A && B || true idiom in costs.sh with explicit if MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's shellcheck flags three SC2015 sites that the local 0.11.0 suppresses when C is `true` — a version skew that makes a local run greener than the gate. The findings are real regardless of which version reports them (§14), so the three sites are rewritten rather than silenced. The weekday counter is the one with teeth: `(( count++ ))` evaluates to 0 — FALSE — on the first weekday of the month, which is precisely why the `|| true` was needed to keep the loop alive. An assignment has no such trap. A/B on the same ledger store, before and after: `today`, `month` and `info` all byte-identical ($291.29 today, $3528.10 month, $196.01 avg/weekday). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- plugins/statusline/assets/costs.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/statusline/assets/costs.sh b/plugins/statusline/assets/costs.sh index 8f0551b..ae621d3 100755 --- a/plugins/statusline/assets/costs.sh +++ b/plugins/statusline/assets/costs.sh @@ -254,13 +254,13 @@ _cleanup_tmps_maybe() { local f mtime_f now_f; now_f=$(date +%s) while IFS= read -r f; do mtime_f=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo "$now_f") - (( now_f - mtime_f > 60 )) && rm -f "$f" 2>/dev/null || true + if (( now_f - mtime_f > 60 )); then rm -f "$f" 2>/dev/null || true; fi done < <(find "$dir" -maxdepth 1 -name "${base}.tmp.*" 2>/dev/null) # Per-session price caches (main + subagent), expire on the retention window. local ret_sec=$(( COST_LOG_RETENTION_DAYS * 86400 )) while IFS= read -r f; do mtime_f=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null || echo "$now_f") - (( now_f - mtime_f > ret_sec )) && rm -f "$f" 2>/dev/null || true + if (( now_f - mtime_f > ret_sec )); then rm -f "$f" 2>/dev/null || true; fi done < <(find "$dir" -maxdepth 1 \( -name "${base}.sub.*" -o -name "${base}.main.*" \) 2>/dev/null) ) & disown 2>/dev/null || true @@ -291,7 +291,7 @@ _weekdays_elapsed() { dstr="${month}-$(printf '%02d' "$d")" dow=$(date -j -f "%Y-%m-%d" "$dstr" +%u 2>/dev/null \ || date -d "$dstr" +%u 2>/dev/null) - [[ "$dow" -le 5 ]] && (( count++ )) || true + if [[ "$dow" -le 5 ]]; then count=$(( count + 1 )); fi done [[ "$count" -lt 1 ]] && count=1 echo "$count" From 600e2db837b770f65e076e87fbdaa2b35ea9a929 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 13:47:44 +0200 Subject: [PATCH 6/7] ci(statusline): pin shellcheck to 0.11.0 and cover both stub-warning codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate ran whatever apt shipped, which is several releases behind a current local install. The two versions do not report the same findings — the older one raises SC2317 where 0.11.0 raises SC2329, and SC2015 on `A && B || true` which 0.11.0 no longer flags. That skew is how three costs.sh findings and five test findings reached CI unseen by a clean local run: an unpinned linter is not a gate, it is a lottery. CI now installs 0.11.0 from the upstream release, matching a local run. The four stub directives list both codes so the suites stay clean under either version — verified against 0.9.0 in a container and 0.11.0 locally, both exit 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- .github/workflows/ci.yml | 14 ++++++++++++-- tests/statusline/test_fit_and_pace.sh | 20 ++++++++++++-------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ed66e6..842d24c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,18 @@ jobs: run: | python -m pip install --upgrade pip pip install pytest - sudo apt-get update - sudo apt-get install -y shellcheck + + # Pinned rather than taken from apt: the distro build is several releases + # behind, and the two versions do not report the same findings — it raises + # SC2317 where 0.11.0 raises SC2329, and SC2015 on `A && B || true` which + # 0.11.0 no longer flags. An unpinned linter makes a local run greener + # than the gate, which is how three findings reached CI unseen. + - name: Install shellcheck 0.11.0 + run: | + v=v0.11.0 + curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${v}/shellcheck-${v}.linux.x86_64.tar.xz" \ + | sudo tar -xJf - --strip-components=1 -C /usr/local/bin "shellcheck-${v}/shellcheck" + shellcheck --version - name: Refine gate contract tests run: pytest tests/test_refine_gate.py -v diff --git a/tests/statusline/test_fit_and_pace.sh b/tests/statusline/test_fit_and_pace.sh index d2156cd..b446fea 100755 --- a/tests/statusline/test_fit_and_pace.sh +++ b/tests/statusline/test_fit_and_pace.sh @@ -305,8 +305,9 @@ function test_probe_cols_env_override_wins() { # The postcondition is "a positive integer, always". The override is an escape # hatch, not an exemption: garbage must fall through to the probes, never reach # the caller, where it would break the arithmetic width comparisons. -# shellcheck disable=SC2329 # The stubs below ARE invoked — by name -# shadowing from inside probe_cols, which shellcheck cannot follow. +# shellcheck disable=SC2329,SC2317 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. Both +# codes are listed: it reports this as SC2317 before 0.11.0, SC2329 after. function test_probe_cols_rejects_a_non_numeric_override() { local out for bad in "abc" "0" "-5" "80x24" " "; do @@ -317,8 +318,9 @@ function test_probe_cols_rejects_a_non_numeric_override() { return 0 } -# shellcheck disable=SC2329 # The stubs below ARE invoked — by name -# shadowing from inside probe_cols, which shellcheck cannot follow. +# shellcheck disable=SC2329,SC2317 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. Both +# codes are listed: it reports this as SC2317 before 0.11.0, SC2329 after. function test_probe_cols_falls_back_to_columns_when_the_tty_is_unreadable() { local out out=$(stty() { return 1; }; unset STATUSLINE_COLS; COLUMNS=137; probe_cols) @@ -335,8 +337,9 @@ function test_probe_cols_falls_back_to_columns_when_the_tty_is_unreadable() { # session. It must NOT be consulted, so the answer here is the wide fallback and # never the stub's 80. Command substitution puts stdout on a pipe, so this is # the real condition, not a simulated one. -# shellcheck disable=SC2329 # The stubs below ARE invoked — by name -# shadowing from inside probe_cols, which shellcheck cannot follow. +# shellcheck disable=SC2329,SC2317 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. Both +# codes are listed: it reports this as SC2317 before 0.11.0, SC2329 after. function test_probe_cols_ignores_tput_when_stdout_is_not_a_terminal() { local out out=$(stty() { return 1; }; tput() { printf '80'; } @@ -349,8 +352,9 @@ function test_probe_cols_ignores_tput_when_stdout_is_not_a_terminal() { # When nothing can answer, the guess is deliberately WIDE: an over-generous # width reproduces the pre-width-aware rendering, an over-tight one hides # information that would have fitted. -# shellcheck disable=SC2329 # The stubs below ARE invoked — by name -# shadowing from inside probe_cols, which shellcheck cannot follow. +# shellcheck disable=SC2329,SC2317 # The stubs below ARE invoked — by name +# shadowing from inside probe_cols, which shellcheck cannot follow. Both +# codes are listed: it reports this as SC2317 before 0.11.0, SC2329 after. function test_probe_cols_final_fallback_is_wide() { local out out=$(stty() { return 1; }; tput() { return 1; } From 05c66eed3b061d5337416179b1aca8ac30d0dc76 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 15:28:59 +0200 Subject: [PATCH 7/7] docs(statusline): correct FIT_RATIO provenance to an honest source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §8 citation on FIT_RATIO pointed at pictet-tech claude-statusline assets/statusline.sh:98-108. That plugin has been uninstalled, so the cited source can no longer be consulted, and its claim (a line near the full width is truncated with "…" and costs the block its second row) was never reproduced in this repo. A citation nobody can follow is not a source. Replaces it with what is actually established — code.claude.com/docs/en/ statusline, read 2026-07-26 against Claude Code 2.1.220: the reserve the host takes is additive (statusLine.padding, "extra horizontal spacing (in characters)", default 0, added to the interface's built-in spacing, whose column count is unpublished), not proportional. Records that 85 is an inherited working default rather than a measurement, and states the known cost of keeping it: measure_widths.sh puts preset l's widest line at 89 columns, so at 85% l is untrimmed only from 104 columns up, while SIZE_L_MIN_COLS selects l from 90 — l is trimmed on every refresh between those two widths. Comment-only; FIT_RATIO stays 85 and no rendered output changes. Verified: shellcheck 0.11.0 clean, 46 + 22 tests pass, smoke render exit 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- .../statusline/assets/statusline-lib/fit.sh | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/plugins/statusline/assets/statusline-lib/fit.sh b/plugins/statusline/assets/statusline-lib/fit.sh index f0435b9..427da9c 100644 --- a/plugins/statusline/assets/statusline-lib/fit.sh +++ b/plugins/statusline/assets/statusline-lib/fit.sh @@ -20,12 +20,31 @@ # lowest-priority segments itself. # Share of the terminal width a line may occupy. Lines are fitted to a fraction -# of the width rather than to the width itself: a line that reaches the right -# margin is truncated by the host and can cost the block a row. -# source: pictet-tech claude-statusline assets/statusline.sh:98-108 ("If line1 -# approaches cols, Claude Code renders '…' at the right margin AND drops the -# second status line"), whose 85% target this reuses unchanged — an inherited -# measured claim, not one re-measured here. +# of the width rather than to the width itself, because the host keeps part of +# the row for its own chrome and cuts whatever crosses into it. +# +# NOT A MEASURED CONSTANT — read this before trusting it. 85 is inherited from +# the pictet-tech claude-statusline plugin (assets/statusline.sh:98-108), which +# is no longer installed here and can no longer be consulted. Its claim — that a +# line approaching the full width is truncated with "…" AND costs the block its +# second row — has never been reproduced against the host by this repo. The +# value is a working default carried forward, not evidence. +# +# What IS established, source: code.claude.com/docs/en/statusline, read +# 2026-07-26 against Claude Code 2.1.220 — the reserve the host takes is +# ADDITIVE, not proportional. `statusLine.padding` is "extra horizontal spacing +# (in characters)", defaults to 0, and is "in addition to the interface's +# built-in spacing", whose column count the docs do not publish. A percentage +# therefore models the constraint in the wrong shape: it over-reserves on wide +# terminals and under-reserves on narrow ones. +# +# Known cost of keeping the ratio, so the next reader is not surprised by it: +# tests/statusline/measure_widths.sh puts preset l's widest line at 89 columns +# (measured 2026-07-26), so at 85% l needs 104 columns to render untrimmed — +# while SIZE_L_MIN_COLS in layout.sh selects l from 90 up. Between 90 and 104 +# columns l is selected and then trimmed on every refresh. Replacing this with a +# measured additive reserve closes that gap and needs one observation against a +# live host; it is deliberately not done here. FIT_RATIO=85 # vislen — terminal columns a rendered segment will occupy.