From 7f4bf8234a279618a866be73a25950c8e0fb1b3e Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 21:00:24 +0200 Subject: [PATCH 1/2] feat(agents): a Claude Code status line that follows the Omarchy theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom bar for the Claude Code CLI, in two lines: where you are (model, effort, worktree, branch, uncommitted count, open PR and its review state), then what it costs (a context-window gauge, the 5-hour and 7-day quota windows, session spend and lines changed). Everything it shows comes from the JSON Claude Code writes to the command's stdin — no API poll, no `gh` call, no transcript parsing. It paints with the sixteen ANSI colors and nothing else. The palette then comes from the terminal, so the bar re-themes itself when Omarchy switches theme or flips light/dark, the same way starship and bat already do here. Truecolor would have meant a theme-set hook and a second copy of the palette. Two details the bar would be wrong without: Nerd Font icons come from the non-Mono family and take two cells, so the width budget counts them as two; and `COLUMNS` is not exported to the status line command while `tput cols` answers 80 whenever stdout is a pipe, which is always, so a wide bar would truncate at 80 — the fallback is a generous 200 instead. `--test` is the self-check: thresholds, the gauge ramp, a quiet session, narrow-terminal truncation, the Touch Bar hand-off, and empty or invalid stdin. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TN4cPFxUtafhd87w2ZqY7R --- PKGBUILD | 2 +- README.md | 18 ++++ agents/claude-statusline.sh | 187 ++++++++++++++++++++++++++++++++++++ install.sh | 15 +++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100755 agents/claude-statusline.sh diff --git a/PKGBUILD b/PKGBUILD index e935719..d54bf23 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -23,7 +23,7 @@ depends=('bash' 'python' 'brightnessctl') optdepends=('omarchy: the theme hooks and shell plugins target it' 'grim: bar-contrast samples the screen with it' 'imagemagick: bar-contrast reduces the sample with it' - 'jq: reading the bar layer geometry' + 'jq: reading the bar layer geometry, and the Claude Code status line' 'hyprland: the gestures and key grammar') source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") sha256sums=('SKIP') diff --git a/README.md b/README.md index c65477b..dd0926e 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,24 @@ app without ever flashing UI. Escape or a click outside cancels; clicking an icon switches to it. The glass needs the theme to blur the `macarchy-switcher` layer namespace (the apple-glass themes do). +## agents/ — Claude Code + +`claude-statusline.sh` is the bottom bar for the Claude Code CLI: model and +reasoning effort, the worktree and branch you are in, an open PR and its review +state, then a context-window gauge with the 5-hour and 7-day quota windows and +what the session has cost. It stays quiet — a quota window or a cost only +appears once it is worth a glance. + +It paints with the sixteen ANSI colors and nothing else, which is the whole +point: the palette comes from the terminal, so the bar re-themes itself when +Omarchy switches theme or flips light/dark. No theme hook, no second copy of +the palette to keep in sync. `install.sh` symlinks it into `~/.claude/` and +points `statusLine` at it, leaving a status line you already configured alone. + +`agents/skills/omarchy-asahi` is the companion machine skill, symlinked into +`~/.claude/skills`: what an agent has to know about this laptop that the +packaged `omarchy` skill gets wrong. + ## Install ./install.sh # scripts, hook, timer, example configs diff --git a/agents/claude-statusline.sh b/agents/claude-statusline.sh new file mode 100755 index 0000000..03b24cf --- /dev/null +++ b/agents/claude-statusline.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Claude Code statusline for macarchy — a fuel gauge, not a dashboard. +# +# ANSI-16 only on purpose: colors come from the terminal palette, so the bar +# re-themes itself when Omarchy flips light/dark — no theme hook needed. +# Quiet by default: a segment appears only once it carries information. + +set -u + +OUT='' LEN=0 BUDGET=0 + +# Append a colored segment, preceded by `gap` spaces, if it still fits. +seg() { # [gap=2] + local gap=${3-2} w bare + ((LEN == 0)) && gap=1 + # Nerd Font icons come from the non-Mono family and take two cells each. + bare=${2//[$'\ue0a0'$'\uf407'$'\uf0e7']/} + w=$((gap + ${#2} + ${#2} - ${#bare})) + ((LEN + w > BUDGET)) && return 1 + printf -v OUT '%s%*s\033[%sm%s\033[0m' "$OUT" "$gap" '' "$1" "$2" + LEN=$((LEN + w)) +} + +num() { local v="${1%%.*}"; echo "${v:-0}"; } + +# calm / warn / critical -> ansi color and fill glyph. Density rises with +# danger so the gauge reads without color. +heat() { local p; p=$(num "$1"); ((p>=85)) && echo 31 || { ((p>=60)) && echo 33 || echo 32; }; } +glyph() { local p; p=$(num "$1"); ((p>=85)) && echo '█' || { ((p>=60)) && echo '▓' || echo '▒'; }; } + +gauge() { # -> 12-cell bar, fill darkens as it rises + local p n i f s=''; p=$(num "$1"); ((p>100)) && p=100 + f=$(glyph "$p"); n=$((p * 12 / 100)) + ((p > 0 && n == 0)) && n=1 # a needle just off zero beats a gauge that looks off + for ((i=0;i<12;i++)); do ((i "2h14" / "47m", empty if past + local s=$(( $(num "$1") - $(date +%s) )) + ((s<=0)) && return + ((s>=3600)) && printf '%dh%02d' $((s/3600)) $((s%3600/60)) || printf '%dm' $((s/60)) +} + +render() { + local F + mapfile -t F < <(jq -r '[ + .model.display_name // "?", + .effort.level // "", + (.fast_mode // false), + .workspace.current_dir // .cwd // "", + (.worktree.name // .workspace.git_worktree // ""), + (.pr.number // ""), (.pr.review_state // ""), + (.context_window.used_percentage // 0), + (.rate_limits.five_hour.used_percentage // 0), + (.rate_limits.seven_day.used_percentage // 0), + (.rate_limits.seven_day.resets_at // 0), + (.cost.total_cost_usd // 0), + (.cost.total_lines_added // 0), (.cost.total_lines_removed // 0), + (.agent.name // ""), + (.session_id // "") + ] | .[] | tostring | gsub("\n";" ")' 2>/dev/null) + [ "${#F[@]}" -ge 16 ] || return 0 + local model=${F[0]} effort=${F[1]} fast=${F[2]} dir=${F[3]} wt=${F[4]} + local pr=${F[5]} prstate=${F[6]} ctx=${F[7]} h5=${F[8]} d7=${F[9]} d7r=${F[10]} + local cost=${F[11]} added=${F[12]} removed=${F[13]} agent=${F[14]} sid=${F[15]} + + # Line 1 — where I am. Line 2 — what it costs. + BUDGET=${COLUMNS:-200}; BUDGET=$((BUDGET - 1)) + + OUT='' LEN=0 + seg 36 "$model" + [ -n "$effort" ] && seg 36 "$effort" 1 + [ "$fast" = true ] && seg 93 $'\uf0e7' 1 + [ -n "$agent" ] && seg 95 "@$agent" + + # `wt/` spells out a worktree: a glyph would be prettier and would not + # render on half the fonts out there. + if [ -n "$wt" ]; then seg 34 "wt/$wt" + elif [ -n "$dir" ]; then seg 34 "${dir##*/}"; fi + + if [ -n "$dir" ] && [ -d "$dir" ]; then + local gs branch dirty + gs=$(git -C "$dir" status --porcelain -b 2>/dev/null) && { + branch=$(head -n1 <<<"$gs"); branch=${branch#\#\# }; branch=${branch%%...*} + dirty=$(( $(wc -l <<<"$gs") - 1 )) + seg 35 $'\ue0a0'" $branch" + ((dirty > 0)) && seg 33 "●$dirty" 1 + } + fi + + # Review state gets a shape as well as a color. + if [ -n "$pr" ]; then + local pc=90 mark='' + case "$prstate" in + approved) pc=32 mark='✓' ;; + changes_requested) pc=31 mark='✗' ;; + pending) pc=33 mark='·' ;; + esac + seg "$pc" $'\uf407'" $pr$mark" + fi + + [ "$(head -n1 "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.ponytail-active" 2>/dev/null | tr -d '[:space:]')" ] && + seg 32 'ponytail' + + local line1=$OUT + + # The gauge leads: leftmost cells are where peripheral vision catches change. + OUT='' LEN=0 + seg "$(heat "$ctx")" "$(gauge "$ctx")" + seg "$(heat "$ctx")" "$(num "$ctx")%" 1 + + # Quota windows stay hidden until a quarter burned — silence is the signal. + [ "$(num "$h5")" -ge 25 ] && seg "$(heat "$h5")" "5h $(num "$h5")%" + if [ "$(num "$d7")" -ge 25 ]; then + seg "$(heat "$d7")" "7d $(num "$d7")%" + local left; left=$(eta "$d7r"); [ -n "$left" ] && seg 90 "↻$left" 1 + fi + + # Tail: droppable first when the terminal is narrow. + awk "BEGIN{exit !($cost >= 0.10)}" 2>/dev/null && seg 90 "$(printf '$%.2f' "$cost")" + ((added + removed > 0)) && seg 90 "+$added/-$removed" + + printf '%s\n%s\n' "$line1" "$OUT" + + # Leave the context reading where macarchy-touchbar's `claude` module can + # find it: one file per session, on tmpfs, so a logout clears them all. + # No-op when nothing reads it. + local d=${XDG_RUNTIME_DIR:-}/macarchy-claude + [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -n "$sid" ] && mkdir -p "$d" 2>/dev/null && + printf '%s\n' "$(num "$ctx")" >"$d/$sid" 2>/dev/null + return 0 +} + +if [ "${1:-}" != "--test" ]; then render; exit 0; fi + +# --- self-check ------------------------------------------------------------- +fail() { echo "FAIL: $1"; exit 1; } +has() { case "$2" in *"$1"*) ;; *) fail "missing '$1' in: $2";; esac; } +hasnt(){ case "$2" in *"$1"*) fail "unexpected '$1' in: $2";; esac; } + +[ "$(heat 42)" = 32 ] && [ "$(heat 60)" = 33 ] && [ "$(heat 85)" = 31 ] || fail 'heat thresholds' +[ "$(glyph 42)" = '▒' ] && [ "$(glyph 60)" = '▓' ] && [ "$(glyph 85)" = '█' ] || fail 'glyph ramp' +[ "$(gauge 0)" = '░░░░░░░░░░░░' ] || fail "gauge 0: $(gauge 0)" +[ "$(gauge 1)" = '▒░░░░░░░░░░░' ] || fail "gauge 1: $(gauge 1)" +[ "$(gauge 50)" = '▒▒▒▒▒▒░░░░░░' ] || fail "gauge 50: $(gauge 50)" +[ "$(gauge 100)" = '████████████' ] || fail "gauge 100: $(gauge 100)" +[ "$(gauge 120)" = '████████████' ] || fail 'gauge clamp' +[ -z "$(eta 1)" ] || fail 'past reset must be empty' + +busy=$(COLUMNS=200 render </dev/null +[ "$(cat "$XDG_RUNTIME_DIR/macarchy-claude/s1" 2>/dev/null)" = 73 ] || fail 'context not handed off' +rm -rf "$XDG_RUNTIME_DIR"; unset XDG_RUNTIME_DIR + +render /dev/null; then + if ! jq -e 'has("statusLine")' "$settings" >/dev/null; then + tmp=$(mktemp) + jq '.statusLine = {"type": "command", "command": "bash ~/.claude/statusline.sh"}' \ + "$settings" >"$tmp" && mv "$tmp" "$settings" + echo "Claude Code status line enabled" + fi +else + echo "Claude Code not configured yet; run ./install.sh again after first launch to enable its status line" +fi + # Cmd-key grammar and the app switcher. On this machine Omarchy already made # ~/.config/hypr, but on a genuinely bare box it does not exist yet and, under # `set -e`, the install dies here before anything below it runs. From ac809272b4459a06eddfd0d946fe8844811a5345 Mon Sep 17 00:00:00 2001 From: Philippe Matray Date: Sat, 5 Sep 2026 21:45:21 +0200 Subject: [PATCH 2/2] test: put the status line's self-check on CI's path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertions shipped inside `claude-statusline.sh --test`, where nothing ran them. This is the wrapper that makes `tests/*.sh` — the gate CI actually runs — call it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TN4cPFxUtafhd87w2ZqY7R --- tests/test_claude_statusline.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100755 tests/test_claude_statusline.sh diff --git a/tests/test_claude_statusline.sh b/tests/test_claude_statusline.sh new file mode 100755 index 0000000..51ab9bf --- /dev/null +++ b/tests/test_claude_statusline.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# tests/test_claude_statusline.sh — the status line carries its own self-check +# (thresholds, the gauge ramp, a quiet session, narrow-terminal truncation, the +# Touch Bar hand-off, empty and invalid stdin). This is what puts it on CI's +# path; the assertions live next to the code they check. +set -uo pipefail +cd "$(dirname "$0")/.." + +if out=$(bash agents/claude-statusline.sh --test 2>&1); then + echo "PASS test_claude_statusline" +else + echo "$out" + exit 1 +fi