Skip to content

feat: add model-aware status-line badge (web-jam-tools#688) - #691

Open
JoshuaVSherman wants to merge 3 commits into
devfrom
claude/688-model-aware-statusline-badge
Open

feat: add model-aware status-line badge (web-jam-tools#688)#691
JoshuaVSherman wants to merge 3 commits into
devfrom
claude/688-model-aware-statusline-badge

Conversation

@JoshuaVSherman

Copy link
Copy Markdown
Contributor

Summary

  • Add scripts/statusline.sh, a new wrapper that reads the Claude Code status-line JSON payload from stdin (a different payload from the hook payload — it carries .model.display_name, which hooks never see), extracts the model tier, and prints a color-coded [Opus] / [Sonnet] / [Haiku] badge in front of the existing status line.
  • The badge match is on the family word in display_name, matched case-insensitively, so a version bump (Opus 5Opus 6) keeps matching without a script change. An unrecognized display_name, a missing .model key, and malformed JSON on stdin all still produce usable, non-empty, exit-0 output rather than an error or a blank line.
  • The captured payload is piped through unmodified to the downstream status-line command (npx -y ccusage statusline by default, overridable via STATUSLINE_DOWNSTREAM_CMD for testing) — the badge is a prefix, never a replacement.
  • Extend scripts/merge-hooks-into-settings.ts with a new --status-line section that idempotently merges a single statusLine: { type: "command", command: "<path>" } value, following the same pattern as the existing --stop / --pre-tool-use / --post-tool-use / --deny / --ask sections, including --check drift-detection behavior.
  • Wire the new argument into scripts/install-hooks.sh on the $SETTINGS_PATH invocations only (both the --check and the write-mode call) — the two $AGY_HOOKS_PATH invocations are untouched, since agy has no status-line surface for this to install into.
  • Add test/statusline_script.test.ts, covering all three tiers (with distinct ANSI codes), the unrecognized/malformed-JSON/missing-.model-key fallback cases, and that the original payload reaches the downstream command unmodified and after the badge.
  • Extend test/install_hooks_merge.test.ts with --status-line merge/no-op/update/--check coverage, and teach test/install_hooks_script.test.ts's synthetic worktree fixture to copy scripts/statusline.sh, now a dependency of install-hooks.sh.
  • Document the script in docs/scripts.md.

This closes the gap where an Opus session and a Sonnet session look identical in the terminal — the only prior signal was remembering what a session was launched with, which is exactly the condition that lets Opus tokens get spent on work that belonged on a cheaper tier.

Not closing the issue on merge. Per the newly-merged web-jam-tools#689 "A PR for a hook issue must never close it on merge — the hook has to be installed and confirmed firing first" rule: this status line is installed into ~/.claude/settings.json by scripts/install-hooks.sh and does nothing until that installer runs and Claude Code restarts. Merging this PR alone does not put the badge in force, so the issue stays open (Part of) for Josh to close by hand after he installs it and confirms the badge actually appears.

How to test locally

Sourced from the issue's "How to test locally" section, corrected to what was actually run.

# 1. Unit tests, including the new ones
deno task test

# 2. Type-check + lint gate
deno task check

Expect: both green. Actually ran: deno task testok | 1333 passed | 0 failed;
deno task check → clean (no type errors across src/ and test/). Also ran
deno task fmt:check and deno task lint, both clean, since the format gate is
part of CI here too.

# 3. The check that exercises the change itself — feed the script a synthetic
#    payload for each tier and confirm the badge differs. STATUSLINE_DOWNSTREAM_CMD
#    is set to `cat` here instead of the real default (npx -y ccusage statusline)
#    purely to avoid a live network call during manual verification — the
#    automated suite (test/statusline_script.test.ts) is what actually asserts
#    pass-through correctness against the real default.
echo '{"model":{"id":"claude-opus-5","display_name":"Opus 5"},"workspace":{"current_dir":"/home/joshua"}}' \
  | STATUSLINE_DOWNSTREAM_CMD=cat scripts/statusline.sh | cat -v
echo '{"model":{"id":"claude-sonnet-5","display_name":"Sonnet 5"},"workspace":{"current_dir":"/home/joshua"}}' \
  | STATUSLINE_DOWNSTREAM_CMD=cat scripts/statusline.sh | cat -v
echo '{"model":{"id":"claude-haiku-4-5-20251001","display_name":"Haiku 4.5"},"workspace":{"current_dir":"/home/joshua"}}' \
  | STATUSLINE_DOWNSTREAM_CMD=cat scripts/statusline.sh | cat -v

Expect: three visibly different badges (^[[1;35m[Opus]^[[0m, ^[[1;36m[Sonnet]^[[0m,
^[[1;32m[Haiku]^[[0m under cat -v), each followed by the unmodified input payload.
Actually observed exactly that.

# 4. Installer is idempotent and its drift check agrees. --hooks-dir is required
#    alongside --settings-path when running from a worktree (as this PR was built
#    in) — otherwise install-hooks.sh's worktree guard refuses to touch the
#    default ~/.claude/hooks destination. Never point either at a real path.
scripts/install-hooks.sh --settings-path /tmp/statusline-probe-settings.json --hooks-dir /tmp/statusline-probe-hooks
scripts/install-hooks.sh --settings-path /tmp/statusline-probe-settings.json --hooks-dir /tmp/statusline-probe-hooks
jq '.statusLine' /tmp/statusline-probe-settings.json
# and: the implicit agy hooks.json target (same directory as the settings path)
# must have NO statusLine key at all.
jq '.statusLine' /tmp/statusline-probe-settings-dir/hooks.json

Expect: the first run adds statusLine; the second run's stdout for the settings
file reads "already up to date (no-op)"; jq '.statusLine' .../settings.json shows
{"type":"command","command":"<repo>/scripts/statusline.sh"}; jq '.statusLine' on
the agy hooks.json target returns null. Actually ran this against throwaway
paths under /tmp and observed exactly that on both runs.

# 5. The two settings-merge test files that changed
deno test --allow-env --allow-run --allow-read --allow-write test/statusline_script.test.ts
deno test --allow-env --allow-run --allow-read --allow-write test/install_hooks_merge.test.ts
deno test --allow-env --allow-run --allow-read --allow-write test/install_hooks_script.test.ts

Expect: all green (9, 49, and 18 tests respectively — all passed).

Part of #688

🤖 Work by Claude Code — Sonnet 5

- scripts/statusline.sh: new wrapper that reads the Claude Code status-line
  stdin payload, extracts .model.display_name, and prints a color-coded
  Opus/Sonnet/Haiku badge (case-insensitive family-word match, uncolored
  fallback for anything unrecognized) before passing the payload through
  unmodified to the downstream status-line command.
- scripts/merge-hooks-into-settings.ts: add --status-line section, merging
  a single statusLine {type, command} value idempotently, with --check
  drift detection matching the existing --deny/--ask pattern.
- scripts/install-hooks.sh: wire the new --status-line arg into the
  $SETTINGS_PATH invocations only; $AGY_HOOKS_PATH invocations are
  untouched, since agy has no status-line surface.
- test/statusline_script.test.ts: new, covers all three tiers, the
  unrecognized/malformed-JSON/missing-model fallback cases, and unmodified
  pass-through to the downstream command.
- test/install_hooks_merge.test.ts: extend with --status-line merge/no-op/
  update/--check coverage.
- test/install_hooks_script.test.ts: teach the synthetic worktree fixture
  to also copy scripts/statusline.sh, now a dependency of install-hooks.sh.
- docs/scripts.md: document the new script.
@JoshuaVSherman
JoshuaVSherman force-pushed the claude/688-model-aware-statusline-badge branch from 04651c0 to fef9bcb Compare August 21, 2026 18:43
@JoshuaVSherman
JoshuaVSherman marked this pull request as ready for review August 21, 2026 18:43
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@JoshuaVSherman JoshuaVSherman left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review Summary

🛑 Changes Requested

Reviewed by Opus at Josh's direct instruction (outside the usual cross-model pairing). Reviewed at head aa35db593d8352e69704ec0e12b977aafc67f941; no prior automated review exists on this PR.

Amended 2026-08-21. This review originally carried a second Must Fix item saying the PR was not a draft. That finding is withdrawn — it was wrong. Josh takes his own PRs out of draft; flagging that is flagging his deliberate workflow action as a defect in the work. Draft/ready state is not a review finding and never should have been raised. The pr-review skill is being amended to exclude it explicitly.

The implementation is good and the scope is exactly right — eight files, every one named in web-jam-tools#688 "Add a model-aware status-line badge so Opus and Sonnet sessions are visually distinguishable", nothing stray. scripts/statusline.sh handles its failure modes deliberately (unrecognized tier, missing .model, malformed JSON all produce usable output), the tier match is on the family word so a version bump won't break it, and the agy isolation is real rather than asserted: I checked lines 711 and 794 of scripts/install-hooks.sh and neither $AGY_HOOKS_PATH invocation receives --status-line.

One thing blocks merge.

🛑 Must Fix Items

  • 🛑 The status line is registered at a raw working-tree path and is invisible to drift detection. scripts/install-hooks.sh:631 sets STATUS_LINE_COMMAND="$REPO_DIR/scripts/statusline.sh" and registers that directly into settings.json. Every hook in this repo instead gets a stable installed path — the live denial messages on this machine name them as $HOME/.claude/hooks/<name>.sh — and scripts/statusline.sh is never linked into that directory at all. Two consequences: (1) the registered command points into whichever checkout ran the installer, so it breaks if the repo moves or if a branch lacking the file is checked out, and (2) because it never lands in the hooks directory, hooks/hook-install-drift-reminder.sh cannot see it — a dead or stale status-line path will never be reported, unlike every other installed artifact. Install it the same way hooks are installed and register the stable path.

Checklist Verification

  • Mergeability: ⚠️ UNKNOWN — GitHub is still recomputing after the re-bump push. Re-check before merging; do not read UNKNOWN as ready.
  • CircleCI: ⚠️ ci/circleci: gate is pending (build 867). Build 866 failed, but that was against the intermediate commit before the version re-bump, so it is not evidence against the current head. Must be green before merge.
  • Snyk: ✅ No Snyk check configured on this PR.
  • Issue acceptance criteria & scope: ✅ All eight changed files are named in the issue: scripts/statusline.sh, scripts/merge-hooks-into-settings.ts, scripts/install-hooks.sh, test/statusline_script.test.ts, test/install_hooks_merge.test.ts, test/install_hooks_script.test.ts, docs/scripts.md, deno.json. No stray refactors.
  • Single semver bump: ✅ Branch is at 1.30.61, dev at 1.30.60 — strictly exceeds. The re-bump was required because dev advanced to 1.30.60 after this branch's first commit, which the criteria explicitly call correct rather than a gratuitous double bump.
  • Package-lock engine alignment: ✅ Not applicable — Deno repository, no package.json.
  • Test plan integrity: ✅ Goes well beyond suite invocations — feeds synthetic per-tier payloads through the script and inspects the ANSI output under cat -v, and runs the installer twice against throwaway paths to prove idempotency and that the agy target keeps statusLine: null.
  • AGENTS.md guardrails: ✅ No raw any in the added TypeScript (checked the *.ts diff). No UI, forms, OAuth, footer, or headers surface touched.

🟡 Actionable Feedback & Suggestions

  • 🟡 scripts/statusline.sh sets set -uo pipefail and ends on the pipeline printf '%s' "$payload" | bash -c "$downstream_cmd", so the script's exit status is the downstream command's. The default downstream is npx -y ccusage statusline, which reaches the network and will fail offline or if the package resolve fails. The script's own header states it must never make the status line disappear; an explicit exit 0 after the pipeline would guarantee that regardless of how Claude Code treats a non-zero exit from a status-line command. I did not verify what Claude Code actually does with a non-zero exit — which is the point: the fix removes the dependency on that answer.
  • 🟡 bash -c "$downstream_cmd" executes the contents of the STATUSLINE_DOWNSTREAM_CMD environment variable as shell. It is a legitimate and well-documented test seam, and the risk is low since it is the user's own environment, but a status line runs on every render — worth a note that anything setting that variable gets arbitrary execution on each redraw.
  • 🟡 The badge occupies fixed width per tier ([Opus], [Sonnet], [Haiku]). If the fallback fires, the text is the full unrecognized display_name, which could be long and push the cost/usage segment off-screen on a narrow terminal. Truncating the fallback to a sensible width would keep the line stable.

Final merge remains Josh's call.

Review finding on web-jam-tools#691 "feat: add model-aware status-line
badge (web-jam-tools#688)": install-hooks.sh registered
STATUS_LINE_COMMAND as the raw working-tree path
$REPO_DIR/scripts/statusline.sh, so the registered command pointed into
whichever checkout ran the installer and broke if the repo moved or a
branch lacking the file was checked out. It was also never linked into
the hooks directory, so hook-install-drift-reminder.sh could not see it
and a dead status-line path would never be reported.

statusline.sh is now installed to a stable destination alongside the
hooks and registered at that path, and the drift checker covers it.
Version bumped to 1.30.64 to clear dev at 1.30.62.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant