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..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 @@ -28,11 +38,20 @@ 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, 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: | @@ -45,3 +64,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..c0b30f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,83 @@ 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` (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 + +- 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. +- 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. + ## [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/assets/costs.sh b/plugins/statusline/assets/costs.sh new file mode 100755 index 0000000..ae621d3 --- /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") + 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") + 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 +} + +_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) + if [[ "$dow" -le 5 ]]; then count=$(( count + 1 )); fi + 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-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-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() 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..427da9c --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/fit.sh @@ -0,0 +1,144 @@ +# 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, 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. +# 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..abe8e42 --- /dev/null +++ b/plugins/statusline/assets/statusline-lib/layout.sh @@ -0,0 +1,109 @@ +# 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() { + # 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:-}" + 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 + # 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/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" diff --git a/tests/statusline/measure_widths.sh b/tests/statusline/measure_widths.sh new file mode 100755 index 0000000..e6153d5 --- /dev/null +++ b/tests/statusline/measure_widths.sh @@ -0,0 +1,80 @@ +#!/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. +# 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; } + +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" + # 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}" + 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 +} + +# =========================== 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,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 + 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,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) + 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,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'; } + 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,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; } + 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() { + 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. +# 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 +} + +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_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 + 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..f01a2cc 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 @@ -35,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}" @@ -173,24 +186,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 } @@ -226,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[@]}" }