From 545ca95068b997624a8968d5a7239af3f6d50f31 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Wed, 2 Sep 2026 19:20:56 +0100 Subject: [PATCH] Read thinking state from the payload and show the effort level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indicator read alwaysThinkingEnabled from settings.json, which Option+T never writes - it toggles thinking for the session only. The key was also absent here, so `// false` pinned the segment to the hollow "off" diamond while thinking was actually on: an inverted indicator, not just a stale one. Claude Code pipes the live state in as .thinking.enabled, alongside .effort.level. Read both, in one jq pass since the line re-renders on every redraw. Absent thinking means enabled, matching the renderer's own `thinking:{enabled: lt !== false}`. The effort level now replaces the static "thinking" label, so the segment says something that changes: "◆ high" rather than a word that was there either way. Unrecognised levels fall back to the old label, since the wrap branch sizes line one from this string. Test widths are usable widths, not raw terminal widths: the tiers are computed after padding is subtracted, so a raw width would change tier whenever statusLine.padding did. The usage cache is seeded for the same reason width_test.sh seeds it - an unseeded cache makes the first render fetch live usage over the network. Co-Authored-By: Claude Opus 5 (1M context) --- claude/statusline.isaacaudet.sh | 48 ++++++---- claude/tests/run.sh | 1 + .../statusline.isaacaudet.thinking_test.sh | 91 +++++++++++++++++++ 3 files changed, 124 insertions(+), 16 deletions(-) create mode 100755 claude/tests/statusline.isaacaudet.thinking_test.sh diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index 22927e8..928cb56 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -38,16 +38,12 @@ CACHE_DIR="${STATUSLINE_CACHE_DIR:-/tmp/claude}" # # To override detection entirely, set TERM_WIDTH in settings.json: # "command": "TERM_WIDTH=160 ~/.claude/statusline.sh" -# Read both settings this script needs in one jq pass. # Only the user-level file: Claude Code also merges project .claude/settings.json, # .claude/settings.local.json and managed policy, so a statusLine.padding set at # project level would be applied by the renderer but missed here. sl_padding=0 -sl_thinking=false if [ -f "$HOME/.claude/settings.json" ]; then - { IFS= read -r sl_padding; IFS= read -r sl_thinking; } </dev/null) -EOF + sl_padding=$(jq -r '.statusLine.padding // 0' "$HOME/.claude/settings.json" 2>/dev/null) [ "${sl_padding:-0}" -ge 0 ] 2>/dev/null || sl_padding=0 fi @@ -321,8 +317,27 @@ used_tokens=$(format_tokens $current) total_tokens=$(format_tokens $size) pct_used=$(( size > 0 ? current * 100 / size : 0 )) -thinking_on=false -[ "$sl_thinking" = "true" ] && thinking_on=true +# Thinking and effort come from the payload, not from alwaysThinkingEnabled in +# settings.json: Option+T toggles thinking for the session only and never +# writes the file, so a settings read reports the wrong state for the rest of +# the session. Absent means enabled, mirroring Claude Code's own +# `thinking:{enabled: lt !== false}`. +# Both in one jq pass: the status line re-renders on every redraw, so a +# process per field is the dominant cost (see the rate-limit section below). +# `== false` rather than `// false`, which jq's falsy `//` would flip to true. +{ IFS= read -r sl_thinking; IFS= read -r effort; } < used/total pct% │ ◇ thinking │ $cost + # model │ ⎇ branch ✔ ↑1 │ used/total pct% │ ◇ high │ $cost # and the branch gets whatever the rest of it leaves. Everything after # the branch is appended below, so its width is added up here rather # than assumed: a flat 56 columns was three short of a four-digit cost, @@ -429,7 +444,7 @@ if $SHOW_GIT && [ -n "$cwd" ]; then [ "${g_behind:-0}" -gt 0 ] && tail_len=$(( tail_len + 2 + ${#g_behind} )) $SHOW_TOKENS && tail_len=$(( tail_len + 3 + bar_w + 1 \ + ${#used_tokens} + 1 + ${#total_tokens} + 1 + ${#pct_used} + 1 )) - $SHOW_THINKING && tail_len=$(( tail_len + 3 + 10 )) + $SHOW_THINKING && tail_len=$(( tail_len + 3 + 2 + ${#thinking_label} )) [ -n "$cost_fmt" ] && tail_len=$(( tail_len + 3 + 1 + ${#cost_fmt} )) local_max=$(( USABLE_WIDTH - $(vis_len "$out") - tail_len )) [ "$local_max" -lt 8 ] && local_max=8 @@ -459,18 +474,19 @@ if $SHOW_TOKENS; then out+="${sep}${token_bar} ${orange}${used_tokens}${dim}/${reset}${white}${total_tokens}${reset} ${dim}${pct_used}%${reset}" fi -# Thinking: -# full/wide → "◆ thinking" / "◇ thinking" (label) -# split → "◆" / "◇" (symbol only, saves ~9 chars) +# Thinking and effort. The diamond is thinking state, the label is the effort +# level ("thinking" only when the payload reports no level): +# full/wide → "◆ high" / "◇ high" (label) +# split → "◆" / "◇" (symbol only, saves the label's columns) # narrow → hidden if $SHOW_THINKING && [ "$width_tier" != "narrow" ]; then out+="${sep}" if $thinking_on; then if [ "$width_tier" = "split" ]; then out+="${amber}◆${reset}" - else out+="${amber}◆ thinking${reset}"; fi + else out+="${amber}◆ ${thinking_label}${reset}"; fi else if [ "$width_tier" = "split" ]; then out+="${dim}◇${reset}" - else out+="${dim}◇ thinking${reset}"; fi + else out+="${dim}◇ ${thinking_label}${reset}"; fi fi fi diff --git a/claude/tests/run.sh b/claude/tests/run.sh index 75bef2d..a195446 100755 --- a/claude/tests/run.sh +++ b/claude/tests/run.sh @@ -11,6 +11,7 @@ cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 status=0 for t in statusline.isaacaudet.payload_test.sh \ + statusline.isaacaudet.thinking_test.sh \ statusline.isaacaudet.width_test.sh \ statusline.isaacaudet.cache_test.sh; do echo "== $t" diff --git a/claude/tests/statusline.isaacaudet.thinking_test.sh b/claude/tests/statusline.isaacaudet.thinking_test.sh new file mode 100755 index 0000000..203edf7 --- /dev/null +++ b/claude/tests/statusline.isaacaudet.thinking_test.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Tests for the thinking/effort segment of claude/statusline.isaacaudet.sh. +# +# The state comes from the payload Claude Code pipes in (.thinking.enabled and +# .effort.level), not from alwaysThinkingEnabled in settings.json: the Option+T +# toggle is session-only, so a settings read cannot track it. Claude Code's own +# payload builder is `thinking:{enabled: lt !== false}`, so an absent field +# means enabled -- which is why the missing-field cases below expect the filled +# diamond rather than the hollow one. +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/statusline.isaacaudet.sh" +# Widths below are USABLE widths, converted to a TERM_WIDTH here. The script +# tiers on USABLE_WIDTH (columns minus padding both sides minus a margin), so a +# raw TERM_WIDTH would land in a different tier the moment statusLine.padding +# changed -- same convention as width_test.sh. +PAD=$(jq -r '.statusLine.padding // 0' "$HOME/.claude/settings.json" 2>/dev/null || echo 0) +OVERHEAD=$(( 2 * PAD + 1 )) +export STATUSLINE_CACHE_DIR="$(mktemp -d)" +CACHE="$STATUSLINE_CACHE_DIR/statusline-usage-cache.json" +REPO="$(mktemp -d)" + +cleanup() { rm -rf "$STATUSLINE_CACHE_DIR" "$REPO"; } +trap cleanup EXIT + +# Seed the usage cache. Without a fixture the first render treats the rate +# limits as live and curls /api/oauth/usage with the real OAuth token, which +# makes the test non-hermetic and sensitive to account state. +cat > "$CACHE" <<'JSON' +{"five_hour":{"utilization":5.0,"resets_at":"2026-08-27T15:40:00+00:00"}, + "seven_day":{"utilization":7.0,"resets_at":"2026-08-28T02:00:00+00:00"}, + "extra_usage":{"is_enabled":false}, + "limits":[]} +JSON + +git -C "$REPO" init -q 2>/dev/null +git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init 2>/dev/null + +pass=0; fail=0 +ok() { echo " PASS $1"; pass=$((pass+1)); } +bad() { echo " FAIL $1"; [ -n "$2" ] && printf '%s\n' "$2" | sed 's/^/ /'; fail=$((fail+1)); } + +# Build the payload in python so a thinking/effort block can be omitted +# entirely -- absent and false are different states here. +stdin_json() { + CWD="$REPO" THINKING="$1" EFFORT="$2" python3 -c ' +import json, os +p = {"model": {"display_name": "Opus 5"}, + "cwd": os.environ["CWD"], + "cost": {"total_cost_usd": 0.5}, + "context_window": {"context_window_size": 200000, + "current_usage": {"input_tokens": 1000}}} +t = os.environ["THINKING"] +if t: p["thinking"] = {"enabled": t == "true"} +e = os.environ["EFFORT"] +if e: p["effort"] = {"level": e} +print(json.dumps(p))' +} + +# $1 = thinking ("true"/"false"/"" for absent), $2 = effort level or "", +# $3 = USABLE width. ANSI stripped. +render() { + stdin_json "$1" "$2" \ + | TERM_WIDTH="$(( ${3:-200} + OVERHEAD ))" bash "$SCRIPT" 2>&1 | sed $'s/\033\[[0-9;]*m//g' +} + +has() { case "$(render "$1" "$2" "$4")" in *"$3"*) ok "$5";; *) bad "$5" "want '$3' in: $(render "$1" "$2" "$4")";; esac; } +lacks() { case "$(render "$1" "$2" "$4")" in *"$3"*) bad "$5" "unwanted '$3' in: $(render "$1" "$2" "$4")";; *) ok "$5";; esac; } + +echo "Thinking state from the payload:" +has true high "◆ high" 200 "enabled + high effort renders a filled diamond and the level" +has false low "◇ low" 200 "disabled renders a hollow diamond" +has "" high "◆ high" 200 "absent thinking field means enabled, matching Claude Code's default" + +echo +echo "Effort level as the label:" +has true medium "◆ medium" 200 "medium is spelled out" +has true xhigh "◆ xhigh" 200 "xhigh is spelled out" +has true max "◆ max" 200 "max is spelled out" +lacks true high "thinking" 200 "the static word 'thinking' is gone when an effort level is known" + +echo +echo "Fallbacks and tiers:" +has true "" "◆ thinking" 200 "absent effort falls back to the old label" +has true garbage "◆ thinking" 200 "an unrecognised level falls back rather than widening the line" +has true high "◆ high" 120 "the label survives the wide tier" +# The split tier (76-99) is unreachable while WRAP_NARROW=true -- that band is +# overridden to wide and wrapped -- so only narrow is asserted here. +lacks true high "◆" 60 "narrow tier hides the segment" + +echo +echo " $pass passed, $fail failed" +[ "$fail" -eq 0 ]