From 25f497a0d6e03dabebafa4576e805eb135ddc0fa Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 16:28:32 +0200 Subject: [PATCH 1/2] test(statusline): make the heat_rgb perf test measure something, and stop it flaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_perf_heat_rgb_20run_avg failed roughly 4 runs in 10 on an idle machine. Two defects, one cause each. It asserted nothing. The "baseline" was a single timing of heat_rgb and the comparand was the mean of twenty more timings of the same call, so both sides contained identical work and the delta was noise around zero. With n=1 on the baseline, one slow first sample failed the assertion outright. The instrument dwarfed the phenomenon. Timing an EMPTY pair of `date +%s%N` calls on this host returned 4.2, 21.4, 21.7, 21.8 and 23.5 ms across five consecutive attempts — a ~19 ms spread against a 5 ms budget. Per-sample timestamps cannot support that assertion at any sample count the suite can afford. Now: one timestamp pair around a block of 2000 calls, and the same around an equally-sized no-op control carrying the same redirection. The fork is amortized (~0.01 ms per call) and the control subtracts everything that is not heat_rgb. Measured on this host: heat_rgb 173 us/call, control 41 us/call — a 0.13 ms delta against the unchanged 5 ms budget (AC-015/NFR-002). Verified: 15 consecutive runs, 0 failures (was 4/15 failing before, and 4/10 with an interim per-sample fix that corrected only the control). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- tests/statusline/test_heat_rgb.sh | 56 +++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/statusline/test_heat_rgb.sh b/tests/statusline/test_heat_rgb.sh index f01a2cc..69b0ceb 100755 --- a/tests/statusline/test_heat_rgb.sh +++ b/tests/statusline/test_heat_rgb.sh @@ -215,25 +215,45 @@ function test_static_no_eval_no_unquoted_expansion() { return 0 } -# --- performance: heat_rgb 20-run average, guard-railed --- -# Only the DELTA vs baseline is asserted (matches AC-015/NFR-002 exactly: -# "delta < +5ms"). An earlier draft also asserted an absolute avg < 3ms -# p95-proxy from the PRD test-template's aspirational budget (05-testing.md); -# that absolute check was dropped after measurement showed it conflates the -# instrument's own cost with the phenomenon under test: `date +%s%N` forks an -# external binary twice per sample, and on this machine that fork alone costs -# ~5ms (measured: `s=$(date +%s%N); e=$(date +%s%N); echo $((e-s))` ~5,034,000ns -# in isolation) — already over the 3ms budget before heat_rgb (a bash builtin -# doing integer comparisons, no I/O) ever runs. The fork cost is present -# identically in both the baseline and the 20-run average, so it cancels out -# of the delta, which is the metric that actually answers the non-regression -# question NFR-002 asks (source: measured on this host, 2026-07-15, see PR). +# --- performance: heat_rgb cost above the instrument, 20 paired samples --- +# Asserts the DELTA only (AC-015/NFR-002: "delta < +5ms"). An absolute budget is +# not assertable here: `date +%s%N` forks an external binary twice per sample, +# and on this machine that fork alone costs ~5ms (measured: +# `s=$(date +%s%N); e=$(date +%s%N); echo $((e-s))` ~5,034,000ns in isolation), +# which already exceeds any budget heat_rgb — a bash function doing integer +# comparisons with no I/O — could be held to. +# +# The delta is measured against a CONTROL, not against an earlier sample of +# heat_rgb itself. The previous form timed one heat_rgb call as its baseline and +# compared it to the mean of twenty more: both sides contained the same work, so +# the delta was noise around zero rather than a cost, and with n=1 on the +# baseline a single slow first sample failed the assertion outright. It failed +# roughly 4 runs in 10 on an otherwise idle machine (measured 2026-07-26, this +# host) — a flaky test asserting nothing. +# +# The timestamps are also taken ONCE PER BLOCK rather than once per sample, +# because the fork is not merely large, it is erratic: timing an EMPTY pair of +# `date +%s%N` calls on this host returned 4.2, 21.4, 21.7, 21.8 and 23.5 ms +# across five consecutive attempts (measured 2026-07-26). A ~19 ms spread cannot +# support a 5 ms assertion at any sample count that keeps the suite fast — which +# is why the old form was flaky rather than merely imprecise. Two timestamps +# around N iterations divide that spread by N instead, so at N=2000 the +# instrument contributes ~0.01 ms per call. +# +# Both arms are measured the same way, and the control carries the same +# redirection so that only heat_rgb's own work separates them. Measured on this +# host at N=2000: heat_rgb 173 us/call, control 41 us/call — a delta of 0.13 ms +# against a 5 ms budget, and the whole test runs in ~0.4 s. function test_perf_heat_rgb_20run_avg() { - local i s e total=0 baseline avg delta - s=$(date +%s%N); heat_rgb 50 >/dev/null; e=$(date +%s%N); baseline=$((e-s)) - for i in $(seq 1 20); do s=$(date +%s%N); heat_rgb 50 >/dev/null; e=$(date +%s%N); total=$((total+(e-s))); done - avg=$((total/20)); delta=$((avg-baseline)) - [ "$delta" -gt 5000000 ] && { echo "FAIL: delta ${delta}ns > +5ms" >&2; return 1; } + local i s e n=2000 per_call per_ctrl delta + heat_rgb 50 >/dev/null # warm-up, discarded: first call pays one-time costs + s=$(date +%s%N); for ((i=0;i/dev/null; done; e=$(date +%s%N) + per_call=$(( (e-s)/n )) + s=$(date +%s%N); for ((i=0;i/dev/null; done; e=$(date +%s%N) + per_ctrl=$(( (e-s)/n )) + delta=$(( per_call - per_ctrl )) + [ "$delta" -gt 5000000 ] && { + echo "FAIL: heat_rgb ${delta}ns au-dessus du controle > +5ms" >&2; return 1; } return 0 } From 7fe3465900ff4085b3d121c567f2d02bf71f9485 Mon Sep 17 00:00:00 2001 From: cdeust Date: Sun, 26 Jul 2026 16:28:58 +0200 Subject: [PATCH 2/2] fix(statusline): budget lines by the host's real reserve, not an inherited 85% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIT_RATIO=85 held every line to 85% of the terminal. The figure came from the pictet-tech claude-statusline plugin, which is no longer installed, and its claim — a line near the full width is truncated AND costs the block its second row — was never reproduced here. The previous commit could only document that; this one replaces it with the host's actual behaviour, read from Claude Code 2.1.220 itself. The reserve is additive, and it is now taken from the renderer: -> 4 cols -> 2*padding The wider of the paddingRight branches is used: one column pessimistic trims a segment early, one column optimistic is cut by the host. statusLine.padding is read from settings.json (read_statusline_padding), type-strict in jq because a JSON string "2" is not a number to the host. The same read falsifies the inherited claim: every line is rendered individually, so an over-wide line is truncated on its own and costs no other row. Line count is independent of line width. Preset selection moves onto the budget rather than the raw width, which removes a contradiction between two constants: l was selected from 90 columns up but could not render untrimmed below 104, so between those widths it was selected and then trimmed on every refresh. SIZE_L_MIN_COLS=90 becomes SIZE_L_MIN_BUDGET=89 — exactly l's widest measured line, so "budget >= widest" is the fit condition and the two cannot disagree. Verified: at 95 columns with padding 1, budget 89 and widest emitted line 89. probe_cols consults $COLUMNS before the controlling tty. The host sets COLUMNS to the width it renders into (`let{columns:N}=process.stdout;if(N)L.COLUMNS= String(N)`), which is the authority; the tty probe stays below it for hand-run invocations. Capturing a live payload on 2026-07-26 showed the hook environment has no controlling terminal at all, so that rung answers nothing under the host, and that the statusLine JSON carries no width field of any kind. Net effect on a 200-column terminal: a line may use 196 columns instead of 170. Verified: shellcheck 0.11.0 clean on renderer, modules and suites; 53 + 22 shell tests and 18 python tests pass; no rendered line exceeds its budget at 40, 60, 80, 95, 120, 200 or 241 columns. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4 --- .claude-plugin/marketplace.json | 4 +- CHANGELOG.md | 37 ++++++ plugins/statusline/.claude-plugin/plugin.json | 2 +- plugins/statusline/README.fr.md | 23 ++-- plugins/statusline/README.md | 21 ++-- .../statusline/assets/statusline-command.sh | 8 +- .../assets/statusline-lib/config.sh | 35 ++++++ .../statusline/assets/statusline-lib/fit.sh | 67 ++++++---- .../assets/statusline-lib/layout.sh | 69 ++++++---- tests/statusline/measure_widths.sh | 14 ++- tests/statusline/test_fit_and_pace.sh | 119 +++++++++++++++++- 11 files changed, 316 insertions(+), 83 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6d812a3..bb0793b 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.1.0" + "version": "2.1.1" }, "plugins": [ { @@ -58,7 +58,7 @@ "name": "statusline", "source": "./plugins/statusline", "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", + "version": "2.1.1", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b30f8..8b33aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ 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.1] - 2026-07-26 + +Statusline only. No change to context-guard or refine-gate. + +### Fixed + +- **The line-width budget is now the host's actual reserve, not a 15% haircut.** + `FIT_RATIO=85` held every line to 85% of the terminal, a figure inherited from + an unrelated plugin and never verified here. The reserve Claude Code takes is + additive, and it is now read from the host itself (2.1.220): 4 columns of + container padding (``) plus + `statusLine.padding` on both sides. On a 200-column terminal a line may now use + 196 columns instead of 170; narrow terminals reserve less than before. +- **Preset selection no longer contradicts the budget.** The verbosity preset is + chosen on the fitted budget rather than the raw width. Previously `l` was + selected from 90 columns up but could not render untrimmed below 104, so + between those widths it was selected and then trimmed on every refresh. +- **`$COLUMNS` is consulted before the controlling tty.** The host sets it to the + width it renders into, which is the authority; the tty probe stays as the + fallback for hand-run invocations. Under the host the tty probe answers nothing + at all — there is no controlling terminal in the hook environment. + +### Notes + +- The claim that an over-wide first line drops the second status line is false + for 2.1.220: each line is rendered `` independently. + +### Internal + +- `test_perf_heat_rgb_20run_avg` was flaky (~4 runs in 10) and asserted nothing: + it compared one sample of `heat_rgb` against the mean of twenty more of the + same call, so the delta was noise around zero, and per-sample `date +%s%N` + forks vary by ~19 ms on the measurement host — four times the 5 ms budget + being asserted. It now times a block of 2000 calls against an equally-sized + no-op control, amortizing the fork: `heat_rgb` measures 0.13 ms above the + control against the same 5 ms budget. + ## [2.1.0] - 2026-07-26 Statusline only. No change to context-guard or refine-gate. diff --git a/plugins/statusline/.claude-plugin/plugin.json b/plugins/statusline/.claude-plugin/plugin.json index 50e9211..7be84c9 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 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", + "version": "2.1.1", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/plugins/statusline/README.fr.md b/plugins/statusline/README.fr.md index af324e5..db546b9 100644 --- a/plugins/statusline/README.fr.md +++ b/plugins/statusline/README.fr.md @@ -130,14 +130,21 @@ 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`. +queue qui part**. + +Le budget est la largeur du terminal moins ce que l'hôte garde pour lui : 4 +colonnes de marge du conteneur, plus `statusLine.padding` compté deux fois +(l'hôte l'applique des deux côtés). Ces deux chiffres sont lus dans le rendu de +Claude Code 2.1.220 lui-même, qui enveloppe le bloc dans +`` et chaque ligne dans +`` — une ligne trop large est donc tronquée **seule** et ne +coûte jamais une rangée au bloc. + +La largeur est sondée depuis `$COLUMNS` d'abord — l'hôte y met la largeur dans +laquelle il rend — puis le tty de contrôle, 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 diff --git a/plugins/statusline/README.md b/plugins/statusline/README.md index 59d389f..c12a5de 100644 --- a/plugins/statusline/README.md +++ b/plugins/statusline/README.md @@ -123,14 +123,19 @@ wildly on a single burst. 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`. +most-important-first, so **the tail is what goes**. + +The budget is the terminal width minus what the host keeps for itself: 4 columns +of container padding, plus `statusLine.padding` twice over (the host applies it +on both sides). Both figures are read from Claude Code 2.1.220's own renderer, +which wraps the block in `` and each line +in `` — so an over-wide line is truncated **on its own** +and never costs the block another row. + +Width is probed from `$COLUMNS` first — the host sets it to the width it is +rendering into — then the controlling tty, 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 diff --git a/plugins/statusline/assets/statusline-command.sh b/plugins/statusline/assets/statusline-command.sh index 62101dc..34784a1 100755 --- a/plugins/statusline/assets/statusline-command.sh +++ b/plugins/statusline/assets/statusline-command.sh @@ -127,8 +127,12 @@ read_transcript_telemetry "$transcript_path" "$now_epoch" # -> txt_* read_subagent_totals "$session_id" # -> sub_count sub_tokens COLS=$(probe_cols) -FIT_W=$(( COLS * FIT_RATIO / 100 )) -resolve_preset "$COLS" # -> SIZE RANK CTX_W BW +FIT_W=$(fit_budget "$COLS" "$(read_statusline_padding)") +# The preset is chosen on the BUDGET, not the raw width: what decides whether a +# preset fits is the room its lines actually get, which is the width less the +# host's chrome (fit.sh). Choosing on the raw width would select a preset the +# budget then trims on every refresh. +resolve_preset "$FIT_W" # -> SIZE RANK CTX_W BW # --- 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. diff --git a/plugins/statusline/assets/statusline-lib/config.sh b/plugins/statusline/assets/statusline-lib/config.sh index 8ae8896..fc36f9e 100644 --- a/plugins/statusline/assets/statusline-lib/config.sh +++ b/plugins/statusline/assets/statusline-lib/config.sh @@ -13,6 +13,10 @@ # drift. CTXGUARD_CONFIG="${HOME}/.claude/ctxguard-thresholds.json" BUDGET_CONFIG="${HOME}/.claude/statusline-budget.json" +# The host's own settings file. Read for exactly one field — statusLine.padding +# — because that padding is applied by the host AROUND this script's output and +# therefore comes out of the width available to it (see fit.sh, fit_budget). +SETTINGS_CONFIG="${HOME}/.claude/settings.json" # resolve_ctx_thresholds — context-window thresholds for the model named $1. # pre: $1 the model's display name (any case). @@ -67,6 +71,37 @@ read_cache_ttl_min() { case "$b_ttl" in ''|*[!0-9]*) printf '5' ;; *) printf '%s' "$b_ttl" ;; esac } +# read_statusline_padding — the host's configured statusLine.padding. +# post: prints a non-negative integer, always. 0 when the setting is absent, +# unreadable, negative or non-numeric — which is also the host's own +# default, so an unreadable settings file degrades to the host default +# rather than to a guess. +# source: code.claude.com/docs/en/statusline — "The optional `padding` field +# adds extra horizontal spacing (in characters) to the status line +# content. Defaults to `0`." +# Only the user-level file is read. A project or local settings file can also +# carry statusLine, and the host merges them; this reader does not, so a +# project-level padding is not seen. The cost of that miss is bounded: the +# budget is off by twice the difference, and fit_line trims a segment early or +# late by that much. Merging the full settings precedence would put the host's +# resolution rules in this renderer, which is a larger contract than the width +# budget needs. +# The type test is done in jq, not on the printed text: the setting is a JSON +# number for the host, and a JSON string "2" is NOT one — the host would hand it +# to a layout engine that expects a number. Filtering on the shell side alone +# would accept "2" and charge two columns the host never indents. +read_statusline_padding() { + local p="" + [ -r "$SETTINGS_CONFIG" ] && { + p=$(jq -r ' + .statusLine.padding as $p + | if ($p | type) == "number" and $p >= 0 and ($p | floor) == $p + then ($p | tostring) else "0" end + ' "$SETTINGS_CONFIG" 2>/dev/null) || p="" + } + case "$p" in ''|*[!0-9]*) printf '0' ;; *) printf '%s' "$p" ;; 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. diff --git a/plugins/statusline/assets/statusline-lib/fit.sh b/plugins/statusline/assets/statusline-lib/fit.sh index 427da9c..6e24b51 100644 --- a/plugins/statusline/assets/statusline-lib/fit.sh +++ b/plugins/statusline/assets/statusline-lib/fit.sh @@ -19,33 +19,44 @@ # 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. +# Columns the host keeps for itself around this script's output, EXCLUDING the +# user-configurable statusLine.padding (fit_budget adds that separately). # -# 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. +# source: Claude Code 2.1.220, /Users/…/.local/share/claude/versions/2.1.220, +# read 2026-07-26. The status line is rendered by a component whose container is +# +# so the row is the full terminal width less 2 columns on the left and 2 on the +# right — 4. The compact branch reserves 1 on the right, i.e. 3 total; the wider +# of the two is used, because a budget that is one column pessimistic trims one +# segment early, while one column optimistic is cut by the host. # -# 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 +# The same read settles what happens on overflow, replacing an inherited claim +# this repo could not reproduce: each emitted line is rendered as +# +# individually (single-line path, and the per-line map for multi-line output), +# so an over-wide line is truncated ON ITS OWN and does NOT cost the block any +# other row. Line count is independent of line width. +FIT_CHROME_COLS=4 + +# fit_budget — columns one rendered line may occupy. +# pre: $1 the terminal width in columns, $2 the configured statusLine.padding. +# Both are validated here rather than trusted: $1 comes from probe_cols, +# whose last rung is a fallback, and $2 from a user-edited settings file. +# post: prints a positive integer, always. Pure: no I/O, no global mutation. +# The host applies padding as Ink's paddingX, which indents BOTH sides, so the +# configured value costs twice its own size. A terminal too narrow to hold even +# one column after the reserve yields 1 rather than 0 or a negative: fit_line +# treats a non-positive budget as "no budget" and returns the line untrimmed, +# which is precisely the overflow this exists to prevent. +fit_budget() { + local cols="$1" pad="${2:-0}" w + case "$cols" in ''|*[!0-9]*) printf '1'; return ;; esac + case "$pad" in ''|*[!0-9]*) pad=0 ;; esac + w=$(( cols - FIT_CHROME_COLS - 2 * pad )) + [ "$w" -lt 1 ] && w=1 + printf '%s' "$w" +} # vislen — terminal columns a rendered segment will occupy. # pre: $1 is a segment as built by this renderer: literal "\033[m" SGR @@ -58,8 +69,10 @@ FIT_RATIO=85 # - 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. +# ambiguous characters double-width will be under-measured, and the budget +# from fit_budget is exact rather than generous, so such a line is cut by +# the host's own truncate. STATUSLINE_COLS is the override for that case: +# declaring a narrower width buys back the difference. # - 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() { diff --git a/plugins/statusline/assets/statusline-lib/layout.sh b/plugins/statusline/assets/statusline-lib/layout.sh index abe8e42..5e4ae62 100644 --- a/plugins/statusline/assets/statusline-lib/layout.sh +++ b/plugins/statusline/assets/statusline-lib/layout.sh @@ -14,28 +14,46 @@ # 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. +# The statusLine JSON carries no terminal size (verified 2026-07-26 by capturing +# a live payload: it has no columns/width/size field of any kind), 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. +# +# $COLUMNS is consulted FIRST because the host sets it to the width it is itself +# rendering into, which is the authority this budget needs — an inferred size +# that disagrees with it is wrong by definition. +# source: code.claude.com/docs/en/statusline — "Claude Code sets these to the +# current terminal dimensions before running your script" (v2.1.153+), and +# Claude Code 2.1.220's hook spawn confirms it verbatim: +# `let {columns:N,rows:P}=process.stdout; if(N) L.COLUMNS=String(N)`. +# +# The controlling-tty probe is kept BELOW it, not removed: it is what answers +# when the script is run by hand from a shell, or by a host too old to export +# COLUMNS. Under the real host it answers nothing at all — the same 2026-07-26 +# capture found no controlling terminal in the hook environment, so `< /dev/tty` +# fails and this rung falls straight through. +# +# `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 + cols="${COLUMNS:-}" + # 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) + cols=$( { stty size < /dev/tty | awk '{print $2}'; } 2>/dev/null ) ;; + 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" @@ -73,17 +91,20 @@ probe_cols() { # 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 +# The threshold is l's widest measured line exactly, compared against the fitted +# BUDGET (fit_budget in fit.sh) rather than the raw terminal width: the budget is +# the room a line actually gets, so "budget >= widest line" is precisely the +# condition for l to render untrimmed, and the two constants cannot disagree. +# Comparing against the raw width instead would select l on terminals whose +# budget cannot hold it, and trim it on every refresh. +# 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_BUDGET=89 -# resolve_preset — choose the verbosity preset for a terminal $1 columns wide. -# pre: $1 a positive integer column count. +# resolve_preset — choose the verbosity preset for a line budget of $1 columns. +# pre: $1 a positive integer, the per-line budget from fit_budget — NOT the raw +# terminal width. See SIZE_L_MIN_BUDGET above for why. # 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() { @@ -96,7 +117,7 @@ resolve_preset() { 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 + [ "$cols" -lt "$SIZE_L_MIN_BUDGET" ] && case "$SIZE" in m|l|xl) SIZE="s" ;; esac ;; esac case "$SIZE" in diff --git a/tests/statusline/measure_widths.sh b/tests/statusline/measure_widths.sh index e6153d5..32173ce 100755 --- a/tests/statusline/measure_widths.sh +++ b/tests/statusline/measure_widths.sh @@ -13,9 +13,11 @@ # 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. +# The cap threshold for a preset is that preset's widest line PLUS the columns +# the host keeps for itself (fit_budget), i.e. the narrowest terminal in which +# the preset still renders untrimmed. It is reported at statusLine.padding 0 — +# the host's own default — so the number is a property of this renderer and not +# of whoever runs the harness; a reader with padding P configured adds 2*P. # # Isolation: STATUSLINE_COST_LOG points at a throwaway ledger under a temp dir, # so a measurement run never touches ~/.claude/statusline-costs.jsonl. All @@ -25,7 +27,7 @@ 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. +# vislen(), FIT_CHROME_COLS and the palette come from the script under test. # 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" @@ -74,7 +76,7 @@ for size in xs s m l xl; do STATUSLINE_SIZE="$size" \ bash "$SCRIPT_UNDER_TEST" < "$TMPDIR_M/payload.json" ) - # narrowest terminal that still renders this preset untrimmed - cap=$(( widest * 100 / FIT_RATIO )) + # narrowest terminal that still renders this preset untrimmed, at padding 0 + cap=$(( widest + FIT_CHROME_COLS )) printf '%-4s %8s %8s %s\n' "$size" "$widest" "$cap" "$nlines" done diff --git a/tests/statusline/test_fit_and_pace.sh b/tests/statusline/test_fit_and_pace.sh index b446fea..c573e7d 100755 --- a/tests/statusline/test_fit_and_pace.sh +++ b/tests/statusline/test_fit_and_pace.sh @@ -321,14 +321,26 @@ function test_probe_cols_rejects_a_non_numeric_override() { # 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() { +function test_probe_cols_prefers_columns_over_the_tty() { local out out=$(stty() { return 1; }; unset STATUSLINE_COLS; COLUMNS=137; probe_cols) assert_eq "$out" "137" "COLUMNS" || return 1 + # The precedence itself, which is the point of the rung order: the host sets + # COLUMNS to the width it renders into, so it must beat a tty that answers + # something else. A stty that succeeds with a DIFFERENT width must not win. + out=$(stty() { printf '61 999'; }; unset STATUSLINE_COLS; COLUMNS=137; probe_cols) + assert_eq "$out" "137" "COLUMNS bat un tty lisible" || 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 tty rung's SUCCESS path is deliberately not asserted here, and stubbing + # `stty` would not reach it: what fails first in a test process is the + # `< /dev/tty` REDIRECTION, since the runner has no controlling terminal — + # the same reason the rung answers nothing under the host (verified against a + # live payload, 2026-07-26). Only a pty harness could exercise it, and the + # rung exists precisely for the case a pty harness would simulate. Its failure + # path — falling through to the next rung — is what the cases above pin. } # The regression this rung exists for: with stdout on a pipe — which is how the @@ -360,7 +372,97 @@ function test_probe_cols_final_fallback_is_wide() { 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; } + [ "$out" -ge "$SIZE_L_MIN_BUDGET" ] || { echo "FAIL: repli sous le seuil l" >&2; return 1; } + return 0 +} + +# =========================== fit_budget ================================== +# fit_budget converts a terminal width into the columns one line actually gets. +# The reserve is the host's own: 4 columns of container padding (FIT_CHROME_COLS) +# plus statusLine.padding on BOTH sides, because the host applies it as Ink's +# paddingX. Every arm below is a path in the function, including the two +# validation arms and the floor. + +function test_fit_budget_subtracts_the_host_chrome() { + assert_eq "$(fit_budget 241 0)" "237" "241 sans padding" || return 1 + assert_eq "$(fit_budget 100 0)" "96" "100 sans padding" +} + +# The configured padding costs TWICE its value — one column each side. A test +# that asserted a single subtraction would pass on a renderer that indents only +# the left, which is not what the host does. +function test_fit_budget_charges_padding_on_both_sides() { + assert_eq "$(fit_budget 241 1)" "235" "padding 1" || return 1 + assert_eq "$(fit_budget 241 3)" "231" "padding 3" || return 1 + # the difference between two paddings is exactly twice their difference + local a b + a=$(fit_budget 200 0); b=$(fit_budget 200 5) + assert_eq "$(( a - b ))" "10" "delta = 2 x padding" +} + +# The budget feeds arithmetic comparisons and fit_line, whose contract needs a +# positive integer. Neither input is trusted: the width comes from probe_cols +# (whose last rung is a fallback) and the padding from a user-edited JSON file. +function test_fit_budget_validates_both_inputs() { + for bad in "" "abc" "80x24" "-5" " "; do + assert_eq "$(fit_budget "$bad" 0)" "1" "largeur invalide [$bad]" || return 1 + done + # A non-numeric padding degrades to 0 rather than poisoning the width. + for bad in "" "abc" "-2" "1.5"; do + assert_eq "$(fit_budget 100 "$bad")" "96" "padding invalide [$bad]" || return 1 + done + # Omitted padding is the host's own default of 0. + assert_eq "$(fit_budget 100)" "96" "padding absent" +} + +# A terminal too narrow to hold anything after the reserve must still yield a +# POSITIVE budget: fit_line reads 0 (and any non-numeric) as "no budget given" +# and returns the line untrimmed, which is the exact overflow this prevents. +function test_fit_budget_floor_is_positive() { + assert_eq "$(fit_budget 4 0)" "1" "largeur = chrome" || return 1 + assert_eq "$(fit_budget 1 0)" "1" "largeur sous le chrome" || return 1 + assert_eq "$(fit_budget 10 20)" "1" "padding superieur a la largeur" || return 1 + # and the floor really is usable by fit_line — it must trim, not pass through + local long="aaaaaaaaaaaaaaaaaaaa" + [ "$(vislen "$(fit_line "$long" "$(fit_budget 4 0)")")" -le 1 ] \ + || { echo "FAIL: plancher non exploitable par fit_line" >&2; return 1; } + return 0 +} + +# ====================== read_statusline_padding ========================== +# The host's default is 0 (code.claude.com/docs/en/statusline). Every arm that +# cannot produce a trustworthy number must land on that same 0, so a broken or +# absent settings file degrades to the host's behaviour and never to a guess. + +function test_read_statusline_padding_reads_the_setting() { + SETTINGS_CONFIG="$TEST_TMPDIR/settings.json" + printf '{"statusLine":{"type":"command","command":"x","padding":2}}' > "$SETTINGS_CONFIG" + assert_eq "$(read_statusline_padding)" "2" "padding lu" || return 1 + printf '{"statusLine":{"padding":0}}' > "$SETTINGS_CONFIG" + assert_eq "$(read_statusline_padding)" "0" "padding zero" +} + +function test_read_statusline_padding_defaults_to_zero() { + SETTINGS_CONFIG="$TEST_TMPDIR/absent-settings.json" + assert_eq "$(read_statusline_padding)" "0" "fichier absent" || return 1 + SETTINGS_CONFIG="$TEST_TMPDIR/settings.json" + # no statusLine at all, statusLine without padding, and a malformed file + printf '{"model":"x"}' > "$SETTINGS_CONFIG" + assert_eq "$(read_statusline_padding)" "0" "statusLine absent" || return 1 + printf '{"statusLine":{"type":"command"}}' > "$SETTINGS_CONFIG" + assert_eq "$(read_statusline_padding)" "0" "padding absent" || return 1 + printf '{ this is not json' > "$SETTINGS_CONFIG" + assert_eq "$(read_statusline_padding)" "0" "json invalide" +} + +# A padding that is not a non-negative integer cannot be charged against the +# width — a negative one would WIDEN the budget past the terminal. +function test_read_statusline_padding_rejects_non_integers() { + SETTINGS_CONFIG="$TEST_TMPDIR/settings.json" + for bad in '"2"' '-1' '1.5' 'null' 'true' '"abc"' '[]'; do + printf '{"statusLine":{"padding":%s}}' "$bad" > "$SETTINGS_CONFIG" + assert_eq "$(read_statusline_padding)" "0" "padding invalide [$bad]" || return 1 + done return 0 } @@ -408,9 +510,9 @@ function test_resolve_preset_defaults_to_l_on_a_wide_terminal() { function test_resolve_preset_width_cap_boundary() { BUDGET_CONFIG="$TEST_TMPDIR/absent.json" unset STATUSLINE_SIZE - resolve_preset "$SIZE_L_MIN_COLS" + resolve_preset "$SIZE_L_MIN_BUDGET" assert_eq "$SIZE" "l" "au seuil" || return 1 - resolve_preset $((SIZE_L_MIN_COLS - 1)) + resolve_preset $((SIZE_L_MIN_BUDGET - 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" @@ -593,9 +695,16 @@ main() { 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_prefers_columns_over_the_tty test_probe_cols_ignores_tput_when_stdout_is_not_a_terminal test_probe_cols_final_fallback_is_wide + test_fit_budget_subtracts_the_host_chrome + test_fit_budget_charges_padding_on_both_sides + test_fit_budget_validates_both_inputs + test_fit_budget_floor_is_positive + test_read_statusline_padding_reads_the_setting + test_read_statusline_padding_defaults_to_zero + test_read_statusline_padding_rejects_non_integers 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