From 4b8ad29782a2fa2dbc23696a49b069176f9d798d Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 15:50:08 -0300 Subject: [PATCH 1/6] feat(usage): render a reset days out as days and hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toward #34. `cp_usage_reset_in` stopped at hours, so a 7-day window three days from resetting rendered `85h 40m`. That is the same instant as `3d 13h` and only one of them is legible. It now uses the coarsest useful pair of units and never three: days and hours once a day is involved, hours and minutes below that, minutes alone under an hour, `<1m` under a minute. The 5-hour window cannot reach a day, so its rendering is unchanged — the existing assertions for the hour, minute and sub-minute tiers pin that, and the new ones cover the day boundary at exactly 24h and a nearly-full week. Three mutations confirm the new assertions bite: removing the tier restores `85h`, widening the boundary to 48h, and printing total hours instead of hours within the day all fail them. Signed-off-by: Diego Cotelo --- scripts/lib/usage.sh | 16 +++++++++++----- tests/test_usage.sh | 10 ++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/lib/usage.sh b/scripts/lib/usage.sh index 1a13fea..d8e1fde 100644 --- a/scripts/lib/usage.sh +++ b/scripts/lib/usage.sh @@ -279,17 +279,23 @@ cp_usage_detail() { return 0 } -# cp_usage_reset_in [] -> "4h 37m", "37m" or "<1m"; -# nothing, return 1, when the reset is not a future epoch. +# cp_usage_reset_in [] -> "3d 13h", "4h 37m", "37m" or +# "<1m"; nothing, return 1, when the reset is not a future epoch. +# +# The coarsest useful pair of units, never three: a 7-day window resets days +# out, where hours alone stop being readable -- 85h 40m and 3d 13h are the same +# instant and only one of them is legible. Minutes are dropped once a day is +# involved for the same reason. cp_usage_reset_in() { - local at="${1:-}" now="${2:-}" left h m + local at="${1:-}" now="${2:-}" left d h m case "$at" in ''|*[!0-9]*) return 1 ;; esac [ -n "$now" ] || now="$(date +%s)" case "$now" in ''|*[!0-9]*) return 1 ;; esac left=$(( at - now )) [ "$left" -gt 0 ] || return 1 - h=$(( left / 3600 )); m=$(( (left % 3600) / 60 )) - if [ "$h" -gt 0 ]; then printf '%sh %sm\n' "$h" "$m" + d=$(( left / 86400 )); h=$(( left / 3600 )); m=$(( (left % 3600) / 60 )) + if [ "$d" -gt 0 ]; then printf '%sd %sh\n' "$d" "$(( (left % 86400) / 3600 ))" + elif [ "$h" -gt 0 ]; then printf '%sh %sm\n' "$h" "$m" elif [ "$m" -gt 0 ]; then printf '%sm\n' "$m" else printf '<1m\n' fi diff --git a/tests/test_usage.sh b/tests/test_usage.sh index 22652dd..1759dc4 100644 --- a/tests/test_usage.sh +++ b/tests/test_usage.sh @@ -431,6 +431,16 @@ now=1700000000 assert_eq '2h 19m' "$(cp_usage_reset_in $((now + 2*3600 + 19*60 + 30)) "$now")" 'reset_in renders hours and minutes' assert_eq '37m' "$(cp_usage_reset_in $((now + 37*60 + 5)) "$now")" 'reset_in renders minutes alone under an hour' assert_eq '<1m' "$(cp_usage_reset_in $((now + 20)) "$now")" 'reset_in renders <1m under a minute' +# A 7-day window resets days out, where hours alone stop being readable: 85h +# 13m is the same instant as 3d 13h and nobody reads the first one. +assert_eq '3d 13h' "$(cp_usage_reset_in $((now + 3*86400 + 13*3600 + 40*60)) "$now")" \ + 'reset_in renders days and hours once a day out' +assert_eq '1d 0h' "$(cp_usage_reset_in $((now + 86400)) "$now")" \ + 'reset_in switches to days at exactly 24h' +assert_eq '23h 59m' "$(cp_usage_reset_in $((now + 23*3600 + 59*60 + 30)) "$now")" \ + 'reset_in keeps hours and minutes just under a day' +assert_eq '6d 23h' "$(cp_usage_reset_in $((now + 6*86400 + 23*3600 + 59*60)) "$now")" \ + 'reset_in renders a nearly-full 7-day window' assert_fail cp_usage_reset_in "$now" "$now" # a reset that is due assert_fail cp_usage_reset_in $((now - 5)) "$now" # a reset already past assert_fail cp_usage_reset_in 'soon' "$now" # a non-epoch reset From 7d8fab5029a3c7f12a949589f36fb65f2b3dba03 Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 15:59:52 -0300 Subject: [PATCH 2/6] feat(statusline): weekly_threshold, and weekly as a known segment Toward #34. The plumbing only: the segment is accepted in a layout and the threshold resolves and validates, but nothing renders it yet. `weekly_threshold` rides on the thresholds line as a third field rather than a fifth line, so the four-line contract every positional consumer depends on is untouched, and a reader taking fields one and two is unaffected. It is its own setting, not a third member of the warn/critical pair: those are colour thresholds validated together, and a rejected pair must not drag the visibility threshold down with it, nor the reverse. Two assertions pin exactly that independence. Three places had to learn the new field, and two of them were found by the tests rather than by reading: the reporter and the test oracle both read the resolver's own output back with `IFS=tab read -r warn crit`, which silently swallowed the third field into `crit`. In the oracle that made `--argjson crit` invalid JSON, so its jq died and every genuine report looked like an over-report -- 19 failures that all traced to one missing variable. That is the derived oracle earning its keep: it noticed a field had shifted. Five malformed shapes join the four-line table and six join the report-exactly-when-replaced rule, which caught the reporter having no message for a rejected weekly_threshold: four under-reports until it got one. Signed-off-by: Diego Cotelo --- scripts/lib/statusline.sh | 41 ++++++++++++++-------- tests/test_statusline.sh | 71 ++++++++++++++++++++++++++++++--------- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/scripts/lib/statusline.sh b/scripts/lib/statusline.sh index cc5cd29..3a5394d 100644 --- a/scripts/lib/statusline.sh +++ b/scripts/lib/statusline.sh @@ -108,7 +108,7 @@ cp_sl_bar() { cp_sl_config() { local cfg="${1:-}" out rc=0 four=0 nl local d_layout='[["badge","model","dir","git"],["context","usage"]]' - local d_fill='▓' d_empty='░' d_width=10 d_warn=70 d_crit=90 + local d_fill='▓' d_empty='░' d_width=10 d_warn=70 d_crit=90 d_weekly=50 local d_model=cyan d_dir=yellow d_git=magenta d_branch=cyan d_label=dim nl=' ' @@ -117,9 +117,10 @@ cp_sl_config() { --argjson deflayout "$d_layout" \ --arg fill "$d_fill" --arg empty "$d_empty" \ --argjson width "$d_width" --argjson warn "$d_warn" --argjson crit "$d_crit" \ + --argjson weekly "$d_weekly" \ --arg model "$d_model" --arg dir "$d_dir" --arg git "$d_git" \ --arg branch "$d_branch" --arg label "$d_label" ' - def known: ["badge","model","dir","git","context","usage"]; + def known: ["badge","model","dir","git","context","usage","weekly"]; # Nothing below character 32 in a value that is kept. These four lines are # read by their delimiters, and an invisible character collides with them: # a tab shifts every field after it on its row -- so the directory colour @@ -146,10 +147,14 @@ cp_sl_config() { | (whole($t.warn; 1; 100; 0)) as $w | (whole($t.critical; 1; 100; 0)) as $cr | (if $w > 0 and $cr > 0 and $w < $cr then [$w, $cr] else [$warn, $crit] end) as $th + # Its own setting, not a third member of the pair above: warn and critical + # are colour thresholds validated together, and a bad pair must not drag + # the visibility threshold for the weekly bar down with it. + | (whole($s.weekly_threshold; 1; 100; $weekly)) as $wk | ([ $layout[] | join(" ") ] | join(";")), ([glyph($b.filled; $fill), glyph($b.empty; $empty), (whole($b.width; 1; 40; $width) | tostring)] | join("\t")), - ($th | map(tostring) | join("\t")), + (($th + [$wk]) | map(tostring) | join("\t")), ([pick($c.model; $model), pick($c.dir; $dir), pick($c.git; $git), pick($c.branch; $branch), pick($c.label; $label)] | join("\t")) ' 2>/dev/null)" || rc=$? @@ -161,9 +166,9 @@ cp_sl_config() { printf '%s\n' "$out" return 0 fi - printf '%s\n%s\t%s\t%s\n%s\t%s\n%s\t%s\t%s\t%s\t%s\n' \ + printf '%s\n%s\t%s\t%s\n%s\t%s\t%s\n%s\t%s\t%s\t%s\t%s\n' \ "$(printf '%s' "$d_layout" | jq -r '[.[] | join(" ")] | join(";")')" \ - "$d_fill" "$d_empty" "$d_width" "$d_warn" "$d_crit" \ + "$d_fill" "$d_empty" "$d_width" "$d_warn" "$d_crit" "$d_weekly" \ "$d_model" "$d_dir" "$d_git" "$d_branch" "$d_label" } @@ -227,7 +232,7 @@ cp_sl_config() { # back to, and one bad section can never suppress another's report. cp_sl_config_problems() { local cfg="${1:-}" key name default state colors_ok tab defaults esc_def - local d_fill='' d_empty='' d_width='' d_warn='' d_crit='' + local d_fill='' d_empty='' d_width='' d_warn='' d_crit='' d_weekly='' local d_model='' d_dir='' d_git='' d_branch='' d_label='' tab="$(printf '\t')" # The fallbacks these messages name are read off the resolver itself, @@ -236,7 +241,7 @@ cp_sl_config_problems() { { read -r _ IFS="$tab" read -r d_fill d_empty d_width - IFS="$tab" read -r d_warn d_crit + IFS="$tab" read -r d_warn d_crit d_weekly IFS="$tab" read -r d_model d_dir d_git d_branch d_label } </dev/null # Colours last, and in bash: cp_sl_code decides what a usable name is, and diff --git a/tests/test_statusline.sh b/tests/test_statusline.sh index 52f8b30..1ff7584 100755 --- a/tests/test_statusline.sh +++ b/tests/test_statusline.sh @@ -213,7 +213,7 @@ slfields() { cp_sl_config "$1" | sed -n "${2}p" | awk -F'\t' '{print NF}'; } DEFLAYOUT='badge model dir git;context usage' assert_eq "$DEFLAYOUT" "$(cfgline '{}' 1)" 'no statusline block: the default layout' assert_eq "▓ ░ 10" "$(cfgline '{}' 2)" 'no statusline block: cprof own bar, ten cells' -assert_eq "70 90" "$(cfgline '{}' 3)" 'no statusline block: the documented thresholds' +assert_eq "70 90 50" "$(cfgline '{}' 3)" 'no statusline block: the documented thresholds' assert_eq "cyan yellow magenta cyan dim" "$(cfgline '{}' 4)" 'no statusline block: the default palette' assert_eq 'badge;context usage' "$(cfgline '{"statusline":{"lines":[["badge"],["context","usage"]]}}' 1)" \ 'a configured layout is honoured, line by line' @@ -229,21 +229,48 @@ assert_eq "▓ ░ 10" "$(cfgline '{"statusline":{"bar":{"filled":"ab","empty":5 'a multi-character glyph, a non-string glyph and an out-of-range width each fall back' assert_eq "▓ ░ 1" "$(cfgline '{"statusline":{"bar":{"width":1}}}' 2)" 'a width of exactly one is accepted' assert_eq "▓ ░ 40" "$(cfgline '{"statusline":{"bar":{"width":40}}}' 2)" 'a width of exactly forty is accepted' -assert_eq "50 80" "$(cfgline '{"statusline":{"thresholds":{"warn":50,"critical":80}}}' 3)" \ +assert_eq "50 80 50" "$(cfgline '{"statusline":{"thresholds":{"warn":50,"critical":80}}}' 3)" \ 'thresholds are configurable' -assert_eq "70 90" "$(cfgline '{"statusline":{"thresholds":{"warn":80,"critical":50}}}' 3)" \ +assert_eq "70 90 50" "$(cfgline '{"statusline":{"thresholds":{"warn":80,"critical":50}}}' 3)" \ 'a warn threshold at or above critical falls back to both defaults' -assert_eq "70 90" "$(cfgline '{"statusline":{"thresholds":{"warn":50,"critical":50}}}' 3)" \ +assert_eq "70 90 50" "$(cfgline '{"statusline":{"thresholds":{"warn":50,"critical":50}}}' 3)" \ 'a warn threshold equal to critical falls back too, proving the comparison is strict' -assert_eq "70 90" "$(cfgline '{"statusline":{"thresholds":{"warn":0,"critical":101}}}' 3)" \ +assert_eq "70 90 50" "$(cfgline '{"statusline":{"thresholds":{"warn":0,"critical":101}}}' 3)" \ 'thresholds outside one to a hundred fall back' -assert_eq "1 100" "$(cfgline '{"statusline":{"thresholds":{"warn":1,"critical":100}}}' 3)" \ +assert_eq "1 100 50" "$(cfgline '{"statusline":{"thresholds":{"warn":1,"critical":100}}}' 3)" \ 'thresholds at exactly one and exactly a hundred are accepted' -assert_eq "70 90" "$(cfgline '{"statusline":{"thresholds":{"warn":50.5,"critical":80}}}' 3)" \ +assert_eq "70 90 50" "$(cfgline '{"statusline":{"thresholds":{"warn":50.5,"critical":80}}}' 3)" \ 'a fractional threshold falls back' assert_eq "red blue green bright-cyan dim" \ "$(cfgline '{"statusline":{"colors":{"model":"red","dir":"blue","git":"green","branch":"bright-cyan"}}}' 4)" \ 'colours are configurable and an unset one keeps its default' + +# --- weekly_threshold: when the 7-day bar appears ------------------------- +# It rides on the thresholds line as a third field rather than a fifth line, +# so the four-line contract below is untouched and a consumer reading fields +# one and two is unaffected. It is its own setting, not part of the +# warn/critical pair: those are colour thresholds validated together, and a +# bad pair must not drag the visibility threshold down with it. +assert_eq "70 90 40" "$(cfgline '{"statusline":{"weekly_threshold":40}}' 3)" \ + 'weekly_threshold is configurable' +assert_eq "70 90 1" "$(cfgline '{"statusline":{"weekly_threshold":1}}' 3)" \ + 'weekly_threshold at exactly one is accepted' +assert_eq "70 90 100" "$(cfgline '{"statusline":{"weekly_threshold":100}}' 3)" \ + 'weekly_threshold at exactly a hundred is accepted' +for bad in 0 101 50.5 '"40"' null true '[]' '{}'; do + assert_eq "70 90 50" "$(cfgline "{\"statusline\":{\"weekly_threshold\":$bad}}" 3)" \ + "weekly_threshold $bad falls back to fifty" +done +assert_eq "70 90 40" "$(cfgline '{"statusline":{"thresholds":{"warn":80,"critical":50},"weekly_threshold":40}}' 3)" \ + 'a rejected warn/critical pair leaves weekly_threshold alone' +assert_eq "50 80 50" "$(cfgline '{"statusline":{"thresholds":{"warn":50,"critical":80},"weekly_threshold":0}}' 3)" \ + 'and a rejected weekly_threshold leaves the pair alone' + +# weekly joins the segments a layout may name +assert_eq 'badge weekly' "$(cfgline '{"statusline":{"lines":[["badge","weekly"]]}}' 1)" \ + 'weekly is a known segment' +assert_eq '' "$(cp_sl_config_problems '{"statusline":{"lines":[["weekly"]],"weekly_threshold":50}}')" \ + 'a layout naming weekly, with a valid threshold, is silent' assert_eq "cyan yellow magenta cyan dim" "$(cfgline '{"statusline":{"colors":{"model":123}}}' 4)" \ 'a non-string colour value falls back to its default' assert_eq '4' "$(cp_sl_config '{}' | wc -l | tr -d ' ')" 'always exactly four lines' @@ -294,6 +321,11 @@ SL_SHAPE=( '{"statusline":{"bar":[]}}' '{"statusline":{"bar":true}}' '{"statusline":{"bar":false}}' + '{"statusline":{"weekly_threshold":0}}' + '{"statusline":{"weekly_threshold":101}}' + '{"statusline":{"weekly_threshold":"40"}}' + '{"statusline":{"weekly_threshold":50.5}}' + '{"statusline":{"weekly_threshold":40}}' '{"statusline":{"thresholds":"x"}}' '{"statusline":{"thresholds":5}}' '{"statusline":{"thresholds":[]}}' @@ -493,7 +525,7 @@ assert_eq '' "$(cp_sl_config_problems '{}')" 'no statusline block, nothing to re assert_eq '' "$(cp_sl_config_problems '{"statusline":{"bar":{"filled":"█"}}}')" 'a valid block, nothing to report' assert_eq 'statusline.lines: not a list of segment lists; using the default layout' \ "$(cp_sl_config_problems '{"statusline":{"lines":"nonsense"}}')" 'a malformed layout is reported' -assert_eq 'statusline.lines: unknown segment "nonsense" (known: badge model dir git context usage)' \ +assert_eq 'statusline.lines: unknown segment "nonsense" (known: badge model dir git context usage weekly)' \ "$(cp_sl_config_problems '{"statusline":{"lines":[["badge","nonsense"]]}}')" 'an unknown segment is named' assert_eq 'statusline.bar.filled: must be exactly one character; using ▓' \ "$(cp_sl_config_problems '{"statusline":{"bar":{"filled":"ab"}}}')" 'a bad glyph is reported with the fallback' @@ -548,7 +580,7 @@ sl_ctl() { LC_ALL=C tr -dc '\001-\011\013-\037' | od -An -c; } forgekey="$(jq -cn '{statusline:{("x"+([10]|implode)+"statusline.bar.width: must be a whole number from 1 to 40; using 40"):1}}')" assert_eq 1 "$(cp_sl_config_problems "$forgekey" | wc -l | tr -d ' ')" \ 'a newline in a key name cannot forge a second doctor line' -assert_eq 'statusline: unknown key "x\nstatusline.bar.width: must be a whole number from 1 to 40; using 40" (known: lines bar thresholds colors)' \ +assert_eq 'statusline: unknown key "x\nstatusline.bar.width: must be a whole number from 1 to 40; using 40" (known: lines bar thresholds colors weekly_threshold)' \ "$(cp_sl_config_problems "$forgekey")" \ 'the forged text comes back escaped inside the name it was written as' # All four levels that name a key, in one config: four reports, four lines. @@ -603,10 +635,10 @@ assert_eq 'statusline.bar.width: must be a whole number from 1 to 40; using 10' # agents` teaches a reader that doctor catches names it does not recognise, # and then it did not catch `wdith`. A misspelled key is the most common real # misconfiguration there is. -assert_eq 'statusline: unknown key "line" (known: lines bar thresholds colors)' \ +assert_eq 'statusline: unknown key "line" (known: lines bar thresholds colors weekly_threshold)' \ "$(cp_sl_config_problems '{"statusline":{"line":[["badge"]]}}')" \ 'an unknown key directly under statusline is named' -assert_eq 'statusline: unknown key "threshold" (known: lines bar thresholds colors)' \ +assert_eq 'statusline: unknown key "threshold" (known: lines bar thresholds colors weekly_threshold)' \ "$(cp_sl_config_problems '{"statusline":{"threshold":{"warn":50,"critical":80}}}')" \ 'a section name that is nearly right is named at the level it was written' assert_eq 'statusline.bar: unknown key "wdith" (known: filled empty width)' \ @@ -766,7 +798,7 @@ assert_eq '' "$(cp_sl_config_problems '{"statusline":{"colors":{"model":null}}}' # cp_t_sl_fallbacks -> one line per key the resolver did not keep cp_t_sl_fallbacks() { local cfg="$1" resolved tab key rv state - local rfill='' rempty='' rwidth='' rwarn='' rcrit='' + local rfill='' rempty='' rwidth='' rwarn='' rcrit='' rweekly='' local rmodel='' rdir='' rgit='' rbranch='' rlabel='' tab="$(printf '\t')" # The block and the sections: structural, so no resolved value is needed. @@ -783,14 +815,14 @@ cp_t_sl_fallbacks() { { read -r _ IFS="$tab" read -r rfill rempty rwidth - IFS="$tab" read -r rwarn rcrit + IFS="$tab" read -r rwarn rcrit rweekly IFS="$tab" read -r rmodel rdir rgit rbranch rlabel } </dev/null # The colours have a second resolution stage that cp_sl_code owns: a name # pick() keeps but the palette does not know is rendered plain, which is a @@ -869,6 +902,12 @@ long31="$(printf 'x%.0s' $(seq 1 31))" cfg31="$(printf '{"statusline":{"colors":{"label":"%s"}}}' "$long31")" SL_RULE_CFG=( '{}' + '{"statusline":{"weekly_threshold":0}}' + '{"statusline":{"weekly_threshold":101}}' + '{"statusline":{"weekly_threshold":"40"}}' + '{"statusline":{"weekly_threshold":50.5}}' + '{"statusline":{"weekly_threshold":40}}' + '{"statusline":{"thresholds":{"warn":80,"critical":50},"weekly_threshold":40}}' '{"statusline":null}' '{"statusline":false}' '{"statusline":""}' @@ -945,7 +984,7 @@ assert_eq "$LINES_BAD" "$(cp_sl_config_problems '{"statusline":{"lines":[]}}')" assert_eq "$LINES_BAD" "$(cp_sl_config_problems '{"statusline":{"lines":[[]]}}')" \ 'a lines array of empty lines is reported' assert_eq "$LINES_BAD -statusline.lines: unknown segment \"nonsense\" (known: badge model dir git context usage)" \ +statusline.lines: unknown segment \"nonsense\" (known: badge model dir git context usage weekly)" \ "$(cp_sl_config_problems '{"statusline":{"lines":[["nonsense"]]}}')" \ 'a layout whose only segment is unknown is reported both ways' assert_eq '' "$(cp_sl_config_problems '{"statusline":{"lines":[["badge"],"junk"]}}')" \ From b7c5c68043c0ed71cb02675138b1d6c654bd1e0f Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 16:07:41 -0300 Subject: [PATCH 3/6] feat(statusline): a weekly usage bar, shown once the week is worth watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #34. ⚑ work │ [Opus 5 (1M context)] │ cprof git:(main*) Context ▓▓▓▓░░░░░░ 39% │ Usage ▓▓░░░░░░░░ 18% (resets in 4h 2m) Usage Weekly ▓▓▓▓▓▓░░░░ 64% (resets in 3d 13h) The 5-hour window was on the line and the 7-day one was not, though the weekly cap is the one that ends a working day without warning. `weekly` renders at or above `statusline.weekly_threshold`, default 50, and renders nothing below it, so the line costs no space early in the week. It is the first segment whose presence depends on data, and that needed less new machinery than expected: cp_sl_assemble already drops a configured line whose segments all came back empty, so a line holding only this bar disappears with it. What is new is a renderer that deliberately produces nothing. The figure is read from the cache `cprof list` fills and never fetched: a Claude Code payload carries the 5-hour window and the context but never the week, and the statusline must not add latency. A profile whose usage has never been fetched therefore shows no weekly bar, which is the honest outcome. Five mutations confirm the assertions bite, including the two that mattered most because they passed before any renderer existed: never checking the threshold trips the three hiding assertions, and `-ge` weakened to `-gt` trips the boundary. Reading five_hour instead of seven_day trips five. Signed-off-by: Diego Cotelo --- README.md | 2 +- docs/statusline.md | 31 +++++++++++++++++ scripts/lib/statusline.sh | 41 ++++++++++++++++++++-- tests/test_statusline.sh | 72 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f137e52..b3f59fd 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Platform](https://img.shields.io/badge/Platform-macOS-1a1b27?style=for-the-badge&color=7aa2f7)](#install) [![Bash](https://img.shields.io/badge/Bash-3.2%2B-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) [![Requires](https://img.shields.io/badge/Requires-jq-1a1b27?style=for-the-badge&color=7aa2f7)](#install) -[![Tests](https://img.shields.io/badge/Tests-1130%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) +[![Tests](https://img.shields.io/badge/Tests-1170%20assertions-1a1b27?style=for-the-badge&color=414868)](CONTRIBUTING.md#development) diff --git a/docs/statusline.md b/docs/statusline.md index a73f47a..126d340 100644 --- a/docs/statusline.md +++ b/docs/statusline.md @@ -56,6 +56,7 @@ defaults behind the render above: "lines": [["badge", "model", "dir", "git"], ["context", "usage"]], "bar": {"filled": "▓", "empty": "░", "width": 10}, "thresholds": {"warn": 70, "critical": 90}, + "weekly_threshold": 50, "colors": {"model": "cyan", "dir": "yellow", "git": "magenta", "branch": "cyan", "label": "dim"} } } @@ -81,6 +82,7 @@ rely on them: | `statusline.bar.filled` / `.empty` | exactly one character, and not an invisible one — a tab is one character and is rejected, with a message of its own | `▓` / `░` | | `statusline.bar.width` | a whole number from 1 to 40 | `10` | | `statusline.thresholds.warn` **and** `.critical` | both, together: whole numbers from 1 to 100 with `warn` below `critical` | `70` **and** `90` — setting only one, or an out-of-order pair, reverts both | +| `statusline.weekly_threshold` | a whole number from 1 to 100 | `50` — its own setting, so a rejected `thresholds` pair does not change it, and a rejected value here does not change the pair | | `statusline.colors.*` — wrong shape | a string, 1-19 characters, with no invisible character in it — a trailing tab is rejected, with a message of its own | its own default (`cyan` for `model`/`branch`, `yellow` for `dir`, `magenta` for `git`, `dim` for `label`) | | `statusline.colors.*` — right shape, unknown name | any name from [the palette](#colours), plus `dim` | *(not a fallback — see below)* | @@ -168,6 +170,35 @@ Usage ██···· 30% (resets in 2h 19m) | `git` | `git:(main*)`, the star meaning uncommitted changes | two git calls | | `context` | `Context ▓▓▓▓░░░░░░ 37%` | the payload | | `usage` | `Usage ▓▓▓░░░░░░░ 30% (resets in 2h 19m)` | the payload, else the profile's cached usage | +| `weekly` | `Usage Weekly ▓▓▓▓▓▓░░░░ 64% (resets in 3d 13h)`, and nothing at all below the threshold | the profile's cached usage — a payload never carries the week | + +### The weekly bar appears only when it matters + +`weekly` is the one segment whose presence depends on data. It renders when the +7-day window is at or above `statusline.weekly_threshold` (default 50) and +stays invisible below it, so the line costs nothing on a Monday and warns you +before the weekly cap ends a working day: + +```console +⚑ work │ [Opus 5 (1M context)] │ cprof git:(main*) +Context ▓▓▓▓░░░░░░ 39% │ Usage ▓▓░░░░░░░░ 18% (resets in 4h 2m) +Usage Weekly ▓▓▓▓▓▓░░░░ 64% (resets in 3d 13h) +``` + +Give it a line of its own and that line disappears with it — a configured line +whose segments all render nothing is dropped rather than printed empty. Put it +beside other segments and only the bar goes; the rest of the line stays. + +Two consequences of where the figure comes from. It is read from the same cache +`cprof list` fills and never fetched, because a Claude Code payload carries the +5-hour window and the context but never the week, and because the statusline +must not add latency — so a profile whose usage has never been fetched shows no +weekly bar. And a reset days away is rendered as `3d 13h` rather than `85h +40m`, which is the same instant told legibly. + +It is its own threshold, not `thresholds.warn`. Those two are colour +thresholds, validated as a pair; retuning them should not silently change when +a line appears. ## Colours diff --git a/scripts/lib/statusline.sh b/scripts/lib/statusline.sh index 3a5394d..63c03d6 100644 --- a/scripts/lib/statusline.sh +++ b/scripts/lib/statusline.sh @@ -555,7 +555,8 @@ cp_sl_assemble() { # resolved configuration says. # # Each segment renders into its own shell variable -- CP_SL_badge, -# CP_SL_model, CP_SL_dir, CP_SL_git, CP_SL_context, CP_SL_usage -- holding +# CP_SL_model, CP_SL_dir, CP_SL_git, CP_SL_context, CP_SL_usage, +# CP_SL_weekly -- holding # only that segment's own text, with no separator. cp_sl_assemble then walks # the configured layout and joins what is there. A segment the layout does # not name is never rendered at all, so an unconfigured git segment runs no @@ -577,7 +578,8 @@ cp_cmd_statusline() { # layout's own segment names, which is why nothing in this function appears # to use them. local CP_SL_badge='' CP_SL_model='' CP_SL_dir='' CP_SL_git='' - local CP_SL_context='' CP_SL_usage='' + local CP_SL_context='' CP_SL_usage='' CP_SL_weekly='' + local th_weekly='' w_data='' w_pct='' w_at='' w_reset='' w_bar='' w_code='' while [ "$#" -gt 0 ]; do case "$1" in @@ -601,6 +603,7 @@ cp_cmd_statusline() { thresh_cfg="$(printf '%s' "$config" | sed -n '3p')" th_warn="$(printf '%s' "$thresh_cfg" | cut -f1)" th_crit="$(printf '%s' "$thresh_cfg" | cut -f2)" + th_weekly="$(printf '%s' "$thresh_cfg" | cut -f3)" colors_cfg="$(printf '%s' "$config" | sed -n '4p')" col_model="$(cp_sl_code "$(printf '%s' "$colors_cfg" | cut -f1)" 2>/dev/null)" col_dir="$(cp_sl_code "$(printf '%s' "$colors_cfg" | cut -f2)" 2>/dev/null)" @@ -729,6 +732,40 @@ cp_cmd_statusline() { fi fi + # The 7-day window, shown only once it is worth watching. Cache-only by + # nature: a Claude Code payload carries the 5-hour window and the context, + # never the week, so this reads the cache `cprof list` fills and never + # fetches -- the statusline must not add latency. Below the threshold the + # variable stays empty and cp_sl_assemble drops the line, which is how a + # quiet week costs no screen space. + if cp_sl_wants "$layout" weekly; then + w_data="$(cp_usage_read_cached_only "$name" 2>/dev/null)" + w_pct="$(cp_usage_pct "$w_data" seven_day 2>/dev/null)" + case "$w_pct" in ''|*[!0-9]*) w_pct='' ;; esac + case "$th_weekly" in ''|*[!0-9]*) th_weekly=50 ;; esac + if [ -n "$w_pct" ] && [ "$w_pct" -ge "$th_weekly" ]; then + w_at="$(cp_usage_resets_at "$w_data" seven_day 2>/dev/null)" + case "$w_at" in + '') ;; + *[!0-9]*) w_at="$(cp_time_epoch "$w_at")" || w_at='' ;; + esac + if [ -n "$w_at" ]; then + w_reset="$(cp_usage_reset_in "$w_at")" || w_reset='' + fi + w_bar="$(cp_usage_bar "$w_pct" "$b_fill" "$b_empty" "$b_width")" + w_code="$(cp_color_code "$(cp_usage_severity_colour "$w_pct" "$th_warn" "$th_crit" 2>/dev/null)" 2>/dev/null)" + if [ "$colour_on" -eq 1 ]; then + CP_SL_weekly="$(printf '%sUsage Weekly%s %s \033[%sm%s%%\033[0m' \ + "$label_open" "$label_close" "$(cp_sl_bar "$w_bar" "$w_code" "$b_empty")" "$w_code" "$w_pct")" + [ -n "$w_reset" ] && CP_SL_weekly="$CP_SL_weekly$(printf ' %s(resets in %s)%s' "$label_open" "$w_reset" "$label_close")" + else + # shellcheck disable=SC2034 # read by cp_sl_assemble via eval + CP_SL_weekly="Usage Weekly $w_bar $w_pct%" + [ -n "$w_reset" ] && CP_SL_weekly="$CP_SL_weekly (resets in $w_reset)" + fi + fi + fi + cp_sl_assemble "$layout" "$sep" return 0 } diff --git a/tests/test_statusline.sh b/tests/test_statusline.sh index 1ff7584..a9c3107 100755 --- a/tests/test_statusline.sh +++ b/tests/test_statusline.sh @@ -148,6 +148,78 @@ out="$(printf '{"cwd":"%s","context_window":{"used_percentage":5}}' "$R" | NO_CO assert_eq "⚑ work │ repo git:($(gitq rev-parse --short HEAD)) Context $(cp_usage_bar 5) 5% │ Usage $(cp_usage_bar 73) 73%" "$out" \ 'no model in the payload: that field is skipped, the rest still renders' +# --- the weekly bar: shown only once the 7-day window is worth watching ---- +# The 7-day figure is cache-only by nature: a Claude Code payload carries the +# 5-hour window and the context, never the week, so this segment reads the +# same cache `cprof list` fills and never fetches. +wk_at="$(date -u -r $(( $(date +%s) + 3*86400 + 13*3600 + 30*60 )) '+%Y-%m-%dT%H:%M:%SZ')" +wk_cache() { # $1 = seven_day utilization + printf '{"fetched_at":1,"five_hour":{"utilization":20},"seven_day":{"utilization":%s,"resets_at":"%s"},"limits":[]}\n' \ + "$1" "$wk_at" > "$CP_T_TMP/state/usage/work.json" +} +wk_cfg() { # $1 = the statusline block, or empty for the default + cp_t_write_config </dev/null)" \ + 'past the threshold: the weekly bar, with days and hours to reset' + +wk_cache 50 +assert_eq "⚑ work +Usage Weekly $(cp_usage_bar 50) 50%" \ + "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null | sed 's/ (resets in .*)$//')" \ + 'exactly at the threshold it is shown' + +wk_cache 49 +assert_eq '⚑ work' "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null)" \ + 'below the threshold the segment renders nothing and its line vanishes' + +wk_cfg '"weekly_threshold":70'; wk_cache 64 +assert_eq '⚑ work' "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null)" \ + 'a higher configured threshold hides a percentage the default would show' +wk_cache 70 +assert_eq '1' "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null | grep -c 'Usage Weekly')" \ + 'and shows it once the window reaches that threshold' + +wk_cfg '"weekly_threshold":1'; wk_cache 0 +assert_eq '⚑ work' "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null)" \ + 'a zero percentage is below every valid threshold' + +# A cache with no seven_day at all, and no cache at all: nothing to show, +# nothing to fail. +wk_cfg '' +printf '{"fetched_at":1,"five_hour":{"utilization":20},"limits":[]}\n' > "$CP_T_TMP/state/usage/work.json" +assert_eq '⚑ work' "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null)" \ + 'a cache without a 7-day window renders no weekly bar' +rm -f "$CP_T_TMP/state/usage/work.json" +assert_eq '⚑ work' "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null)" \ + 'no cache at all renders no weekly bar' +assert_ok bash -c "NO_COLOR=1 '$CLI' statusline >/dev/null 2>&1" + +# The configured glyphs and width apply to this bar like any other. +wk_cfg '"bar":{"filled":"#","empty":".","width":6}'; wk_cache 64 +assert_eq "Usage Weekly $(cp_usage_bar 64 '#' '.' 6) 64%" \ + "$(NO_COLOR=1 "$CLI" statusline 2>/dev/null | sed -n '2p' | sed 's/ (resets in .*)$//')" \ + 'the weekly bar honours the configured glyphs and width' + +# Restore the fixture the rest of the file expects. +rm -f "$CP_T_TMP/state/usage/work.json" +cp_t_write_config < Date: Fri, 18 Sep 2026 19:08:24 +0000 Subject: [PATCH 4/6] chore(release): 0.15.0 --- .claude-plugin/marketplace.json | 4 ++-- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 8 ++++++++ scripts/cprof | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 275509b..13b00c1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,13 +7,13 @@ }, "metadata": { "description": "Per-repository Claude account switching: keep personal and work subscriptions separate.", - "version": "0.14.0" + "version": "0.15.0" }, "plugins": [ { "name": "cprof", "source": "./", - "version": "0.14.0", + "version": "0.15.0", "description": "Select which Claude account a session uses, by default profile, per-repo pin, or directory rule. Each profile is its own config directory, so a work subscription and a personal one never share credentials.", "category": "workflow", "keywords": [ diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 626e927..f0d636a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cprof", - "version": "0.14.0", + "version": "0.15.0", "description": "Select which Claude account a session uses, by default profile, per-repo pin, or directory rule.", "author": { "name": "Diego Cotelo", diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ad77e..95634a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ release workflow reads its notes from the section matching the tag. ## [Unreleased] +## [0.15.0] + +### Added + +- a weekly usage bar, shown once the week is worth watching +- weekly_threshold, and weekly as a known segment +- render a reset days out as days and hours + ## [0.14.0] ### Added - `cprof doctor` reports two install problems that used to be invisible. It diff --git a/scripts/cprof b/scripts/cprof index 478068f..0879ed3 100755 --- a/scripts/cprof +++ b/scripts/cprof @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -u -CP_VERSION='0.14.0' +CP_VERSION='0.15.0' # The plugin's own marketplace and scoped name, consumed by cp_cmd_update. # 'update' always targets this specific listing — it is not something a user # configures, so it lives next to CP_VERSION rather than in ~/.cprof.json. From 13788349d5e284c5ad6c150a471878c773e5b260 Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 18:13:43 -0300 Subject: [PATCH 5/6] docs(changelog): rewrite the 0.15.0 notes as prose The bump generates notes from commit subjects, and CONTRIBUTING says to rewrite them before merging for exactly this reason: three bullets of commit log for what is one feature plus its plumbing, and the release workflow publishes this section verbatim as the release notes. One entry for the weekly segment, saying what it does and when it appears, and one for the reset formatting, saying what changed and what did not. Signed-off-by: Diego Cotelo --- CHANGELOG.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95634a1..6fec482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,22 @@ release workflow reads its notes from the section matching the tag. ## [Unreleased] ## [0.15.0] - ### Added +- A `weekly` statusline segment: the 7-day usage window as a bar, with the time + until it resets. It renders only once the window is at or above + `statusline.weekly_threshold` — a whole number from 1 to 100, 50 by default — + and renders nothing below it, so the line costs no space early in the week and + appears before the weekly cap ends a working day. Give it a line of its own in + `statusline.lines` and that line disappears with it. The figure comes from the + cache `cprof list` fills and is never fetched, because a Claude Code payload + carries the 5-hour window and the context but never the week, and because the + statusline must not add latency — so a profile whose usage has never been + fetched shows no weekly bar. -- a weekly usage bar, shown once the week is worth watching -- weekly_threshold, and weekly as a known segment -- render a reset days out as days and hours +### Changed +- A reset a day or more away is reported as days and hours, `3d 13h`, rather + than as hours and minutes, `85h 40m` — the same instant, told legibly. The + 5-hour window cannot reach a day, so what it shows is unchanged. ## [0.14.0] ### Added From 8d5536a70590ce52913acff2f6bebd06d80b3cb7 Mon Sep 17 00:00:00 2001 From: Diego Cotelo Date: Fri, 18 Sep 2026 19:16:26 -0300 Subject: [PATCH 6/6] docs(changelog): blank lines around the 0.15.0 headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MD022. The release script already writes them — release-version.sh emits '\n### %s\n\n' — and the hand-rewrite of these notes dropped them while matching the older sections, which were hand-edited the same way. Restored for 0.15.0; the historical sections are left as they are. Signed-off-by: Diego Cotelo --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fec482..214b16b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ release workflow reads its notes from the section matching the tag. ## [Unreleased] ## [0.15.0] + ### Added + - A `weekly` statusline segment: the 7-day usage window as a bar, with the time until it resets. It renders only once the window is at or above `statusline.weekly_threshold` — a whole number from 1 to 100, 50 by default — @@ -19,6 +21,7 @@ release workflow reads its notes from the section matching the tag. fetched shows no weekly bar. ### Changed + - A reset a day or more away is reported as days and hours, `3d 13h`, rather than as hours and minutes, `85h 40m` — the same instant, told legibly. The 5-hour window cannot reach a day, so what it shows is unchanged.