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 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 }