SLOPSHOPPER

statusline-band

A panel above the prompt with rate limits and their pace, context, cost and tokens, git status and switches, actions, and a Spotify card, with Clawd and Beatbot

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline-band
│ ┃ Search Spotify ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ● Spotify ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭───────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ a track, an artist, an album ⏎ search │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰───────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Set spotifyClientId in /config first │ ┃ (your app at ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ developer.spotify.com/dashboard). │ ✻ Worked for 42s · done 4:20 PM │ │ › /spotify-search │ ⎿ statusline-band: Spotify search opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Search Spotify
● Spotify ╭─────────────────────────────────────────────────────╮ │ a track, an artist, an album ⏎ search │ ╰─────────────────────────────────────────────────────╯ Set spotifyClientId in /config first (your app at developer.spotify.com/dashboard).
Pane · usage-history
30 days $0.00 · 0 tokens, counted from when the mod was in… copy as text
README

statusline-band

A panel above the Claude Code prompt, with Clawd, the Claude Code mascot, standing beside it, and a Spotify card under it with Beatbot, a Spotify mascot.

  • Top edge: the repo name, branch, and the model with its effort and your Claude plan (Max 5x, Pro...). On the right: the session's cost and tokens, then today's and the last 30 days'.
  • Middle: the 5-hour and 7-day rate limits as filling rings, with the reset countdown and the local reset time. ▲ out ~7h appears when the pace would use a limit up before it resets. Then context usage as a bar.
  • Bottom edge: running subagents, uncommitted files and lines, unpushed commits and their lines, commits behind, stashes and the time since the last commit. On the right: the last reply's input (with its cached share) and output tokens, the session length and the time.
  • Clawd: runs while Claude works. Otherwise it sweats while a rate limit is at 95% or more, falls asleep after 10 quiet minutes, and cheers when your commits are pushed.
  • Actions: a row under the card sends a prompt as if you typed it: push (commit and push), find bugs, run tests and summarize. quick commit has Haiku write a message from the diff and shows it: commit, commit and push, or type your own; it then commits itself, without a turn of the main model. Click one, or focus the band with ctrl+x tab, move to it and press Enter.
  • Git switches: commit auto · push ask, kept per repo. On ask, Claude's git add or commit, or push, waits for your OK. On auto Claude also does it on its own: it commits each finished change, and pushes right after. It runs without a prompt, but only in its plain form, naming its repo with -C: git -C '/abs/path' add <files>, git -C '/abs/path' commit -m '…' and git -C '/abs/path' push [remote] [branch], joined by &&. Any other shape asks whatever the switch says: a step without -C (the Bash tool keeps its own folder between calls), a force push, a heredoc, $(…), a cd, a pipe, a wrapper such as env or xargs, or an alias that commits or pushes. Nothing is allowed in plan mode. Steps that only read (status, log, diff, show, with flags that only shape their output) may ride along. When a step asks, a line under the dialog says what it takes with it. The system prompt tells Claude the plain form and where each switch stands. Click commit or push to flip it.
  • Spotify (macOS): off until you click spotify beside the git switches (spotify off, then on, kept across sessions). While it is off, nothing asks Spotify anything. When it is on and Spotify runs, a card framed in Spotify green under the band shows the state and the track, ◀◀ ❚❚ ▶▶, progress with the time, shuffle, repeat and volume − +, and the track heard before it (kept across sessions). With lyrics on (beside spotify, on by default), the card also shows the line being sung, bright, and the next one dim, in time with the song; they come from LRCLIB (lrclib.net), and a track it has no synced lyrics for shows none. search (or /spotify-search) opens a pane that searches Spotify and plays the track you pick, keeping the terminal in front, once spotifyClientId and the client secret are set. Beatbot, a little Spotify mascot drawn in pixels, dances under Clawd while a track plays, and falls asleep with Z's once a track has been paused 10 minutes. It asks Spotify through AppleScript every 5 seconds and never launches it. The playing playlist is not shown: Spotify gives it only through its Web API, which needs a sign-in.
  • Minimize: minimize on the bottom edge of either card shrinks it to one line from the band's left edge. The main card keeps the repo and branch, model, limits and context as percents, the first git fact and the session cost; the Spotify card keeps the controls, the track and the time. expand brings it back. Each card remembers its choice across sessions.
  • Extras: hover a rate limit for its pace. Click 30d, or run /usage-history, for a pane with each day's cost and tokens. Toasts fire when a limit passes 80% and 95%, and when its pace would run it out before the reset.

Needs Claude Code v2.1.287 or later.

Install

/plugin marketplace add AsyrafHussin/claude-code-statusline
/plugin install statusline-band@claude-code-statusline

Settings

Each is a row in /config, or run /plugin configure statusline-band@claude-code-statusline.

SettingDefaultWhat it does
initialsemptyUp to two letters on Clawd's shirt
cardColor#0a0a0aBackground of the band; empty ("") for none, so it takes the terminal's own
padRows1Empty rows above and below the stats, 0 to 2
historyDays30Days the rolling cost and the history pane cover, 7 to 62
noAttributionfalseLeave Co-Authored-By, Claude-Session and the Claude Code footer out of commits and pull requests
gitStricttrueOn: with a git switch on auto, only plain commands naming their repo with -C run without a prompt. Off: any git command runs without a prompt while the repo's switch is on auto
spotifytrueWhile Spotify runs, show what it plays under the band, with Beatbot dancing, a progress bar and controls (macOS)
planemptyYour Claude plan as shown, such as Max 20x; empty reads it from Claude Code's own ~/.claude.json (updated when Claude Code starts or signs in)
spotifyClientIdemptyClient ID of your own app at developer.spotify.com/dashboard, for searching Spotify
spotifyClientSecretemptyClient secret of that app. /config leaves secret settings out, so keep it in the macOS Keychain instead: security add-generic-password -U -s statusline-band-spotify -a client-secret -w (it asks for the secret, hidden)

What it runs, reads, keeps and sends

Programs it runs

It runs these programs, never through a shell. The arguments are fixed except the folder (each git command runs in the repo's folder, git -C <folder>), for quick commit the commit message you approved, and for a searched track its Spotify URI (checked to be spotify:track: and letters and digits only).

  • git, read-only, to show the branch and its changes (each in the repo's folder, git -C <folder>). It runs:
  • rev-parse --show-toplevel for the repo's root, once per folder
  • status --porcelain=v2 --branch --show-stash for the branch, its upstream, commits ahead and behind, uncommitted files and stashes
  • diff --shortstat HEAD and diff --shortstat @{upstream}...HEAD for the lines uncommitted and unpushed
  • log -1 --format=%ct for the time of the last commit

These run every 30 seconds and after each tool call.

  • git, when a git step of Claude's asks, to say under the dialog what it takes with it: the same read-only commands, and config --get alias.<name> to see what a git alias in the command stands for.
  • git, when you press quick commit: status --porcelain, diff HEAD and log -5 --format=%s to write the message; then, once you approve it, add -A, commit -q -m <message> and, if you chose it, push. These are the only git commands it runs that change anything.
  • osascript, for Spotify: every 5 seconds, only while the spotify switch is on and Spotify runs, to read the track; on a press of a control (play, pause, skip, shuffle, repeat, volume); and when you pick a searched track, to play it and bring the app you were in back to the front.
  • security find-generic-password -s statusline-band-spotify -w, for search: when you open or use the search and the setting is empty, to read the Spotify client secret from your macOS Keychain; once it is found, it is kept until the mod reloads.
  • date, to read the local date and time (+%Y-%m-%d, and +%Y-%m-%d|%I:%M %p|%H|%M|%S). It also formats a rate limit's reset moment in your timezone (date -r <epoch>).

What it reads

  • From the session: the usage Claude Code reports (cost, context, rate limits and when they reset), the model's name, the session id and working folder, the subagents that are running, and the token counts at the end of each turn.
  • From Claude Code's own ~/.claude.json: only your account's plan type and rate-limit tier, once per load, to show the plan.
  • From Spotify, while it runs: the playing track's name, artist, album, length, position and id, and the shuffle, repeat and volume settings.
  • From your macOS Keychain: only the item statusline-band-spotify, the Spotify client secret, when you search.
  • What it never reads: the text of your prompts or Claude's replies. Of tool calls it reads only Bash commands, to find the git steps the switches cover.

What it keeps

All of it stays in the plugin's own store on your machine, nothing else:

  • each repo's git switches, by the repo's root
  • the last three Spotify tracks heard
  • the spotify and lyrics switches, on or off
  • whether each card is minimized
  • each day's cost and tokens, for the last 62 days
  • each session's last reading, for the last 100 sessions
  • rate-limit readings from the last 3 hours, used for the recent pace
  • which toasts it has already shown

What it sends

  • Prompts to Claude, only when you press an action. Each is the fixed text listed under Actions, sent to this session as if you typed it. A push still goes through your own permission rules.
  • The diff to Haiku, only when you press quick commit. Up to 24k characters of the diff, the names of new files and the last five commit subjects go to Claude's small model through Claude Code's own model call, the same way Claude Code itself talks to the model, to write the message.
  • A push, only when you choose "Commit and push" in quick commit. It runs git push itself, to the branch's upstream, with the git credentials you already have.
  • A Spotify search, only when you search. With your own app's client ID and secret, the mod asks accounts.spotify.com for an app token and api.spotify.com for up to 10 tracks matching what you typed; picking one plays it in the Spotify app through AppleScript.
  • The playing track to LRCLIB, while spotify and lyrics are on. Once per new track, its name, artist, album and length go to lrclib.net, a free open lyrics service, to get its synced lyrics. No account or key is involved. Turn lyrics off and nothing is sent.
  • Nothing else. It never fetches from git, makes no other network calls, has no telemetry, and sends nothing to any other service. Commits behind are counted against the remote as of your own last fetch or pull.

Hooks

  • tool.call: only notes the time and refreshes the panel after the call. It lets every call run unchanged and never blocks or rewrites one.
  • prompt.submit: starts Clawd's running animation. The prompt passes through unchanged.
  • turn.complete: stops the animation and adds the turn's token counts to the totals.
  • session.start: starts the panel's timers and registers /usage-history and, with spotify on, /spotify-search.
  • command.run: answers only its own /usage-history and /spotify-search commands, by opening their panes.
  • attribution.text: with noAttribution on, answers an empty commit trailer and PR footer.
  • prompt.compose: adds one section to the system prompt: the git plain form, where each switch stands, and (with noAttribution) no credit lines.
  • Action buttons: pressing one calls $.prompt.submit with its fixed prompt; nothing is sent without a press. quick commit instead sends the diff (up to 24k characters) and the last five commit subjects to Haiku through Claude Code's own model call, then runs git add -A, git commit -m and, if you choose it, git push itself.
  • classic.UserPromptSubmit, classic.PostToolUse: read the permission mode and effort; they change nothing.
  • classic.PreToolUse (Bash only): runs the hooks beneath it first (your own settings hooks among them; their ask or deny stands), then reads the command as it will run for git add, commit and push, and answers ask or allow by the repo's switches. Any other command passes on to your usual permissions untouched.
  • ui.render: draws the panel above the prompt (AbovePrompt), and the history and Spotify search panes (Pane).
Source 8 files
hooks/register.tsx 1093 lines
1// The hooks module: reads the person's settings and wires the band to the engine's events. The engine
2// follows $ only within this file, so everything that touches it lives here, in sections; the pure parts
3// live beside it: logic (tested), state (shared values), band and pane (the drawings)
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import { drawBand } from './band'
8import { BEATBOT_SLEEP_MS } from './layout'
9import type { MusicCommand } from './band'
10import type { Action } from './band'
11import {
12  aliasSteps,
13  cleanCommitMessage,
14  formatReset,
15  livePosition,
16  lyricAt,
17  lyricsUrl,
18  parseLyrics,
19  gitDecision,
20  gitGuide,
21  isSensitivePath,
22  looseGitSteps,
23  parsePlainGit,
24  parseShortstat,
25  addPlayed,
26  basicAuth,
27  parseSearch,
28  safeTrackUri,
29  parseSpotify,
30  parseStatusV2,
31  placeFolder,
32  planLabel,
33  prettyModel,
34  readConfig,
35  runsOutIn,
36  shiftDay,
37  sumDays,
38} from './logic'
39import type { FoundTrack, GitStep, PlayedTrack } from './logic'
40import { drawHistoryPane, drawSearchPane, historyText } from './pane'
41import { GIT_TIMEOUT, LIMIT_LABELS, REVIEW, runtime } from './state'
42import type { GitAuto } from './state'
43import type { CostLedger, Limit, Mood, PaceLog, Snapshot, SpotifySearch, TokenLedger } from '../types'
44
45// ── State values the band draws from
46
47const snap = atom({ plugin: 'statusline-band', key: 'snap' } as const, null)
48const isCompacting = atom({ plugin: 'statusline-band', key: 'isCompacting' } as const, false)
49const frame = atom({ plugin: 'statusline-band', key: 'frame' } as const, 0)
50const isBlinking = atom({ plugin: 'statusline-band', key: 'isBlinking' } as const, false)
51const lastTurn = atom({ plugin: 'statusline-band', key: 'lastTurn' } as const, null)
52const history = atom({ plugin: 'statusline-band', key: 'history' } as const, [])
53const mood = atom({ plugin: 'statusline-band', key: 'mood' } as const, 'idle')
54const moodTick = atom({ plugin: 'statusline-band', key: 'moodTick' } as const, 0)
55const music = atom({ plugin: 'statusline-band', key: 'music' } as const, null)
56const danceTick = atom({ plugin: 'statusline-band', key: 'danceTick' } as const, 0)
57const musicCompact = atom({ plugin: 'statusline-band', key: 'musicCompact' } as const, false)
58// The Spotify switch on the band: off until turned on, and nothing is asked of Spotify while it is off
59const musicOn = atom({ plugin: 'statusline-band', key: 'musicOn' } as const, false)
60// The playing track's lyrics, and their switch beside Spotify's: on until turned off
61const lyrics = atom({ plugin: 'statusline-band', key: 'lyrics' } as const, null)
62const lyricsOn = atom({ plugin: 'statusline-band', key: 'lyricsOn' } as const, true)
63const cardCompact = atom({ plugin: 'statusline-band', key: 'cardCompact' } as const, false)
64const search = atom({ plugin: 'statusline-band', key: 'search' } as const, { query: '', status: 'idle', message: '', tracks: [] })
65
66// The pane with each day's cost and tokens, opened by /usage-history or the window's label on the band;
67// a literal, so the engine's scan can read the hooks' matchers
68const HISTORY_PANE = 'usage-history'
69// The Spotify search dialog, opened by the search button or /spotify-search
70const SEARCH_PANE = 'spotify-search'
71
72// ── Actions under the card
73
74const ACTIONS: Action[] = [
75  {
76    key: 'push',
77    label: 'push',
78    prompt: 'Commit any uncommitted changes with a clear message, then push the current branch.',
79  },
80  {
81    key: 'bugs',
82    label: 'find bugs',
83    prompt:
84      'Review the uncommitted changes and the commits not yet pushed in this repo for bugs; if there are none, review the last few commits instead. Verify each one before reporting it, and list them before fixing anything.',
85  },
86  {
87    key: 'test',
88    label: 'run tests',
89    prompt: "Run this project's tests, typecheck and linters, and report what fails.",
90  },
91  {
92    key: 'summary',
93    label: 'summarize',
94    prompt: 'Summarize what we changed in this session in a few short lines.',
95  },
96  { key: 'quick-commit', label: 'quick commit' },
97]
98
99// ── Usage kept across sessions: cost and tokens, the limits' recent pace, toasts shown, reset times
100
101const LEDGER_DAYS = 62
102const LEDGER_SESSIONS = 100
103// v2: the first ledger counted a resumed session's whole past cost as today's
104const COST_KEY = 'costs-v2'
105// Tokens are counted by the mod at each turn's end, so they start from when it was installed
106const TOKEN_KEY = 'tokens-v1'
107const ALERTED_KEY = 'alerted'
108const ALERT_THRESHOLDS = [80, 95]
109// The recent pace: readings kept every 5 minutes over the last 3 hours, read once they span 30 minutes
110const PACE_KEY = 'pace-v1'
111const PACE_SAMPLE_MS = 5 * 60_000
112const PACE_LOOKBACK_MS = 3 * 3_600_000
113const PACE_MIN_SPAN_MS = 30 * 60_000
114
115type Ledger = { days: Record<string, number>; sessions: Record<string, number> }
116
117// Sets a session's figure as the newest, so a session still running is never the first pruned, and keeps
118// the last 62 days and the last 100 sessions
119const settle = (ledger: Ledger, sessionId: string, value: number) => {
120  delete ledger.sessions[sessionId]
121  ledger.sessions[sessionId] = value
122  const days = Object.keys(ledger.days).sort()
123  for (const old of days.slice(0, Math.max(0, days.length - LEDGER_DAYS))) delete ledger.days[old]
124  const sessions = Object.keys(ledger.sessions)
125  for (const old of sessions.slice(0, Math.max(0, sessions.length - LEDGER_SESSIONS))) delete ledger.sessions[old]
126}
127
128// Adds what this session spent since its last reading to today's total. A session first seen after it
129// began before today (a resume, one open overnight) counts from that reading on, since what it spent
130// earlier is not today's
131async function recordCost($: EngineInterface, sessionId: string, usd: number, day: string, isFromToday: boolean) {
132  const ledger = ((await $.store.get(COST_KEY)) as CostLedger | undefined) ?? { days: {}, sessions: {} }
133  const last = ledger.sessions[sessionId]
134  if (last === undefined && !isFromToday) {
135    settle(ledger, sessionId, usd)
136    await $.store.set(COST_KEY, ledger)
137    return ledger
138  }
139  const delta = usd - (last ?? 0)
140  if (delta <= 0) return ledger
141  ledger.days[day] = (ledger.days[day] ?? 0) + delta
142  settle(ledger, sessionId, usd)
143  await $.store.set(COST_KEY, ledger)
144  return ledger
145}
146
147// Adds a turn's tokens to its session's and today's totals
148async function recordTokens($: EngineInterface, sessionId: string, day: string, tokens: number) {
149  const ledger = ((await $.store.get(TOKEN_KEY)) as TokenLedger | undefined) ?? { days: {}, sessions: {} }
150  ledger.days[day] = (ledger.days[day] ?? 0) + tokens
151  settle(ledger, sessionId, (ledger.sessions[sessionId] ?? 0) + tokens)
152  await $.store.set(TOKEN_KEY, ledger)
153}
154
155const readTokens = async ($: EngineInterface) => (await $.store.get(TOKEN_KEY)) as TokenLedger | undefined
156
157// Gathers each day's cost and tokens over the history window from the ledgers, then opens the pane
158async function openHistory($: EngineInterface, today: string) {
159  const [costs, tokens] = await Promise.all([$.store.get(COST_KEY) as Promise<CostLedger | undefined>, readTokens($)])
160  const span = runtime.config.rollingDays
161  const days = Array.from({ length: span }, (_, i) => shiftDay(today, i - (span - 1)))
162  await update($, history, () => days.map(day => ({ day, usd: costs?.days[day] ?? 0, tokens: tokens?.days[day] ?? 0 })))
163  await $.ui.open({ id: HISTORY_PANE, title: `Usage, last ${span} days` })
164}
165
166// Keeps a reading of each window every few minutes, across sessions, and gives each window's recent pace
167// in percent per millisecond: from the oldest reading in the lookback to now
168async function recordPace($: EngineInterface, limits: Limit[], now: number) {
169  const log = ((await $.store.get(PACE_KEY)) as PaceLog | undefined) ?? {}
170  const next: PaceLog = {}
171  const rates: Record<string, number> = {}
172  let isChanged = false
173  for (const l of limits) {
174    if (!l.resetsAt) continue
175    const id = `${l.kind}@${l.resetsAt}`
176    const kept = (log[id] ?? []).filter(r => now - r.t <= PACE_LOOKBACK_MS)
177    const last = kept[kept.length - 1]
178    if (last === undefined || now - last.t >= PACE_SAMPLE_MS) {
179      kept.push({ t: now, pct: l.percent })
180      isChanged = true
181    }
182    next[id] = kept
183    const oldest = kept[0]
184    if (oldest !== undefined && now - oldest.t >= PACE_MIN_SPAN_MS && l.percent > oldest.pct) {
185      rates[l.kind] = (l.percent - oldest.pct) / (now - oldest.t)
186    }
187  }
188  if (isChanged || Object.keys(log).length !== Object.keys(next).length) await $.store.set(PACE_KEY, next)
189  return rates
190}
191
192// Toasts once per window and threshold, even across sessions, and once per window when its pace would
193// use it up before the reset
194async function alertLimits($: EngineInterface, limits: Limit[], now: number) {
195  const alerted = ((await $.store.get(ALERTED_KEY)) as string[] | undefined) ?? []
196  const fresh: string[] = []
197  for (const l of limits) {
198    const name = LIMIT_LABELS[l.kind] ?? l.kind
199    const crossed = ALERT_THRESHOLDS.filter(t => l.percent >= t).pop()
200    const id = `${l.kind}@${l.resetsAt ?? ''}@${crossed}`
201    if (crossed !== undefined && !alerted.includes(id)) {
202      fresh.push(id)
203      $.ui.toast(`${crossed >= 95 ? '🔥' : '⚠️'}  ${name} limit at ${Math.round(l.percent)}%`, { timeoutMs: 8000 })
204    }
205    if (!l.resetsAt) continue
206    const resetMs = Date.parse(l.resetsAt) - now
207    const out = runsOutIn(l.kind, l.percent, resetMs, l.recentRate)
208    const paceId = `pace@${l.kind}@${l.resetsAt}`
209    if (out === undefined || alerted.includes(paceId)) continue
210    fresh.push(paceId)
211    $.ui.toast(`▲  ${name} limit runs out in ~${formatReset(out)} at this pace, ${formatReset(resetMs - out)} before it resets`, {
212      timeoutMs: 10_000,
213    })
214  }
215  if (fresh.length > 0) await $.store.set(ALERTED_KEY, [...alerted, ...fresh].slice(-50))
216}
217
218// The reset moment in the machine's own timezone: "11:10 AM" within a day, else "Sat 8:00 PM". A
219// window's reset moment rarely changes, so each is formatted once per load (and format)
220const resetTimes = new Map<string, string | undefined>()
221async function localResetTime($: EngineInterface, iso: string, now: number) {
222  const ms = Date.parse(iso)
223  if (!Number.isFinite(ms)) return undefined
224  const format = ms - now < 86_400_000 ? '+%-I:%M %p' : '+%a %-I:%M %p'
225  const key = `${iso}|${format}`
226  if (resetTimes.has(key)) return resetTimes.get(key)
227  const epoch = String(Math.floor(ms / 1000))
228  // BSD date (macOS) takes the epoch with -r; GNU date (Linux) reads -r as a file and takes -d @epoch
229  const bsd = await $.process.run(['date', '-r', epoch, format])
230  const gnu = bsd.exitCode === 0 ? null : await $.process.run(['date', '-d', `@${epoch}`, format])
231  const text = bsd.exitCode === 0 ? bsd.stdout.trim() : gnu?.exitCode === 0 ? gnu.stdout.trim() : undefined
232  resetTimes.set(key, text)
233  return text
234}
235
236// ── Git: the repo as the band shows it, the per-repo switches, the line under a step's dialog
237
238// Per repo, by its root: whether Claude may commit, and push, without asking
239const GIT_AUTO_KEY = 'git-auto-v1'
240
241// git in a folder, with its output, or null when it could not run
242const runGit = ($: EngineInterface, dir: string, args: string[], timeoutMs = GIT_TIMEOUT.timeoutMs) =>
243  $.process.run(['git', '-C', dir, ...args], { timeoutMs }).catch(() => null)
244
245// The root of the repo a folder is in, or null outside one; a folder's root does not change, so each
246// is looked up once per load
247const roots = new Map<string, string | null>()
248async function repoRoot($: EngineInterface, dir: string) {
249  const known = roots.get(dir)
250  if (known !== undefined) return known
251  const top = await runGit($, dir, ['rev-parse', '--show-toplevel'])
252  const root = top?.exitCode === 0 ? top.stdout.trim() : null
253  // Only a found root is kept: a folder outside a repo may become one
254  if (root !== null) roots.set(dir, root)
255  return root
256}
257
258// The repo as the band shows it, in four git runs: status (branch, upstream, ahead and behind, stashes,
259// changed paths), the uncommitted and the unpushed lines, and the time of the last commit
260async function readGit($: EngineInterface, cwd: string): Promise<Snapshot['git']> {
261  const root = await repoRoot($, cwd)
262  if (root === null) return null
263  const [status, diff, outgoing, last] = await Promise.all([
264    runGit($, cwd, ['status', '--porcelain=v2', '--branch', '--show-stash']),
265    runGit($, cwd, ['diff', '--shortstat', 'HEAD']),
266    // Fails harmlessly without an upstream
267    runGit($, cwd, ['diff', '--shortstat', '@{upstream}...HEAD']),
268    runGit($, cwd, ['log', '-1', '--format=%ct']),
269  ])
270  if (status?.exitCode !== 0) return null
271  const state = parseStatusV2(status.stdout)
272  if (!state.branch) return null
273  const { added, removed } = parseShortstat(diff?.stdout ?? '')
274  const unpushed = parseShortstat(outgoing?.exitCode === 0 ? outgoing.stdout : '')
275  const committedAt = last?.exitCode === 0 ? Number(last.stdout.trim()) : NaN
276  return {
277    root,
278    branch: state.branch,
279    changed: state.changed,
280    upstream: state.upstream,
281    hasUpstream: state.upstream !== null,
282    ahead: state.ahead,
283    behind: state.behind,
284    added,
285    removed,
286    unpushedAdded: unpushed.added,
287    unpushedRemoved: unpushed.removed,
288    stashed: state.stashed,
289    lastCommitAt: Number.isFinite(committedAt) && committedAt > 0 ? committedAt * 1000 : null,
290  }
291}
292
293const readAllGitAuto = async ($: EngineInterface) =>
294  ((await $.store.get(GIT_AUTO_KEY)) as Record<string, GitAuto> | undefined) ?? {}
295
296async function readGitAuto($: EngineInterface, root: string): Promise<GitAuto> {
297  return { ...REVIEW, ...(await readAllGitAuto($))[root] }
298}
299
300// Flips one switch, auto or ask, for the repo the session is in, and redraws the band with it
301async function toggleGitAuto($: EngineInterface, step: GitStep) {
302  const root = (await read($, snap))?.git?.root
303  if (!root) return
304  // Read right before the write, so a switch another session flipped meanwhile is kept
305  const all = await readAllGitAuto($)
306  const next = { ...REVIEW, ...all[root], [step]: !(all[root]?.[step] ?? false) }
307  await $.store.set(GIT_AUTO_KEY, { ...all, [root]: next })
308  await update($, snap, was => (was ? { ...was, gitAuto: next } : was))
309}
310
311// What a git step is about to take with it, for the line under its dialog:
312// "✎ 2 files +12 −4" for a commit, "↑ 3 commits +518 −20 to origin/main" for a push
313async function pendingSummary($: EngineInterface, root: string, steps: GitStep[]) {
314  const git = await readGit($, root)
315  if (git === null) return ''
316  const lines = (added: number, removed: number) => `${added ? ` +${added}` : ''}${removed ? ` −${removed}` : ''}`
317  const parts: string[] = []
318  if (steps.includes('commit') && git.changed > 0) {
319    parts.push(`✎ ${git.changed} ${git.changed === 1 ? 'file' : 'files'}${lines(git.added, git.removed)}`)
320  }
321  if (steps.includes('push')) {
322    if (git.upstream === null) parts.push('↑ a branch with no upstream yet')
323    else if (git.ahead > 0) {
324      const commits = `${git.ahead} ${git.ahead === 1 ? 'commit' : 'commits'}`
325      parts.push(`↑ ${commits}${lines(git.unpushedAdded, git.unpushedRemoved)} to ${git.upstream}`)
326    }
327  }
328  return parts.join(' · ')
329}
330
331// The steps the aliases in a command take, looked up in the repo's git config
332async function stepsOfAliases($: EngineInterface, dir: string, aliases: string[]) {
333  const found = await Promise.all(
334    aliases.map(async alias => {
335      const expansion = await runGit($, dir, ['config', '--get', `alias.${alias}`])
336      return expansion?.exitCode === 0 ? aliasSteps(expansion.stdout) : []
337    }),
338  )
339  return found.flat()
340}
341
342// ── The snapshot: everything the band shows, read in one refresh
343
344const CHEER_MS = 3500
345
346// The Claude plan: the plan setting when set, else Claude Code's own config, of which only the account
347// type and tier are read, once per load. Claude Code updates that file when it starts or signs in, so
348// an upgrade shows from the next start
349async function readPlan($: EngineInterface) {
350  if (runtime.config.plan) return runtime.config.plan
351  if (runtime.plan !== undefined) return runtime.plan
352  const home = await $.env.get('HOME')
353  const text = home ? await $.fs.read(`${home}/.claude.json`).catch(() => '') : ''
354  runtime.plan = typeof text === 'string' ? planLabel(text) : null
355  return runtime.plan
356}
357
358async function readAndDraw($: EngineInterface) {
359  const cwd = await $.session.cwd()
360  const [usage, model, now, clock, repo, sessionId, agents] = await Promise.all([
361    $.session.usage(),
362    $.session.model(),
363    $.clock.now(),
364    $.process.run(['date', '+%Y-%m-%d|%I:%M %p|%H|%M|%S']),
365    readGit($, cwd).catch(() => null),
366    $.session.id(),
367    $.agent.list().catch(() => []),
368  ])
369  const [day = '', time = '', hh, mm, ss] = clock.stdout.trim().split('|')
370  const midnight = now - ((Number(hh) * 60 + Number(mm)) * 60 + Number(ss)) * 1000
371  const costUsd = usage.cost?.usd ?? null
372  const ledger = costUsd !== null ? await recordCost($, sessionId, costUsd, day, usage.startedAt >= midnight) : null
373  const tokenLedger = await readTokens($)
374  const since = shiftDay(day, -(runtime.config.rollingDays - 1))
375  const readings = await Promise.all(
376    usage.rateLimits.map(async (l): Promise<Limit> => ({
377      kind: l.kind,
378      percent: l.percentUsed,
379      resetsAt: l.resetsAt,
380      resetsOn: l.resetsAt ? await localResetTime($, l.resetsAt, now) : undefined,
381    })),
382  )
383  const rates = await recordPace($, readings, now)
384  const limits = readings.map(l => ({ ...l, recentRate: rates[l.kind] }))
385  const ctx = usage.context
386
387  const next: Omit<Snapshot, 'gitAuto'> = {
388    ...placeFolder(cwd, repo?.root ?? null),
389    git: repo,
390    model: prettyModel(model),
391    effort: runtime.effort,
392    plan: (await readPlan($)) ?? undefined,
393    startedAt: usage.startedAt,
394    now,
395    day,
396    time,
397    context: ctx.percent !== undefined ? { percent: ctx.percent, tokens: ctx.tokens ?? 0, window: ctx.window } : null,
398    costUsd,
399    todayUsd: ledger?.days[day] ?? null,
400    tokens: {
401      session: tokenLedger?.sessions[sessionId] ?? 0,
402      today: tokenLedger?.days[day] ?? 0,
403      rolling: sumDays(tokenLedger?.days ?? {}, since, day),
404    },
405    rollingUsd: ledger ? sumDays(ledger.days, since, day) : null,
406    limits,
407    agents: agents.filter(a => a.status === 'running').length,
408  }
409  // Commits that were waiting to go out and are now pushed: Clawd cheers
410  const before = await read($, snap)
411  if ((before?.git?.ahead ?? 0) > 0 && repo !== null && repo.hasUpstream && repo.ahead === 0) {
412    runtime.cheerUntil = now + CHEER_MS
413  }
414  // The switches are read last, so one flipped while this refresh ran is not drawn back
415  const gitAuto = repo?.root ? await readGitAuto($, repo.root) : null
416  await update($, snap, () => ({ ...next, gitAuto }))
417  await alertLimits($, limits, now)
418}
419
420// One refresh at a time. A call while one runs is not dropped: it waits for that one, then runs once
421// more (however many calls came meanwhile), so whoever awaits it reads fresh data
422let running: Promise<void> | null = null
423let queued: Promise<void> | null = null
424function refresh($: EngineInterface): Promise<void> {
425  if (running === null) {
426    running = readAndDraw($)
427      .catch(() => undefined)
428      .finally(() => {
429        running = null
430      })
431    return running
432  }
433  queued ??= running.then(() => {
434    queued = null
435    return refresh($)
436  })
437  return queued
438}
439
440// ── Timers and Clawd's mood
441
442const REFRESH_EVERY_MS = 30_000
443const MOOD_TICK_MS = 800
444// A mood animates this long, then rests on one frame, so the band is not redrawn every tick for hours
445const MOOD_ANIMATE_MS = 60_000
446const SLEEP_AFTER_MS = 10 * 60_000
447const SWEAT_AT = 95
448const BLINK_EVERY_MS = 4_500
449const BLINK_FOR_MS = 300
450
451// Picks Clawd's mood and steps the shared beat while a mood animates or Spotify plays.
452async function tickMood($: EngineInterface) {
453  const now = await $.clock.now()
454  runtime.lastActiveAt ??= now
455  const s = await read($, snap)
456  const highest = Math.max(0, ...(s?.limits ?? []).map(l => l.percent))
457  const next: Mood =
458    now < runtime.cheerUntil
459      ? 'cheer'
460      : now - runtime.lastActiveAt > SLEEP_AFTER_MS
461        ? 'sleep'
462        : highest >= SWEAT_AT
463          ? 'sweat'
464          : 'idle'
465  if (next !== (await read($, mood))) {
466    runtime.moodSince = now
467    await update($, mood, () => next)
468  }
469  const spotifyPlaying = runtime.config.spotify && (await read($, music))?.isPlaying
470  if (spotifyPlaying || (next !== 'idle' && now - runtime.moodSince < MOOD_ANIMATE_MS)) {
471    await update($, moodTick, n => n + 1)
472  }
473}
474
475async function markActive($: EngineInterface) {
476  runtime.lastActiveAt = await $.clock.now()
477  if ((await read($, mood)) === 'sleep') await update($, mood, () => 'idle')
478}
479
480// The refresh, mood and blink timers, started once per load. Every hook calls it, since a hot reload
481// starts the module over without a session.start
482let hasTimers = false
483function startTimers($: EngineInterface) {
484  if (hasTimers) return
485  hasTimers = true
486  $.clock.every(REFRESH_EVERY_MS, () => void refresh($))
487  void $.store.get(CARD_COMPACT_KEY).then(kept => update($, cardCompact, () => kept === true))
488  $.clock.every(MOOD_TICK_MS, () => void tickMood($))
489  if (runtime.config.spotify) {
490    void Promise.all([$.store.get(MUSIC_ON_KEY), $.store.get(LYRICS_ON_KEY)]).then(async ([kept, keptLyrics]) => {
491      await update($, musicOn, () => kept === true)
492      await update($, lyricsOn, () => keptLyrics !== false)
493      await readSpotify($)
494    })
495    $.clock.every(SPOTIFY_EVERY_MS, () => void readSpotify($))
496    void $.store.get(COMPACT_KEY).then(kept => update($, musicCompact, () => kept === true))
497    // Beatbot's dance: a step each tick, only while a track plays
498    $.clock.every(DANCE_EVERY_MS, () => void stepDance($))
499  }
500  $.clock.every(BLINK_EVERY_MS, () => {
501    void update($, isBlinking, () => true)
502    $.clock.after(BLINK_FOR_MS, () => void update($, isBlinking, () => false))
503  })
504}
505
506// ── Spotify: what is playing, its album art as pixels, and the controls
507
508const SPOTIFY_EVERY_MS = 5_000
509const DANCE_EVERY_MS = 250
510const COMPACT_KEY = 'spotify-compact-v1'
511const MUSIC_ON_KEY = 'spotify-on-v1'
512const LYRICS_ON_KEY = 'lyrics-on-v1'
513const CARD_COMPACT_KEY = 'card-compact-v1'
514// Asks only while Spotify runs ("is running" never launches it); fields joined by the unit separator
515const SPOTIFY_SCRIPT = `if application "Spotify" is running then
516  tell application "Spotify"
517    set s to player state as string
518    if s is "stopped" then return "stopped"
519    set t to current track
520    set sep to ASCII character 31
521    return s & sep & (name of t) & sep & (artist of t) & sep & (album of t) & sep & (duration of t) & sep & (player position as string) & sep & (artwork url of t) & sep & (id of t) & sep & (shuffling as string) & sep & (repeating as string) & sep & (sound volume as string)
522  end tell
523end if
524return ""`
525
526// Tracks heard, kept across sessions: each is added when the next one starts
527const RECENT_KEY = 'spotify-recent-v1'
528const RECENT_SHOWN = 3
529
530async function readSpotify($: EngineInterface) {
531  if (!(await read($, musicOn))) {
532    if ((await read($, music)) !== null) await update($, music, () => null)
533    return
534  }
535  const ran = await $.process.run(['osascript', '-e', SPOTIFY_SCRIPT], { timeoutMs: 4000 }).catch(() => null)
536  const playing = ran?.exitCode === 0 ? parseSpotify(ran.stdout) : null
537  const before = await read($, music)
538  if (playing === null) {
539    if (before !== null) await update($, music, () => null)
540    return
541  }
542  let recent = before?.recent ?? ((await $.store.get(RECENT_KEY)) as PlayedTrack[] | undefined) ?? []
543  // A new track: the one before goes to the top of "recently played"
544  if (before !== null && before.trackId !== playing.trackId) {
545    recent = addPlayed(recent, { trackId: before.trackId, name: before.name, artist: before.artist, at: await $.clock.now() })
546    await $.store.set(RECENT_KEY, recent)
547  }
548  // When the track was paused, kept while it stays paused (a pause seen at load counts from then)
549  const pausedAt = playing.isPlaying ? null : before && !before.isPlaying && before.pausedAt !== null ? before.pausedAt : await $.clock.now()
550  const readAt = await $.clock.now()
551  await update($, music, () => ({ ...playing, pausedAt, readAt, recent: recent.filter(t => t.trackId !== playing.trackId).slice(0, RECENT_SHOWN) }))
552  void loadLyrics($, playing)
553}
554
555// LRCLIB asks each app to name itself
556const LYRICS_AGENT = 'statusline-band (https://github.com/AsyrafHussin/claude-code-statusline)'
557// The track whose lyrics were last asked for, so each track is asked once, even when the answer failed
558let lyricsAskedFor: string | null = null
559
560// The track's synced lyrics, asked of LRCLIB once per track while the lyrics switch is on
561async function loadLyrics($: EngineInterface, m: { trackId: string; name: string; artist: string; album: string; durationMs: number }) {
562  if (!(await read($, lyricsOn)) || lyricsAskedFor === m.trackId) return
563  if ((await read($, lyrics))?.trackId === m.trackId) return
564  lyricsAskedFor = m.trackId
565  const got = await $.http.fetch(lyricsUrl(m), { headers: { 'User-Agent': LYRICS_AGENT } }).catch(() => null)
566  // No answer (offline): nothing kept, so the next track asks again; a 404 is a track LRCLIB lacks
567  if (got === null || (!got.ok && got.status !== 404)) return
568  const lines = got.ok ? parseLyrics(got.text) : []
569  if ((await read($, music))?.trackId === m.trackId) await update($, lyrics, () => ({ trackId: m.trackId, lines }))
570}
571
572// Turns the lyrics on or off, kept across sessions
573async function toggleLyrics($: EngineInterface) {
574  const isOn = !(await read($, lyricsOn))
575  await update($, lyricsOn, () => isOn)
576  await $.store.set(LYRICS_ON_KEY, isOn)
577  lyricsAskedFor = null
578  const m = await read($, music)
579  if (isOn && m !== null) await loadLyrics($, m)
580}
581
582// Steps only while Beatbot moves and shows: dancing to a track, or asleep with his Z's, and the card not
583// minimized, so the band is not redrawn four times a second for nothing
584async function stepDance($: EngineInterface) {
585  const m = await read($, music)
586  if (m === null || (await read($, musicCompact))) return
587  const isAsleep = !m.isPlaying && m.pausedAt !== null && (await $.clock.now()) - m.pausedAt >= BEATBOT_SLEEP_MS
588  if (m.isPlaying || isAsleep) await update($, danceTick, n => n + 1)
589}
590
591// The controls under the band, each one AppleScript line to Spotify; then the state is read again at once
592const SPOTIFY_COMMANDS: Record<Exclude<MusicCommand, 'search' | 'compact'>, string> = {
593  playpause: 'playpause',
594  next: 'next track',
595  previous: 'previous track',
596  shuffle: 'set shuffling to not shuffling',
597  repeat: 'set repeating to not repeating',
598  louder: 'set sound volume to ((sound volume) + 10)',
599  quieter: 'set sound volume to ((sound volume) - 10)',
600  open: 'activate',
601}
602
603async function controlSpotify($: EngineInterface, command: MusicCommand) {
604  if (command === 'search') {
605    await openSearch($)
606    return
607  }
608  // Shrinks the card to one line or brings it back, kept across sessions
609  if (command === 'compact') {
610    const isCompact = !(await read($, musicCompact))
611    await update($, musicCompact, () => isCompact)
612    await $.store.set(COMPACT_KEY, isCompact)
613    return
614  }
615  const line = SPOTIFY_COMMANDS[command]
616  await $.process.run(['osascript', '-e', `tell application "Spotify" to ${line}`], { timeoutMs: 4000 }).catch(() => null)
617  await readSpotify($)
618}
619
620// ── Spotify search: the Web API with the app's own credentials (no user sign-in), up to 10 tracks, played
621// in the Spotify app through AppleScript
622
623const SEARCH_LIMIT = 10
624// The rows the search wants: a gap, heading, the framed box, ten tracks of two lines each, the hint, and gaps
625const SEARCH_ROWS = 1 + 1 + 1 + 3 + 1 + SEARCH_LIMIT * 2 + 1 + 2
626// The app token, kept until a minute before it expires
627let spotifyToken: { value: string; until: number } | null = null
628
629// The app's client secret: the setting when it holds one, else the macOS Keychain item KEYCHAIN_SERVICE,
630// since /config leaves out settings marked sensitive. Read once per load
631const KEYCHAIN_SERVICE = 'statusline-band-spotify'
632let keychainSecret: string | undefined
633async function readSpotifySecret($: EngineInterface) {
634  if (runtime.config.spotifyClientSecret) return runtime.config.spotifyClientSecret
635  if (keychainSecret !== undefined) return keychainSecret
636  const found = await $.process
637    .run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], { timeoutMs: 5000 })
638    .catch(() => null)
639  // Kept only once found, so a secret added after a failed search is read on the next one
640  const secret = found?.exitCode === 0 ? found.stdout.trim() : ''
641  if (secret) keychainSecret = secret
642  return secret || null
643}
644
645async function readSpotifyToken($: EngineInterface) {
646  const now = await $.clock.now()
647  if (spotifyToken && now < spotifyToken.until) return spotifyToken.value
648  const id = runtime.config.spotifyClientId
649  const secret = await readSpotifySecret($)
650  if (!id || !secret) return null
651  const got = await $.http
652    .fetch('https://accounts.spotify.com/api/token', {
653      method: 'POST',
654      headers: { Authorization: basicAuth(id, secret), 'Content-Type': 'application/x-www-form-urlencoded' },
655      body: 'grant_type=client_credentials',
656    })
657    .catch(() => null)
658  if (!got?.ok) return null
659  let body: { access_token?: string; expires_in?: number }
660  try {
661    body = JSON.parse(got.text) as typeof body
662  } catch {
663    return null
664  }
665  if (!body.access_token) return null
666  spotifyToken = { value: body.access_token, until: now + ((body.expires_in ?? 3600) - 60) * 1000 }
667  return spotifyToken.value
668}
669
670async function openSearch($: EngineInterface) {
671  const id = runtime.config.spotifyClientId
672  const secret = await readSpotifySecret($)
673  if (!id || !secret) {
674    await update($, search, (): SpotifySearch => ({
675      query: '',
676      status: 'failed',
677      message: !id
678        ? 'Set spotifyClientId in /config first (your app at developer.spotify.com/dashboard).'
679        : `Add the client secret to the Keychain: security add-generic-password -U -s ${KEYCHAIN_SERVICE} -a client-secret -w`,
680      tracks: [],
681    }))
682  } else {
683    // A failure from before, such as missing settings since set, does not greet the next opening
684    await update($, search, (state): SpotifySearch => (state.status === 'failed' ? { ...state, status: 'idle', message: '' } : state))
685  }
686  await $.ui.open({ id: SEARCH_PANE, title: 'Search Spotify', focus: true, closeOnEscape: true, holdToasts: true, rows: SEARCH_ROWS, columns: 72 })
687}
688
689async function searchSpotify($: EngineInterface, query: string) {
690  const text = query.trim()
691  if (!text) return
692  await update($, search, (): SpotifySearch => ({ query: text, status: 'searching', message: '', tracks: [] }))
693  const token = await readSpotifyToken($)
694  if (token === null) {
695    await update($, search, (): SpotifySearch => ({
696      query: text,
697      status: 'failed',
698      message: 'Spotify would not give a token: check the client ID in /config and the secret in the Keychain.',
699      tracks: [],
700    }))
701    return
702  }
703  const url = `https://api.spotify.com/v1/search?type=track&limit=${SEARCH_LIMIT}&q=${encodeURIComponent(text)}`
704  const got = await $.http.fetch(url, { headers: { Authorization: `Bearer ${token}` } }).catch(() => null)
705  // A token Spotify no longer takes is dropped, so the next search asks for a new one
706  if (got?.status === 401) spotifyToken = null
707  const tracks = got?.ok ? parseSearch(got.text) : null
708  // A newer search typed meanwhile wins: this one's answer is dropped
709  if ((await read($, search)).query !== text) return
710  await update($, search, (): SpotifySearch =>
711    tracks === null
712      ? { query: text, status: 'failed', message: `The search failed${got ? ` (${got.status})` : ''}.`, tracks: [] }
713      : { query: text, status: 'found', message: '', tracks },
714  )
715}
716
717// Plays a found track in the Spotify app (launching it if it is not running: the person asked), keeping
718// the terminal in front, then closes the dialog and reads the new state
719async function playFound($: EngineInterface, track: FoundTrack) {
720  const uri = safeTrackUri(track.uri)
721  if (uri === null) return
722  // play track brings Spotify to the front, so the app that was in front (the terminal) is put back
723  await $.process
724    .run(
725      [
726        'osascript',
727        '-e', 'set frontApp to path to frontmost application as text',
728        '-e', `tell application "Spotify" to play track "${uri}"`,
729        '-e', 'delay 0.4',
730        '-e', 'tell application frontApp to activate',
731      ],
732      { timeoutMs: 8000 },
733    )
734    .catch(() => null)
735  await $.ui.close({ id: SEARCH_PANE })
736  await readSpotify($)
737}
738
739// Turns the Spotify card on or off, kept across sessions; off clears it at once
740async function toggleMusic($: EngineInterface) {
741  const isOn = !(await read($, musicOn))
742  await update($, musicOn, () => isOn)
743  await $.store.set(MUSIC_ON_KEY, isOn)
744  await readSpotify($)
745}
746
747// Shrinks the main card to one line or brings it back, kept across sessions
748async function toggleCardCompact($: EngineInterface) {
749  const isCompact = !(await read($, cardCompact))
750  await update($, cardCompact, () => isCompact)
751  await $.store.set(CARD_COMPACT_KEY, isCompact)
752}
753
754// ── What the buttons do: quick commit, compact, and the suggestion after a turn
755
756const FILES_SHOWN = 12
757const COMMIT_TIMEOUT_MS = 30_000
758
759// Commits everything in the session's repo with a message the small model writes from the diff, after
760// the person reads it and the files it takes: commit, commit and push, or type their own. It runs git
761// itself, on that press, so it costs the main model nothing. One at a time: a second press while one
762// runs does nothing
763let isCommitting = false
764async function quickCommit($: EngineInterface) {
765  if (isCommitting) return
766  isCommitting = true
767  try {
768    await commitWithMessage($)
769  } finally {
770    isCommitting = false
771  }
772}
773
774async function commitWithMessage($: EngineInterface) {
775  const root = (await read($, snap))?.git?.root
776  if (!root) {
777    $.ui.toast('Not in a git repo')
778    return
779  }
780  const git = (...args: string[]) => runGit($, root, args, COMMIT_TIMEOUT_MS)
781  const status = await git('status', '--porcelain')
782  // "XY path", or "XY old -> new" for a rename
783  const rows = (status?.stdout ?? '').split('\n').filter(row => row.trim() !== '')
784  if (rows.length === 0) {
785    $.ui.toast('Nothing to commit')
786    return
787  }
788  const paths = rows.map(row => row.slice(3).split(' -> ').pop() ?? row.slice(3))
789  const sensitive = paths.filter(isSensitivePath)
790  $.ui.toast('✎  Writing a commit message…')
791  const [diff, log] = await Promise.all([git('diff', 'HEAD'), git('log', '-5', '--format=%s')])
792  const untracked = rows.filter(row => row.startsWith('??')).map(row => row.slice(3))
793  const written = await $.model.complete({
794    model: 'haiku',
795    maxTokens: 400,
796    system:
797      'You write git commit messages. Reply with the message alone: a subject line under 72 characters in the style of the recent subjects, a blank line, then a short body saying what changed and why. No code fences, no quotes, and no line crediting anyone.',
798    prompt: [
799      `Recent subjects:\n${log?.stdout.trim() ?? ''}`,
800      untracked.length > 0 ? `New files:\n${untracked.join('\n')}` : '',
801      `Diff:\n${(diff?.stdout ?? '').slice(0, 24_000)}`,
802    ]
803      .filter(Boolean)
804      .join('\n\n'),
805  })
806  if (!written.isAnswered) {
807    $.ui.toast(`Could not write a message (${written.reason})`)
808    return
809  }
810  const proposed = cleanCommitMessage(written.text)
811  const listed = rows.slice(0, FILES_SHOWN).map(row => `  ${row}`)
812  const more = rows.length > FILES_SHOWN ? [`  …and ${rows.length - FILES_SHOWN} more`] : []
813  const warning =
814    sensitive.length > 0 ? [`⚠ May hold secrets: ${sensitive.join(', ')}. Cancel and add them to .gitignore if so.`, ''] : []
815  const COMMIT = 'Commit'
816  const PUSH = 'Commit and push'
817  const CANCEL = 'Cancel'
818  const count = `${rows.length} ${rows.length === 1 ? 'file' : 'files'}`
819  const answer = await $.ui
820    .ask([`Commit ${count} with this message? (or type your own)`, '', ...warning, ...listed, ...more, '', proposed].join('\n'), {
821      options: [COMMIT, PUSH, CANCEL],
822      header: 'Commit',
823    })
824    .catch(() => CANCEL)
825  if (answer === CANCEL) return
826  // A message the person typed goes in as they typed it; git takes it as one argument, so it needs no
827  // quoting or cleaning
828  const message = answer === COMMIT || answer === PUSH ? proposed : answer.trim()
829  if (!message) return
830  const added = await git('add', '-A')
831  const committed = added?.exitCode === 0 ? await git('commit', '-q', '-m', message) : added
832  if (committed?.exitCode !== 0) {
833    const why = (committed?.stderr || committed?.stdout || '').trim().split('\n')[0] ?? ''
834    $.ui.toast(`Commit failed${why ? `: ${why}` : ''}`)
835    return
836  }
837  if (answer === PUSH) {
838    const pushed = await git('push')
839    const why = (pushed?.stderr ?? '').trim().split('\n')[0] ?? ''
840    $.ui.toast(pushed?.exitCode === 0 ? '✓  Committed and pushed' : `Committed, but the push failed${why ? `: ${why}` : ''}`)
841  } else {
842    $.ui.toast('✓  Committed')
843  }
844  await refresh($)
845}
846
847async function compactNow($: EngineInterface) {
848  if (await read($, isCompacting)) return
849  await update($, isCompacting, () => true)
850  $.ui.toast('🗜  Compacting the conversation…')
851  try {
852    await $.session.compact()
853    await refresh($)
854  } finally {
855    await update($, isCompacting, () => false)
856  }
857}
858
859// After a turn that changed files, offers "commit and push" in the empty prompt box, for Tab to take
860async function suggestAfterTurn($: EngineInterface) {
861  await refresh($)
862  const changed = (await read($, snap))?.git?.changed ?? 0
863  if (changed > 0 && changed !== runtime.changedAtStart) {
864    await $.prompt.suggest({ text: 'commit and push' }).catch(() => undefined)
865  }
866}
867
868// ── The hooks
869
870const RUNNER_TICK_MS = 120
871const RUNNER_MAX_TICKS = 15_000
872
873// Clawd's running animation, from a prompt until its turn completes
874let runner: { cancel: () => void } | null = null
875
876export const register: Register = (on, options) => {
877  runtime.config = readConfig(options)
878
879  on('session.start', async ($, e, next) => {
880    const ran = await next(e)
881    startTimers($)
882    void refresh($)
883    if (runtime.config.spotify) {
884      await $.command.register({ name: SEARCH_PANE, description: 'Search Spotify and play a track' })
885    }
886    await $.command.register({
887      name: HISTORY_PANE,
888      description: `Show cost and tokens for each of the last ${runtime.config.rollingDays} days`,
889    })
890    return ran
891  })
892
893  on('prompt.submit', async ($, e, next) => {
894    startTimers($)
895    await markActive($)
896    runtime.changedAtStart = (await read($, snap))?.git?.changed ?? 0
897    if (runner === null) {
898      let ticks = 0
899      const timer = $.clock.every(RUNNER_TICK_MS, () => {
900        ticks += 1
901        if (ticks > RUNNER_MAX_TICKS) timer.cancel()
902        void update($, frame, n => n + 1)
903      })
904      runner = timer
905    }
906    return next(e)
907  })
908
909  on('turn.complete', async ($, e, next) => {
910    startTimers($)
911    void markActive($)
912    const ran = await next(e)
913    runner?.cancel()
914    runner = null
915    const used = ran.usage
916    if (used) {
917      const input = used.input_tokens + used.cache_read_input_tokens + used.cache_creation_input_tokens
918      await update($, lastTurn, () => ({ input, output: used.output_tokens, cached: used.cache_read_input_tokens }))
919      // The local date the last refresh read, or the clock's when there has been none yet
920      const day = (await read($, snap))?.day || (await $.process.run(['date', '+%Y-%m-%d'])).stdout.trim()
921      await recordTokens($, await $.session.id(), day, input + used.output_tokens)
922    }
923    void suggestAfterTurn($)
924    return ran
925  })
926
927  on('tool.call', async ($, e, next) => {
928    startTimers($)
929    void markActive($)
930    const ran = await next(e)
931    void refresh($)
932    return ran
933  })
934
935  // With attribution off, the trailer and footer the engine asks Claude to write are empty
936  on('attribution.text', async ($, e, next) =>
937    runtime.config.noAttribution && (e.kind === 'commit' || e.kind === 'pr') ? { text: '' } : next(e),
938  )
939
940  // The system prompt learns how git goes in this repo: what each switch asks, the plain form, and no
941  // credit lines with attribution off
942  on('prompt.compose', async ($, e, next) => {
943    const composed = await next(e)
944    const root = (await read($, snap))?.git?.root
945    if (!root) return composed
946    const text = gitGuide(root, await readGitAuto($, root), runtime.config.noAttribution)
947    return { sections: [...composed.sections, { id: 'statusline-band:git', text, scope: 'session' as const }] }
948  })
949
950  // The permission mode and effort ride on each prompt and each finished tool call, so a mode changed
951  // mid-turn is seen from the next call on
952  on('classic.UserPromptSubmit', async ($, e, next) => {
953    runtime.permissionMode = e.permission_mode
954    runtime.effort = e.effort?.level ?? runtime.effort
955    return next(e)
956  })
957  on('classic.PostToolUse', async ($, e, next) => {
958    runtime.permissionMode = e.permission_mode ?? runtime.permissionMode
959    runtime.effort = e.effort?.level ?? runtime.effort
960    return next(e)
961  })
962
963  // The switches at work. A git add, commit or push (an alias for one included) asks while its switch is
964  // on ask. On auto it runs without a prompt only in its plain form, naming its repo with -C (git -C
965  // /path add|commit -m '...'|push, joined by &&). Any other shape asks whatever the switch says: its
966  // repo or what it runs is uncertain. In plan mode nothing is allowed
967  on('classic.PreToolUse', { tool: 'Bash' }, async ($, e, next) => {
968    const first = looseGitSteps(e.command)
969    if (first.steps.length === 0 && first.aliases.length === 0) return next(e)
970    // Everything beneath runs first, your own settings hooks among them, and its ask or deny stands
971    const below = await next(e)
972    if (below.ask !== undefined || below.deny !== undefined) return below
973    const { allow: _allow, ask: _ask, deny: _deny, ...kept } = below
974    // Judged as it will run: a hook beneath may have rewritten it
975    const rewritten = below.updatedInput?.command
976    const command = typeof rewritten === 'string' ? rewritten : e.command
977    const plain = parsePlainGit(command)
978    const loose = looseGitSteps(command)
979    const cwd = await $.session.cwd()
980    const aliased = await stepsOfAliases($, plain?.dir ?? cwd, loose.aliases)
981    const steps = [...new Set([...(plain?.steps ?? loose.steps), ...aliased])]
982    const root = plain?.dir !== undefined ? await repoRoot($, plain.dir) : null
983    const auto = root !== null ? await readGitAuto($, root) : REVIEW
984    const decision = gitDecision(plain, steps, auto, runtime.permissionMode)
985    if (decision === 'pass') return below
986    if (decision === 'allow') return { ...kept, allow: true }
987    // With gitStrict off, the person trusts their own repo: a shape that is not plain (or does not name its
988    // repo) asks only when a switch of the session's repo, or the repo it names, says ask
989    if (!runtime.config.gitStrict) {
990      const trusted = root ?? (await repoRoot($, cwd))
991      const trustedAuto = trusted !== null ? await readGitAuto($, trusted) : REVIEW
992      if (steps.every(step => trustedAuto[step])) return below
993    }
994    // Under the dialog: what this step takes with it, in the repo it names or the session's
995    const shownRoot = root ?? (await repoRoot($, cwd))
996    if (shownRoot !== null) {
997      const summary = await pendingSummary($, shownRoot, steps)
998      if (summary) $.ui.notice(e.tool_use_id, summary)
999    }
1000    if (root === null || plain === null) {
1001      return {
1002        ...kept,
1003        ask: 'Confirm this git step. On auto the band only lets plain git add, commit -m and push through, each naming its repo with -C.',
1004      }
1005    }
1006    const name = root.split('/').filter(Boolean).pop() ?? root
1007    const held = steps.filter(step => !auto[step])
1008    const doing = held.map(step => (step === 'push' ? 'pushing' : 'committing')).join(' and ')
1009    const switches = held.map(step => `"${step}"`).join(' and ')
1010    return { ...kept, ask: `${name} asks before ${doing}. Switch ${switches} to auto in the band to skip this.` }
1011  })
1012
1013  on('command.run', { command: HISTORY_PANE }, async $ => {
1014    const day = (await read($, snap))?.day || (await $.process.run(['date', '+%Y-%m-%d'])).stdout.trim()
1015    await openHistory($, day)
1016    return { text: 'Usage history opened.' }
1017  })
1018
1019  on('ui.render', { component: 'Pane', requestId: HISTORY_PANE }, async ($, e) => {
1020    const days = await read($, history)
1021    const span = runtime.config.rollingDays
1022    return drawHistoryPane($.ui.resolve(e), {
1023      days,
1024      span,
1025      rows: e.viewport?.rows ?? 40,
1026      onCopy: () =>
1027        void $.ui
1028          .copy({ text: historyText(days, span) })
1029          .then(copied => $.ui.toast(copied.isCopied ? '✓  Copied' : 'Could not copy')),
1030    })
1031  })
1032
1033  on('command.run', { command: SEARCH_PANE }, async $ => {
1034    await openSearch($)
1035    return { text: 'Spotify search opened.' }
1036  })
1037
1038  on('ui.render', { component: 'Pane', requestId: SEARCH_PANE }, async ($, e) => {
1039    const state = await read($, search)
1040    return drawSearchPane($.ui.resolve(e), {
1041      ...state,
1042      playingUri: (await read($, music))?.trackId ?? null,
1043      onSearch: query => void searchSpotify($, query),
1044      onPlay: track => void playFound($, track),
1045    })
1046  })
1047
1048  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1049    const s = await read($, snap)
1050    if (e.props.hasSurvey || s === null) return next(e)
1051    const isWorking = e.props.isWorking
1052    // A playing track's position counts on between Spotify's reads, so the bar, the time and the lyric move
1053    // with each of Beatbot's steps
1054    const kept = runtime.config.spotify && (await read($, musicOn)) ? await read($, music) : null
1055    const playing = kept && { ...kept, positionMs: livePosition(kept, await $.clock.now()) }
1056    const words = await read($, lyrics)
1057    const isLyricsOn = await read($, lyricsOn)
1058    const lyric = playing && isLyricsOn && words?.trackId === playing.trackId && words.lines.length > 0 ? lyricAt(words.lines, playing.positionMs) : null
1059    return drawBand($.ui.resolve(e), {
1060      s,
1061      config: runtime.config,
1062      effort: runtime.effort,
1063      isWorking,
1064      bodyColumns: e.props.bodyColumns,
1065      isCompacting: await read($, isCompacting),
1066      turn: await read($, lastTurn),
1067      feeling: isWorking ? 'idle' : await read($, mood),
1068      beat: await read($, moodTick),
1069      danceTick: await read($, danceTick),
1070      tick: isWorking ? await read($, frame) : null,
1071      isBlinking: await read($, isBlinking),
1072      music: playing,
1073      lyric,
1074      isLyricsOn: runtime.config.spotify && (await read($, musicOn)) ? isLyricsOn : null,
1075      onLyricsSwitch: () => void toggleLyrics($),
1076      isMusicCompact: await read($, musicCompact),
1077      isMusicOn: runtime.config.spotify ? await read($, musicOn) : null,
1078      onMusicSwitch: () => void toggleMusic($),
1079      isCardCompact: await read($, cardCompact),
1080      onCardCompact: () => void toggleCardCompact($),
1081      canDrawArt: e.surface === 'terminal',
1082      logoFile: e.surface === 'terminal' ? `${$.plugin.root}/assets/spotify.png` : undefined,
1083      actions: ACTIONS,
1084      onHistory: () => void openHistory($, s.day),
1085      onCompact: () => void compactNow($),
1086      onAction: action =>
1087        void (action.prompt === undefined ? quickCommit($) : $.prompt.submit({ text: action.prompt, asUser: true })),
1088      onSwitch: step => void toggleGitAuto($, step),
1089      onMusic: command => void controlSpotify($, command),
1090    })
1091  })
1092}
1093
hooks/band.tsx 699 lines
1// The band above the prompt: Clawd beside a card with the project, the rate limits, context, cost and
2// git, and a row of actions under it
3import type { Elements } from 'claude-code'
4
5import {
6  BAR_CELLS,
7  WINDOW_MS,
8  barCells,
9  formatClock,
10  formatDuration,
11  formatReset,
12  formatTokens,
13  formatUsd,
14  CLEAR,
15  perHour,
16  pixelsToCells,
17  withGlyphs,
18  runsOutIn,
19  shirtText,
20} from './logic'
21import type { GitStep } from './logic'
22import {
23  BEATBOT_PAD,
24  BEATBOT_SLEEP_MS,
25  BEFORE_BAR,
26  BEFORE_TIME,
27  CONTROL_PIECES,
28  CLAWD_WIDTH,
29  MAIN_BOTTOM_TAIL,
30  MUSIC_BOTTOM_TAIL,
31  bottomFill,
32  musicRow,
33} from './layout'
34import { BLINK_FRAME, DANCE_FRAMES, LOGO_COLUMNS, LOGO_ROWS, PALETTE, STANDING } from './spotify-logo'
35import { COLORS, LIMIT_LABELS } from './state'
36import type { Config, GitAuto } from './state'
37import type { Limit, Mood, Music, Snapshot, TurnTokens } from '../types'
38
39// The elements the band draws with, as $.ui.resolve gives them; Raster only on the terminal
40export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & Partial<Pick<Elements['terminal'], 'Raster' | 'Image' | 'Input'>>
41
42const SPOTIFY_GREEN = '#1db954'
43// Encode each pose once; rendering only selects a frame from the shared animation beat.
44const logoCells = (grid: string[]) =>
45  grid.length > 0
46    ? pixelsToCells(
47        { width: LOGO_COLUMNS, height: LOGO_ROWS * 2, pixels: grid.join('').split('').map(c => PALETTE[c] ?? CLEAR) },
48        LOGO_COLUMNS,
49        LOGO_ROWS,
50      )
51    : null
52const LOGO_CELLS = logoCells(STANDING)
53const DANCE_CELLS = DANCE_FRAMES.map(logoCells)
54const BLINK_CELLS = logoCells(BLINK_FRAME)
55// Asleep: eyes shut, with a Z and a z in the free column on his right, trading places each step
56const SLEEP_STEP = 4
57const Z_COLOR = 0xa1a1aa
58const SLEEP_CELLS = BLINK_CELLS
59  ? [
60      withGlyphs(BLINK_CELLS, LOGO_COLUMNS, [
61        { column: LOGO_COLUMNS - 1, row: 0, char: 'Z', color: Z_COLOR },
62        { column: LOGO_COLUMNS - 1, row: 1, char: 'z', color: Z_COLOR },
63      ]),
64      withGlyphs(BLINK_CELLS, LOGO_COLUMNS, [
65        { column: LOGO_COLUMNS - 1, row: 0, char: 'z', color: Z_COLOR },
66        { column: LOGO_COLUMNS - 1, row: 1, char: 'Z', color: Z_COLOR },
67      ]),
68    ]
69  : []
70
71export type MusicCommand = 'playpause' | 'next' | 'previous' | 'shuffle' | 'repeat' | 'louder' | 'quieter' | 'open' | 'search' | 'compact'
72
73// A button under the card: most send a prompt as if typed; one with no prompt runs itself (quick commit)
74export type Action = { key: string; label: string; prompt?: string }
75
76// What the band needs to draw: the snapshot and the moment's state, and what each press does
77export type BandView = {
78  s: Snapshot
79  config: Config
80  effort?: string
81  isWorking: boolean
82  bodyColumns: number
83  isCompacting: boolean
84  turn: TurnTokens | null
85  feeling: Mood
86  beat: number
87  // Beatbot's dance step, counting while a track plays
88  danceTick: number
89  // The running animation's frame while Claude works, else null
90  tick: number | null
91  isBlinking: boolean
92  // What Spotify plays, null when it plays nothing (or the setting is off)
93  music: Music | null
94  // The Spotify card shrunk to one line
95  isMusicCompact: boolean
96  // The Spotify switch beside the repo's: on or off, null when the setting is off and there is no switch
97  isMusicOn: boolean | null
98  onMusicSwitch: () => void
99  // The line sung now and the next, inside the Spotify card; null when there are no synced lyrics
100  lyric: { current: string; next: string } | null
101  // The lyrics switch, beside Spotify's while it is on; null otherwise
102  isLyricsOn: boolean | null
103  onLyricsSwitch: () => void
104  // The main card shrunk to one line, and the button that flips it
105  isCardCompact: boolean
106  onCardCompact: () => void
107  canDrawArt: boolean
108  // The Spotify logo, a PNG the plugin ships; absent where pictures cannot be drawn
109  logoFile?: string
110  actions: Action[]
111  onHistory: () => void
112  onCompact: () => void
113  onAction: (action: Action) => void
114  onSwitch: (step: GitStep) => void
115  onMusic: (command: MusicCommand) => void
116}
117
118const RINGS = ['○', '◔', '◑', '◕', '●']
119const COMPACT_AT = 80
120// Room left for the band's own collapse mark ([-], three columns) on the right, with one to spare
121const ENGINE_MARK_WIDTH = 4
122// Columns of card left and right of the frame: a backgrounded Box paints over its own edge columns
123// below its first row, so the frame sits one in
124const CARD_INSET = 1
125
126// Clawd, the Claude Code mascot, in a shirt with the person's initials: standing when idle, running while
127// Claude works (arms and legs trade places each step, dust kicked up behind), and by mood otherwise
128const CLAWD_COLOR = '#d97757'
129const DUST_COLOR = '#78716c'
130const SHIRT_COLOR = '#2563eb'
131const SWEAT_COLOR = '#60a5fa'
132const SPARK_COLOR = '#fde047'
133const SLEEP_COLOR = '#a1a1aa'
134// Where the actions under the card start, and the Spotify text with them: past Clawd and the frame's edge
135const INDENT = CLAWD_WIDTH + CARD_INSET + 3
136const CLAWD_HEAD = ' ▐▛███▜▌ '
137const CLAWD_HEAD_BLINK = ' ▐█████▌ '
138const CLAWD_IDLE = { arms: ['▝', '▘'], legs: '  ▘▘ ▝▝  ' }
139const CLAWD_RUN = [
140  { arms: ['▝', '▘'], legs: ' ▘ ▘ ▝ ▝ ' },
141  { arms: ['▗', '▖'], legs: '  ▝▘ ▘▝  ' },
142]
143const CLAWD_CHEER = [
144  { arms: ['▘', '▝'], legs: '  ▘▘ ▝▝  ' },
145  { arms: ['▘', '▝'], legs: ' ▘ ▘ ▝ ▝ ' },
146]
147const DUST = [['  ', '  ', ' ·'], ['  ', ' ·', '∙ '], ['  ', '  ', '· ']]
148
149const toneHex = (pct: number) => (pct >= 80 ? COLORS.hot : pct >= 50 ? COLORS.warn : COLORS.ok)
150const ring = (pct: number) => RINGS[Math.min(4, Math.round((pct / 100) * 4))]
151
152// The two columns left of Clawd: dust while running, else a falling drop (an emoji, two wide), z's or
153// sparkles by mood
154const clawdSide = (feeling: Mood, beat: number, dust: string[] | undefined): [string, string][] => {
155  if (feeling === 'sweat') return [0, 1, 2].map(row => [row === beat % 3 ? '💧' : '  ', SWEAT_COLOR])
156  if (feeling === 'sleep') {
157    return beat % 2 === 0
158      ? [[' Z', SLEEP_COLOR], ['z ', SLEEP_COLOR], ['  ', SLEEP_COLOR]]
159      : [['Z ', SLEEP_COLOR], [' z', SLEEP_COLOR], ['  ', SLEEP_COLOR]]
160  }
161  if (feeling === 'cheer') {
162    return beat % 2 === 0
163      ? [['✦ ', SPARK_COLOR], ['  ', SPARK_COLOR], [' ✧', SPARK_COLOR]]
164      : [[' ✧', SPARK_COLOR], ['✦ ', SPARK_COLOR], ['  ', SPARK_COLOR]]
165  }
166  return [0, 1, 2].map(row => [dust?.[row] ?? '  ', DUST_COLOR])
167}
168
169export function drawBand({ Box, Button, Text, Raster, Image }: Kit, view: BandView) {
170  const { s, config, turn, feeling, beat, tick, isWorking } = view
171
172  // Pieces of text whose widths are known, so the frame can be drawn to fit them
173  type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
174  const width = (segs: Seg[]) => segs.reduce((n, seg) => n + [...seg.text].length, 0)
175  const draw = (segs: Seg[]) =>
176    segs.map(seg => (
177      <Text color={seg.color} bold={seg.bold} dimColor={seg.dim}>{seg.text}</Text>
178    ))
179
180  const border = isWorking ? COLORS.borderBusy : feeling === 'sweat' ? COLORS.hot : COLORS.border
181  const line = (text: string): Seg => ({ text, color: border })
182  const total = Math.max(40, view.bodyColumns - 2 - CLAWD_WIDTH - ENGINE_MARK_WIDTH - CARD_INSET * 2)
183
184  // Top edge: the project as the panel's title, then the model and its effort
185  const g = s.git
186  const sep: Seg = { text: ' · ', dim: true }
187  const diffSegs = (added: number, removed: number): Seg[] => [
188    ...(added > 0 ? [{ text: ` +${added}`, color: COLORS.ok }] : []),
189    ...(removed > 0 ? [{ text: ` −${removed}`, color: COLORS.hot }] : []),
190  ]
191  // Each git fact is its own group: uncommitted work, commits to push and pull, then the last commit
192  const gitGroups: Seg[][] =
193    g === null
194      ? []
195      : [
196          ...(g.changed > 0
197            ? [[{ text: `✎ ${g.changed} ${g.changed === 1 ? 'file' : 'files'}`, color: COLORS.warn }, ...diffSegs(g.added, g.removed)]]
198            : []),
199          ...(g.ahead > 0
200            ? [
201                [
202                  { text: `↑ ${g.ahead} ${g.ahead === 1 ? 'commit' : 'commits'}`, color: COLORS.warn },
203                  ...diffSegs(g.unpushedAdded, g.unpushedRemoved),
204                ],
205              ]
206            : []),
207          ...(g.behind > 0 ? [[{ text: `↓ ${g.behind} behind`, color: COLORS.hot }]] : []),
208          ...(g.stashed > 0 ? [[{ text: `≡ ${g.stashed} stash`, color: COLORS.model }]] : []),
209          ...(g.changed === 0 && g.hasUpstream && g.ahead === 0 && g.behind === 0
210            ? [[{ text: '✓ synced', color: COLORS.ok }]]
211            : []),
212          ...(g.lastCommitAt !== null
213            ? [[{ text: `committed ${s.now - g.lastCommitAt < 60_000 ? 'just now' : `${formatReset(s.now - g.lastCommitAt)} ago`}`, dim: true }]]
214            : []),
215          ...(!g.hasUpstream ? [[{ text: 'no upstream', color: COLORS.warn }]] : []),
216        ]
217  const effort = view.effort ?? s.effort
218  const title: Seg[] = [
219    line('╭─ '),
220    { text: `◆ ${s.folder}`, color: COLORS.folder, bold: true },
221    ...(s.subdir ? [{ text: s.subdir, dim: true }] : []),
222    ...(g === null ? [] : [line(' ─ '), { text: g.branch, color: COLORS.branch }]),
223    line(' ─ '),
224    { text: `✦ ${s.model}`, color: COLORS.model },
225    ...(effort ? [{ text: ` · ${effort}`, dim: true }] : []),
226    ...(s.plan ? [{ text: ' · ', dim: true }, { text: s.plan, color: CLAWD_COLOR, bold: true }] : []),
227    { text: ' ' },
228  ]
229  // Top right: what the session, today and the history window have cost, each with its tokens
230  const hours = (s.now - s.startedAt) / 3_600_000
231  const cost: Seg[] = []
232  const costShort: Seg[] = []
233  // A snapshot kept from before a reload may predate the token counts
234  const counted = (s.tokens as Snapshot['tokens'] | undefined) ?? { session: 0, today: 0, rolling: 0 }
235  if (s.costUsd !== null) {
236    const tokens = (n: number): Seg[] => (n > 0 ? [{ text: ` ${formatTokens(n)}`, color: COLORS.model }] : [])
237    cost.push(
238      { text: ' ' },
239      { text: formatUsd(s.costUsd), color: COLORS.ok, bold: true },
240      ...tokens(counted.session),
241      { text: ' session', dim: true },
242    )
243    costShort.push(...cost, { text: ' ' })
244    if (hours > 0.05) cost.push({ text: ` ${formatUsd(s.costUsd / hours)}/h`, dim: true })
245    const addTotal = (usd: number, n: number) =>
246      cost.push({ text: ' · ', dim: true }, { text: formatUsd(usd), color: COLORS.ok }, ...tokens(n), { text: ' ' })
247    // Today only when it adds to the session; the history window always, even while it matches today
248    if (s.todayUsd !== null && s.todayUsd - s.costUsd >= 0.01) {
249      addTotal(s.todayUsd, counted.today)
250      cost.push({ text: 'today', dim: true })
251    }
252    // The window's label is a button that opens the history pane, so it is drawn apart
253    if (s.rollingUsd !== null) addTotal(s.rollingUsd, counted.rolling)
254  }
255  const historyLabel = `${config.rollingDays}d`
256  const hasHistory = s.costUsd !== null && s.rollingUsd !== null
257  const costWidth = width(cost) + (hasHistory ? historyLabel.length : 0) + 1
258  // When the whole line does not fit beside the title, the session cost alone still shows
259  const fits = (n: number) => width(title) + n + 3 <= total
260  const showFull = cost.length > 0 && fits(costWidth)
261  const right = showFull ? cost : fits(width(costShort)) ? costShort : []
262  const rightWidth = showFull ? costWidth : width(right)
263  const topLeft = [...title, line('─'.repeat(Math.max(1, total - width(title) - rightWidth - 2))), ...right]
264  const topRight = [...(showFull ? [{ text: ' ' }] : []), line('─╮')]
265
266  // Bottom edge: running subagents and git on the left; the last reply's tokens, how long the session has
267  // run and the time on the right
268  const running = (s.agents as number | undefined) ?? 0
269  const groups: Seg[][] = [
270    ...(running > 0 ? [[{ text: `↻ ${running} ${running === 1 ? 'agent' : 'agents'}`, color: COLORS.model }]] : []),
271    ...gitGroups,
272  ]
273  const changes: Seg[] =
274    groups.length === 0 ? [] : [{ text: ' ' }, ...groups.flatMap((group, i) => (i === 0 ? group : [sep, ...group])), { text: ' ' }]
275  const clock: Seg[] = [
276    { text: ' ' },
277    // The last turn's tokens, its responses summed: read in (cache included) and written out
278    ...(turn
279      ? [
280          { text: 'in ', dim: true },
281          { text: formatTokens(turn.input), color: COLORS.model },
282          ...(turn.input > 0 && turn.cached !== undefined
283            ? [{ text: ` (${Math.floor((turn.cached / turn.input) * 100)}% cache)`, dim: true }]
284            : []),
285          { text: ' · out ', dim: true },
286          { text: formatTokens(turn.output), color: COLORS.model },
287          { text: ' · ', dim: true },
288        ]
289      : []),
290    { text: `session ${formatDuration(s.now - s.startedAt)}`, dim: true },
291    { text: ' · ', dim: true },
292    { text: s.time, dim: true },
293    { text: ' ' },
294  ]
295  // On its right, after the time, the button that shrinks the card to one line
296  const [mainDot = '', minimizeLabel = '', mainCorner = ''] = MAIN_BOTTOM_TAIL
297  const fill = bottomFill(total, width(changes) + width(clock), MAIN_BOTTOM_TAIL)
298  const bottom = [line('╰─'), ...changes, line('─'.repeat(fill)), ...clock, { text: mainDot, dim: true }]
299
300  // Shrunk: one line from the band's left edge, in place of the card and Clawd: the repo and branch, the model, each limit and the
301  // context as a percent, the first git fact, and the session cost, with the button that brings it back
302  const compactSegs: Seg[] = [
303    { text: `◆ ${s.folder}`, color: COLORS.folder, bold: true },
304    ...(g === null ? [] : [{ text: ' ' }, { text: g.branch, color: COLORS.branch }]),
305    sep,
306    { text: `✦ ${s.model}`, color: COLORS.model },
307    ...s.limits.flatMap((l): Seg[] => [
308      sep,
309      { text: `${ring(l.percent)} `, color: toneHex(l.percent) },
310      { text: `${Math.round(l.percent)}%`, bold: true, color: l.percent >= 50 ? toneHex(l.percent) : undefined },
311      { text: ` ${LIMIT_LABELS[l.kind] ?? l.kind}`, dim: true },
312    ]),
313    ...(s.context
314      ? [
315          sep,
316          { text: 'ctx ', dim: true },
317          { text: `${Math.round(s.context.percent)}%`, bold: true, color: s.context.percent >= 50 ? toneHex(s.context.percent) : undefined },
318        ]
319      : []),
320    ...(gitGroups[0] ? [sep, ...gitGroups[0]] : []),
321    ...(s.costUsd !== null ? [sep, { text: formatUsd(s.costUsd), color: COLORS.ok, bold: true }] : []),
322  ]
323
324  // Clawd
325  const step = tick === null ? 0 : Math.floor(tick / 2)
326  const pose =
327    tick !== null
328      ? CLAWD_RUN[step % CLAWD_RUN.length] ?? CLAWD_IDLE
329      : feeling === 'cheer'
330        ? CLAWD_CHEER[beat % CLAWD_CHEER.length] ?? CLAWD_IDLE
331        : CLAWD_IDLE
332  const blink = tick === null && (feeling === 'sleep' || view.isBlinking)
333  const side = clawdSide(feeling, beat, tick === null ? undefined : DUST[step % DUST.length])
334  const puff = (row: number) => <Text color={side[row]?.[1]}>{side[row]?.[0] ?? '  '}</Text>
335  const clawd = (
336    <Box flexDirection="column" width={CLAWD_WIDTH} flexShrink={0}>
337      <Text>
338        {puff(0)}
339        <Text color={CLAWD_COLOR}>{blink ? CLAWD_HEAD_BLINK : CLAWD_HEAD}</Text>
340      </Text>
341      <Text>
342        {puff(1)}
343        <Text color={CLAWD_COLOR}>{pose.arms[0]}</Text>
344        <Text color={SHIRT_COLOR}>▜</Text>
345        <Text backgroundColor={SHIRT_COLOR} color="#ffffff" bold>{shirtText(config.initials)}</Text>
346        <Text color={SHIRT_COLOR}>▛</Text>
347        <Text color={CLAWD_COLOR}>{pose.arms[1]}</Text>
348      </Text>
349      <Text>
350        {puff(2)}
351        <Text color={CLAWD_COLOR}>{pose.legs}</Text>
352      </Text>
353    </Box>
354  )
355
356  // Middle: one block per window, side by side between thin dividers
357  const stat = (pct: number, label: string, extra: Seg[] = []): Seg[] => [
358    { text: `${ring(pct)} `, color: toneHex(pct) },
359    { text: `${Math.round(pct)}%`, bold: true, color: pct >= 50 ? toneHex(pct) : undefined },
360    { text: ` ${label}`, dim: true },
361    ...extra,
362  ]
363  // A limit's card, shown while the pointer is over it: the pace across the window and lately
364  const paceCard = (l: Limit, resetMs: number, out: number | undefined): Seg[] | undefined => {
365    const windowMs = WINDOW_MS[l.kind]
366    const elapsed = windowMs === undefined ? 0 : windowMs - resetMs
367    if (elapsed <= 0) return undefined
368    return [
369      { text: ' pace ', dim: true },
370      { text: perHour(l.percent / elapsed) },
371      { text: ' this window', dim: true },
372      { text: ' · ', dim: true },
373      ...(l.recentRate !== undefined
374        ? [{ text: perHour(l.recentRate) }, { text: ' last 3h', dim: true }]
375        : [{ text: 'last 3h not read yet', dim: true }]),
376      ...(out !== undefined
377        ? [{ text: ` → out ~${formatReset(out)}, ${formatReset(resetMs - out)} before reset `, color: COLORS.hot }]
378        : [{ text: ' → lasts until reset ', color: COLORS.ok }]),
379    ]
380  }
381  type Block = { key: string; segs: Seg[]; card?: Seg[] }
382  const stats: Block[] = [
383    ...s.limits.map((l): Block => {
384      const resetMs = l.resetsAt ? Date.parse(l.resetsAt) - s.now : 0
385      const name = LIMIT_LABELS[l.kind] ?? l.kind
386      if (resetMs <= 0) return { key: l.kind, segs: stat(l.percent, name) }
387      const out = runsOutIn(l.kind, l.percent, resetMs, l.recentRate)
388      const segs = stat(l.percent, `${name} · reset ${formatReset(resetMs)}`, [
389        ...(l.resetsOn ? [{ text: ` (${l.resetsOn})`, dim: true }] : []),
390        ...(out !== undefined ? [{ text: ` ▲ out ~${formatReset(out)}`, color: COLORS.hot, bold: true }] : []),
391      ])
392      return { key: l.kind, segs, card: paceCard(l, resetMs, out) }
393    }),
394    ...(s.context
395      ? [
396          {
397            key: 'ctx',
398            segs: [
399              { text: 'ctx ', dim: true },
400              { text: '━'.repeat(barCells(s.context.percent)), color: toneHex(s.context.percent) },
401              { text: '━'.repeat(BAR_CELLS - barCells(s.context.percent)), dim: true },
402              {
403                text: ` ${Math.round(s.context.percent)}%`,
404                bold: true,
405                color: s.context.percent >= 50 ? toneHex(s.context.percent) : undefined,
406              },
407              { text: ` ${formatTokens(s.context.tokens)}/${formatTokens(s.context.window)}`, dim: true },
408            ],
409          },
410        ]
411      : []),
412  ]
413
414  // Drop the last blocks first when the terminal is too narrow for one row
415  const divider: Seg = { text: '   │   ', color: border }
416  const room = total - 8
417  while (stats.length > 1 && stats.reduce((n, st) => n + width(st.segs) + width([divider]), 0) > room) stats.pop()
418
419  const ctxFull = s.context !== null && s.context.percent >= COMPACT_AT
420  // A snapshot kept from before a reload may predate the switches
421  const gitAuto = (s.gitAuto as GitAuto | null | undefined) ?? null
422  // A side of the frame, one "│" per row of the stats and their padding
423  const edge = (key: string) => (
424    <Box flexDirection="column">
425      {Array.from({ length: config.padRows * 2 + 1 }, (_, i) => (
426        <Text key={`${key}${i}`} wrap="truncate">{draw([line('│')])}</Text>
427      ))}
428    </Box>
429  )
430  // Spotify, in a card of its own framed in Spotify green, drawn like the main one and as wide, with Beatbot
431  // dancing under Clawd.
432  // Its top edge holds the state and the track; inside, the controls, progress with the time, and
433  // shuffle, repeat and volume; its bottom edge, the track heard before this one
434  const drawMusic = (m: Music) => {
435    // Beatbot dances through his frames while a track plays, stands (blinking now and then) while not,
436    // and falls asleep, Z's rising beside him, once the track has been paused BEATBOT_SLEEP_MS
437    const isAsleep = !m.isPlaying && m.pausedAt !== null && s.now - m.pausedAt >= BEATBOT_SLEEP_MS
438    const mascotCells = m.isPlaying
439      ? DANCE_CELLS[view.danceTick % DANCE_CELLS.length] ?? LOGO_CELLS
440      : isAsleep
441        ? SLEEP_CELLS[Math.floor(view.danceTick / SLEEP_STEP) % SLEEP_CELLS.length] ?? BLINK_CELLS
442        : view.isBlinking
443          ? BLINK_CELLS
444          : LOGO_CELLS
445    const cardWidth = total
446    // ▶ is one column narrower than ❚❚: the column goes after ▶▶, so the controls sit evenly and
447    // nothing after them moves when the track plays or pauses
448    const pausePad = m.isPlaying ? '' : ' '
449    const time = `${formatClock(m.positionMs)} / ${formatClock(m.durationMs)}`
450    const last = m.recent[0]
451    // Spotify's card is framed in Spotify green
452    const line = (text: string): Seg => ({ text, color: SPOTIFY_GREEN })
453
454    // Top edge, the track cut to fit
455    const head: Seg[] = [
456      line('╭─ '),
457      { text: 'Spotify', color: SPOTIFY_GREEN, bold: true },
458      { text: m.isPlaying ? ' · playing' : ' · paused', dim: true },
459      line(' ─ '),
460    ]
461    const track = [m.name, m.artist ? ` — ${m.artist}` : '', m.album ? ` · ${m.album}` : '']
462    const roomForTrack = Math.max(0, cardWidth - width(head) - 4)
463    const trackSegs: Seg[] = []
464    let used = 0
465    track.forEach((part, i) => {
466      const chars = [...part]
467      const take = Math.min(chars.length, Math.max(0, roomForTrack - used))
468      if (take <= 0) return
469      const text = take < chars.length ? `${chars.slice(0, Math.max(0, take - 1)).join('')}…` : part
470      used += [...text].length
471      trackSegs.push(i === 0 ? { text, bold: true } : i === 1 ? { text } : { text, dim: true })
472    })
473    const topMusic = [...head, ...trackSegs, { text: ' ' }]
474    const topLine = [...topMusic, line('─'.repeat(Math.max(1, cardWidth - width(topMusic) - 2))), line('─╮')]
475
476    // Bottom edge: the track heard before this one
477    const lastSegs: Seg[] = last
478      ? [{ text: ' ' }, { text: `last: ${last.name} — ${last.artist} · ${s.now - last.at < 60_000 ? 'just now' : `${formatReset(s.now - last.at)} ago`}`, dim: true }, { text: ' ' }]
479      : []
480    // ...and on its right, the button that shrinks the card to one line
481    const [musicGap = '', musicMinimize = '', musicCorner = ''] = MUSIC_BOTTOM_TAIL
482    const bottomLine = [line('╰─'), ...lastSegs, line('─'.repeat(bottomFill(cardWidth, width(lastSegs), MUSIC_BOTTOM_TAIL))), { text: musicGap }]
483
484    // Shrunk: one line from the band's left edge, no frame and no Beatbot, from the play controls to the track and the time,
485    // with the button that brings the card back
486    if (view.isMusicCompact) {
487      return (
488        <Box width={INDENT + total - 4} justifyContent="space-between">
489          <Box flexShrink={1}>
490            <Text color={SPOTIFY_GREEN} bold>{'♪ '}</Text>
491            <Button key="music-previous" plain label="◀◀" onPress={() => view.onMusic('previous')} />
492            <Text>{' '}</Text>
493            <Button key="music-play" plain label={m.isPlaying ? '❚❚' : '▶'} onPress={() => view.onMusic('playpause')} />
494            <Text>{' '}</Text>
495            <Button key="music-next" plain label="▶▶" onPress={() => view.onMusic('next')} />
496            <Text wrap="truncate">
497              <Text bold>{`${pausePad}  ${m.name}`}</Text>
498              <Text>{m.artist ? ` — ${m.artist}` : ''}</Text>
499              <Text dimColor>{`  ${time}`}</Text>
500            </Text>
501          </Box>
502          <Box flexShrink={0}>
503            <Text>{'  '}</Text>
504            <Button key="music-compact" plain dimColor label="expand" onPress={() => view.onMusic('compact')} />
505          </Box>
506        </Box>
507      )
508    }
509
510    // Inside: the controls, progress and the time, then shuffle, repeat and volume on the right. As the
511    // card narrows, the toggles go first, then the progress bar, so nothing wraps or runs together
512    const toggles = `shuffle · repeat · vol − ${m.volume}% +`
513    const { hasToggles, hasBar, hasTime, bar: cells } = musicRow(cardWidth, time.length, toggles.length)
514    // The gaps between the controls, as layout.ts counts them
515    const [, controlGap = '', , , , searchGap = ''] = CONTROL_PIECES
516    const filled = m.durationMs > 0 ? Math.min(cells, Math.round((m.positionMs / m.durationMs) * cells)) : 0
517    const toggle = (key: MusicCommand, label: string, isOn: boolean) => (
518      <Button key={`music-${key}`} plain dimColor={!isOn} label={label} onPress={() => view.onMusic(key)} />
519    )
520
521    return (
522      <Box>
523        {/* Under Clawd, Beatbot dances while Spotify plays; an empty grid falls back to the PNG
524            as an Image, which kitty and Ghostty draw and other terminals show as its alt, the word Spotify */}
525        <Box width={CLAWD_WIDTH} flexShrink={0} paddingLeft={BEATBOT_PAD}>
526          {mascotCells && view.canDrawArt && Raster ? (
527            <Raster key="spotify-logo" columns={LOGO_COLUMNS} rows={LOGO_ROWS} cells={mascotCells} />
528          ) : view.logoFile && Image ? (
529            <Image key="spotify-logo" source={{ file: view.logoFile, format: 'png' }} columns={LOGO_COLUMNS} rows={LOGO_ROWS} alt="Spotify" />
530          ) : null}
531        </Box>
532        <Box flexDirection="column" backgroundColor={card} paddingX={CARD_INSET}>
533          <Text wrap="truncate">{draw(topLine)}</Text>
534          <Box width={cardWidth}>
535            <Text wrap="truncate">{draw([line('│')])}</Text>
536            <Box flexGrow={1} paddingX={2} justifyContent="space-between">
537              <Box flexShrink={0}>
538                <Button key="music-previous" plain label="◀◀" onPress={() => view.onMusic('previous')} />
539                <Text>{controlGap}</Text>
540                <Button key="music-play" plain label={m.isPlaying ? '❚❚' : '▶'} onPress={() => view.onMusic('playpause')} />
541                <Text>{controlGap}</Text>
542                <Button key="music-next" plain label="▶▶" onPress={() => view.onMusic('next')} />
543                <Text>{`${pausePad}${searchGap}`}</Text>
544                <Button key="music-search" plain dimColor label="search" onPress={() => view.onMusic('search')} />
545                {hasTime ? <Text>{BEFORE_BAR}</Text> : null}
546                {hasTime ? (
547                  <Text wrap="truncate">
548                    <Text color={SPOTIFY_GREEN}>{'━'.repeat(filled)}</Text>
549                    <Text dimColor>{'━'.repeat(cells - filled)}</Text>
550                    <Text dimColor>{`${hasBar ? BEFORE_TIME : ''}${time}`}</Text>
551                  </Text>
552                ) : null}
553              </Box>
554              <Box flexShrink={0} display={hasToggles ? 'flex' : 'none'}>
555                {toggle('shuffle', 'shuffle', m.isShuffling)}
556                <Text dimColor>{' · '}</Text>
557                {toggle('repeat', 'repeat', m.isRepeating)}
558                <Text dimColor>{' · vol '}</Text>
559                <Button key="music-quieter" plain dimColor label="−" onPress={() => view.onMusic('quieter')} />
560                <Text>{` ${m.volume}% `}</Text>
561                <Button key="music-louder" plain dimColor label="+" onPress={() => view.onMusic('louder')} />
562              </Box>
563            </Box>
564            <Text wrap="truncate">{draw([line('│')])}</Text>
565          </Box>
566          {/* The lyrics, under the controls: the line sung now, bright, and the next one dim */}
567          {view.lyric &&
568            [
569              { key: 'lyric-now', text: view.lyric.current || '♪', isNow: true },
570              { key: 'lyric-next', text: view.lyric.next, isNow: false },
571            ].map(row => (
572              <Box key={row.key} width={cardWidth}>
573                <Text wrap="truncate">{draw([line('│')])}</Text>
574                <Box flexGrow={1} paddingX={2}>
575                  <Text wrap="truncate" bold={row.isNow} dimColor={!row.isNow}>{row.text || ' '}</Text>
576                </Box>
577                <Text wrap="truncate">{draw([line('│')])}</Text>
578              </Box>
579            ))}
580          <Box>
581            <Text wrap="truncate">{draw(bottomLine)}</Text>
582            <Button key="music-compact" plain dimColor label={musicMinimize} onPress={() => view.onMusic('compact')} />
583            <Text>{draw([line(musicCorner)])}</Text>
584          </Box>
585        </Box>
586      </Box>
587    )
588  }
589
590  const card = config.card || undefined
591
592  return (
593    // The whole band on the card color (none when cardColor is empty); its own edge columns are padding
594    <Box paddingX={1} paddingY={1} flexDirection="column" backgroundColor={card}>
595      {view.isCardCompact ? (
596        <Box width={INDENT + total - 4} justifyContent="space-between">
597          <Text wrap="truncate">{draw(compactSegs)}</Text>
598          <Box flexShrink={0}>
599            <Text>{'  '}</Text>
600            <Button key="card-compact" plain dimColor label="expand" onPress={view.onCardCompact} />
601          </Box>
602        </Box>
603      ) : (
604      <Box alignItems="center">
605        {clawd}
606        <Box flexDirection="column" backgroundColor={card} paddingX={CARD_INSET}>
607          <Box>
608            <Text wrap="truncate">{draw(topLeft)}</Text>
609            {showFull && hasHistory && (
610              <Button key="history" plain dimColor label={historyLabel} onPress={view.onHistory} />
611            )}
612            <Text wrap="truncate">{draw(topRight)}</Text>
613          </Box>
614          <Box width={total}>
615            {edge('l')}
616            <Box flexGrow={1} paddingX={2} paddingY={config.padRows} justifyContent="space-between">
617              <Box>
618                {stats.flatMap((st, i) => [
619                  ...(i === 0 ? [] : [<Text key={`div-${st.key}`} wrap="truncate">{draw([divider])}</Text>]),
620                  <Box key={`stat-${st.key}`}>
621                    <Text wrap="truncate">{draw(st.segs)}</Text>
622                    {st.card && (
623                      <Box position="absolute" top={1} left={0} display="none" hover={{ display: 'flex' }} backgroundColor={card}>
624                        <Text wrap="truncate">{draw(st.card)}</Text>
625                      </Box>
626                    )}
627                  </Box>,
628                ])}
629              </Box>
630              {ctxFull && !isWorking && (
631                <Button
632                  key="compact"
633                  label={view.isCompacting ? 'compacting…' : 'compact'}
634                  hotkey="c"
635                  variant="primary"
636                  onPress={view.onCompact}
637                />
638              )}
639            </Box>
640            {edge('r')}
641          </Box>
642          <Box>
643            <Text wrap="truncate">{draw(bottom)}</Text>
644            <Button key="card-compact" plain dimColor label={minimizeLabel} onPress={view.onCardCompact} />
645            <Text>{draw([line(mainCorner)])}</Text>
646          </Box>
647        </Box>
648      </Box>
649      )}
650      {/* Drawn plain, as the band's own dim text: "1: push · 2: find bugs · ...", the repo's switches on the right */}
651      {/* Each action keeps its separator; on a narrow screen they wrap whole, and the switches drop to
652          their own line, rather than running together */}
653      <Box marginLeft={view.isCardCompact ? 0 : INDENT} width={view.isCardCompact ? INDENT + total - 4 : total - 4} justifyContent="space-between" flexWrap="wrap">
654        <Box flexWrap="wrap" flexShrink={1}>
655          {view.actions.map((action, i) => (
656            <Box key={`action-box-${action.key}`} flexShrink={0}>
657              {i === 0 ? null : <Text dimColor>{' · '}</Text>}
658              <Button
659                key={`action-${action.key}`}
660                plain
661                dimColor
662                label={action.label}
663                onPress={() => view.onAction(action)}
664              />
665            </Box>
666          ))}
667        </Box>
668        {/* The switches, as words: "spotify off · commit auto · push ask"; the label toggles, the word says which */}
669        {(gitAuto || view.isMusicOn !== null) && (
670          <Box flexShrink={0}>
671            {view.isMusicOn !== null && [
672              <Button key="switch-spotify" plain dimColor label="spotify" onPress={view.onMusicSwitch} />,
673              <Text key="switch-state-spotify" color={view.isMusicOn ? COLORS.ok : undefined} dimColor={!view.isMusicOn}>
674                {view.isMusicOn ? ' on' : ' off'}
675              </Text>,
676            ]}
677            {view.isLyricsOn !== null && [
678              <Text key="switch-sep-lyrics" dimColor>{' · '}</Text>,
679              <Button key="switch-lyrics" plain dimColor label="lyrics" onPress={view.onLyricsSwitch} />,
680              <Text key="switch-state-lyrics" color={view.isLyricsOn ? COLORS.ok : undefined} dimColor={!view.isLyricsOn}>
681                {view.isLyricsOn ? ' on' : ' off'}
682              </Text>,
683            ]}
684            {gitAuto && (['commit', 'push'] as const).flatMap((step, i) => [
685              ...(i === 0 && view.isMusicOn === null ? [] : [<Text key={`switch-sep-${step}`} dimColor>{' · '}</Text>]),
686              <Button key={`switch-${step}`} plain dimColor label={step} onPress={() => view.onSwitch(step)} />,
687              <Text key={`switch-state-${step}`} color={gitAuto[step] ? COLORS.ok : COLORS.warn}>
688                {gitAuto[step] ? ' auto' : ' ask'}
689              </Text>,
690            ])}
691          </Box>
692        )}
693      </Box>
694      {/* Under the actions, while Spotify plays: Beatbot and the Spotify card */}
695      {view.music && <Box marginTop={view.isMusicCompact ? 0 : 1}>{drawMusic(view.music)}</Box>}
696    </Box>
697  )
698}
699
hooks/layout.ts 55 lines
1// The band's widths, kept apart from the drawing so tests can hold them to the card: each frame edge
2// must come out exactly as wide as its card, and nothing inside may need more room than it has.
3// band.tsx draws with these same pieces, so a test of their widths is a test of what shows
4
5// Columns of a piece of text as the terminal draws it (every character here is one column wide)
6export const columnsOf = (text: string) => [...text].length
7
8// Clawd's column, and Beatbot's under it: the box, and Beatbot's grid with the one column left of it
9export const CLAWD_WIDTH = 11
10export const BEATBOT_PAD = 1
11// How long a track stays paused before Beatbot falls asleep
12export const BEATBOT_SLEEP_MS = 10 * 60_000
13
14// The end of a card's bottom edge, after what it shows: the button that shrinks the card, then the corner.
15// On the main card a dot parts it from the time
16export const MINIMIZE_LABEL = 'minimize'
17export const MAIN_BOTTOM_TAIL = ['· ', MINIMIZE_LABEL, ' ─╯']
18export const MUSIC_BOTTOM_TAIL = [' ', MINIMIZE_LABEL, ' ─╯']
19const BOTTOM_HEAD = '╰─'
20const tailColumns = (tail: string[]) => tail.reduce((n, piece) => n + columnsOf(piece), 0)
21
22// The rule between what a bottom edge shows on its left and right, so the edge spans the card
23export const bottomFill = (cardWidth: number, shown: number, tail: string[]) =>
24  Math.max(1, cardWidth - columnsOf(BOTTOM_HEAD) - shown - tailColumns(tail))
25
26// The Spotify card's middle row: the controls, then (room allowing) the progress bar and the time, then
27// shuffle, repeat and volume on the right. As the card narrows the toggles go first, then the bar, then
28// the time, so nothing wraps
29export const CONTROL_PIECES = ['◀◀', '  ', '❚❚', '  ', '▶▶', '   ', 'search']
30const CONTROLS = CONTROL_PIECES.reduce((n, piece) => n + columnsOf(piece), 0)
31// The frame's sides and the row's padding on each side
32export const MUSIC_ROW_CHROME = 2 + 4
33export const BEFORE_BAR = '    '
34export const BEFORE_TIME = '  '
35// The least room between the left group and the toggles, and the shortest bar worth drawing
36const TOGGLE_GAP = 3
37const MIN_BAR = 8
38
39export const musicRow = (cardWidth: number, timeColumns: number, toggleColumns: number) => {
40  const inner = cardWidth - MUSIC_ROW_CHROME
41  const withTime = CONTROLS + columnsOf(BEFORE_BAR) + timeColumns
42  const withBar = withTime + MIN_BAR + columnsOf(BEFORE_TIME)
43  const hasToggles = inner >= withBar + TOGGLE_GAP + toggleColumns
44  const hasBar = inner >= withBar
45  const hasTime = inner >= withTime
46  const bar = hasBar ? inner - withTime - columnsOf(BEFORE_TIME) - (hasToggles ? TOGGLE_GAP + toggleColumns : 0) : 0
47  // What the row takes, for the tests: it may never pass `inner`
48  const used =
49    CONTROLS +
50    (hasTime ? withTime - CONTROLS : 0) +
51    (hasBar ? bar + columnsOf(BEFORE_TIME) : 0) +
52    (hasToggles ? TOGGLE_GAP + toggleColumns : 0)
53  return { hasToggles, hasBar, hasTime, bar, inner, used }
54}
55
hooks/logic.ts 684 lines
1// The band's pure parts: formatting, dates, pace and settings, apart so they can be tested
2import type { PluginOptions } from 'claude-code'
3
4export const BAR_CELLS = 8
5export const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
6// Too early in a window, one burst would read as a runaway pace
7export const PACE_MIN_ELAPSED = 0.05
8const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
9const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
10
11// What each person sets for themselves in /config, the manifest's userConfig, with out-of-range numbers clamped
12export const DEFAULTS = { initials: '', card: '#0a0a0a', padRows: 1, rollingDays: 30, noAttribution: false, gitStrict: true, spotify: true, plan: '', spotifyClientId: '', spotifyClientSecret: '' }
13
14export const readConfig = (options: PluginOptions) => {
15  const text = (key: string, fallback: string) => (typeof options[key] === 'string' ? (options[key] as string) : fallback)
16  const whole = (key: string, fallback: number, lo: number, hi: number) =>
17    typeof options[key] === 'number' ? Math.min(hi, Math.max(lo, Math.round(options[key] as number))) : fallback
18  return {
19    initials: text('initials', DEFAULTS.initials),
20    card: text('cardColor', DEFAULTS.card),
21    padRows: whole('padRows', DEFAULTS.padRows, 0, 2),
22    rollingDays: whole('historyDays', DEFAULTS.rollingDays, 7, 62),
23    noAttribution: typeof options.noAttribution === 'boolean' ? options.noAttribution : DEFAULTS.noAttribution,
24    gitStrict: typeof options.gitStrict === 'boolean' ? options.gitStrict : DEFAULTS.gitStrict,
25    spotify: typeof options.spotify === 'boolean' ? options.spotify : DEFAULTS.spotify,
26    plan: text('plan', DEFAULTS.plan).trim(),
27    spotifyClientId: text('spotifyClientId', DEFAULTS.spotifyClientId).trim(),
28    spotifyClientSecret: text('spotifyClientSecret', DEFAULTS.spotifyClientSecret).trim(),
29  }
30}
31
32// Up to two initials across the shirt, as " A H "; none leaves it plain
33export const shirtText = (initials: string) => {
34  const [a = ' ', b = ' '] = [...initials.replace(/\s/g, '').toUpperCase()]
35  return ` ${a} ${b} `
36}
37
38// claude-opus-5-5 → Opus 5.5; anything else is shown as it came
39export const prettyModel = (id: string) => {
40  const [, family, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id) ?? []
41  return family ? `${family[0]?.toUpperCase()}${family.slice(1)} ${major}.${minor}` : id
42}
43
44// Tokens as 999, 2k, 2.4m or 1.2b; a count that would round up to "1000k" or "1000.0m" takes the next unit
45export const formatTokens = (n: number) =>
46  n >= 999_950_000
47    ? `${(n / 1_000_000_000).toFixed(1)}b`
48    : n >= 999_500
49      ? `${(n / 1_000_000).toFixed(1)}m`
50      : n >= 1000
51        ? `${Math.round(n / 1000)}k`
52        : `${n}`
53
54// Dollars always to the cent, thousands split by commas: $0.57, $366.00, $1,234.56
55export const formatUsd = (usd: number) => {
56  const [whole = '0', cents = '00'] = Math.abs(usd).toFixed(2).split('.')
57  return `${usd < 0 ? '-' : ''}$${whole.replace(/\B(?=(\d{3})+$)/g, ',')}.${cents}`
58}
59
60export const formatDuration = (ms: number) => {
61  const secs = Math.max(0, Math.floor(ms / 1000))
62  if (secs >= 3600) return `${Math.floor(secs / 3600)}h${Math.floor((secs % 3600) / 60)}m`
63  if (secs >= 60) return `${Math.floor(secs / 60)}m`
64  return `${secs}s`
65}
66
67export const formatReset = (ms: number) => {
68  const mins = Math.floor(ms / 60_000)
69  const days = Math.floor(mins / 1440)
70  const hours = Math.floor((mins % 1440) / 60)
71  if (days > 0) return `${days}d${hours}h`
72  if (hours > 0) return `${hours}h${mins % 60}m`
73  return `${mins}m`
74}
75
76// How long until a window runs out, when that comes before its reset: at the faster of the
77// pace across the whole window and the recent pace (percent per millisecond)
78export const runsOutIn = (kind: string, pct: number, resetMs: number, recentRate?: number) => {
79  const windowMs = WINDOW_MS[kind]
80  if (windowMs === undefined || pct <= 0 || pct >= 100) return undefined
81  const elapsed = windowMs - resetMs
82  if (elapsed < windowMs * PACE_MIN_ELAPSED) return undefined
83  const rate = Math.max(pct / elapsed, recentRate ?? 0)
84  const left = (100 - pct) / rate
85  return left < resetMs ? left : undefined
86}
87
88// A local date ("2026-10-02") moved by whole days; a UTC calendar keeps the steps exact
89export const shiftDay = (day: string, by: number) =>
90  new Date(Date.parse(`${day}T00:00:00Z`) + by * 86_400_000).toISOString().slice(0, 10)
91
92// "2026-10-02" as "Fri 02 Oct"
93export const dayLabel = (day: string) => {
94  const date = new Date(`${day}T00:00:00Z`)
95  return `${WEEKDAYS[date.getUTCDay()]} ${day.slice(8)} ${MONTHS[date.getUTCMonth()]}`
96}
97
98// A pace in percent per millisecond, as "1.20%/h"
99export const perHour = (rate: number) => `${(rate * 3_600_000).toFixed(2)}%/h`
100
101// Filled cells for a percentage; any use at all shows at least one
102export const barCells = (pct: number) => (pct <= 0 ? 0 : Math.min(BAR_CELLS, Math.max(1, Math.round((pct / 100) * BAR_CELLS))))
103
104// The repo's name, with the path below its root when the session runs in a subfolder
105export const placeFolder = (cwd: string, root: string | null) => {
106  const base = (path: string) => path.split('/').filter(Boolean).pop() ?? path
107  if (root === null || !cwd.startsWith(root)) return { folder: base(cwd), subdir: '' }
108  return { folder: base(root), subdir: cwd.slice(root.length) }
109}
110
111// The sum of a ledger's days from `since` to `until`, both included
112export const sumDays = (days: Record<string, number>, since: string, until: string) =>
113  Object.entries(days).reduce((sum, [day, n]) => (day >= since && day <= until ? sum + n : sum), 0)
114
115// The git steps the per-repo switches govern: add and commit under "commit", push under "push"
116export type GitStep = 'commit' | 'push'
117
118// A command the switches may allow without a prompt: one or more plain git steps joined by &&, all
119// in one repo, with nothing a shell could expand or redirect. Anything else is not plain, and asks.
120export type PlainGit = { steps: GitStep[]; dir?: string }
121
122// Splits a command into words, or gives null when it holds anything beyond plain words, single-quoted
123// text and && (double quotes, $, backticks, ;, |, redirections, newlines outside quotes, ~, globs...)
124const plainWords = (command: string): string[] | null => {
125  const words: string[] = []
126  let word = ''
127  let isInWord = false
128  for (let i = 0; i < command.length; i += 1) {
129    const c = command[i] ?? ''
130    if (c === "'") {
131      const end = command.indexOf("'", i + 1)
132      if (end === -1) return null
133      word += command.slice(i + 1, end)
134      isInWord = true
135      i = end
136    } else if (c === ' ' || c === '\t') {
137      if (isInWord) words.push(word)
138      word = ''
139      isInWord = false
140    } else if (c === '&') {
141      if (command[i + 1] !== '&' || isInWord) return null
142      words.push('&&')
143      i += 1
144    } else if (/[\w./@%+=:,-]/.test(c)) {
145      word += c
146      isInWord = true
147    } else {
148      return null
149    }
150  }
151  if (isInWord) words.push(word)
152  return words
153}
154
155// A remote or a branch: no leading dash, no "+" (a force refspec) and no ":" (a src:dst refspec)
156const NAME = /^[\w.@/%=,][\w.@/%=,-]*$/
157// A file to add: anything that does not start with a dash; a quoted one may hold spaces
158const FILE = /^[^-\n][^\n]*$/
159// The repo's folder: an absolute path; quoted, it may hold spaces or letters beyond ASCII
160const ABSOLUTE = /^\/[^\n]*$/
161
162// Flags that only shape what a reading step prints; anything else (--output writes a file) is not plain
163const READ_FLAG =
164  /^(?:-q|--quiet|--oneline|--stat|--shortstat|--short|--porcelain|--name-only|--name-status|--graph|--decorate|--no-color|-n|-\d+|--format=[^\s]*|--pretty=[^\s]*)$/
165const READ_VERBS = new Set(['status', 'log', 'diff', 'show'])
166
167// One git step in its strict form, or null: add files, commit with -m messages, push a branch, or a
168// step that only reads (status, log, diff, show), which the switches do not govern
169const plainStep = (args: string[]): { step?: GitStep; dir?: string } | null => {
170  let rest = args
171  let dir: string | undefined
172  if (rest[0] === '-C') {
173    if (!ABSOLUTE.test(rest[1] ?? '')) return null
174    dir = rest[1]
175    rest = rest.slice(2)
176  }
177  const [verb, ...tail] = rest
178  if (verb !== undefined && READ_VERBS.has(verb)) {
179    return tail.every(arg => READ_FLAG.test(arg) || NAME.test(arg)) ? { dir } : null
180  }
181  if (verb === 'add') {
182    const isPlain = tail.length > 0 && tail.every(arg => arg === '-A' || arg === '--all' || FILE.test(arg))
183    return isPlain ? { step: 'commit', dir } : null
184  }
185  if (verb === 'commit') {
186    let messages = 0
187    for (let i = 0; i < tail.length; i += 1) {
188      const arg = tail[i]
189      if (arg === '-m') {
190        if (tail[i + 1] === undefined) return null
191        messages += 1
192        i += 1
193      } else if (arg !== '-q' && arg !== '--quiet' && arg !== '-a' && arg !== '--all') {
194        return null
195      }
196    }
197    return messages > 0 ? { step: 'commit', dir } : null
198  }
199  if (verb === 'push') {
200    const flags = tail.filter(arg => arg.startsWith('-'))
201    const names = tail.filter(arg => !arg.startsWith('-'))
202    const isPlain =
203      flags.every(arg => arg === '-u' || arg === '--set-upstream' || arg === '-q' || arg === '--quiet') &&
204      names.length <= 2 &&
205      names.every(arg => NAME.test(arg))
206    return isPlain ? { step: 'push', dir } : null
207  }
208  return null
209}
210
211export const parsePlainGit = (command: string): PlainGit | null => {
212  const words = plainWords(command.trim())
213  if (words === null || words.length === 0) return null
214  const steps = new Set<GitStep>()
215  const dirs = new Set<string | undefined>()
216  let segment: string[] = []
217  for (const word of [...words, '&&']) {
218    if (word !== '&&') {
219      segment.push(word)
220      continue
221    }
222    if (segment[0] !== 'git') return null
223    const step = plainStep(segment.slice(1))
224    if (step === null) return null
225    if (step.step) steps.add(step.step)
226    dirs.add(step.dir)
227    segment = []
228  }
229  // Every step in one repo: all with the same -C, or all without one
230  if (dirs.size !== 1) return null
231  return { steps: [...steps], dir: [...dirs][0] }
232}
233
234// The git steps a command may take, read loosely so a step in any shape is still seen. git counts where
235// a command starts (after &&, ;, |, a newline, a subshell, `sh -c "`, eval) or behind a wrapper (env,
236// xargs, timeout, sudo, VAR=value...), as a path (/usr/bin/git), quoted ("git") or escaped (\git), with
237// any of its own options before the verb. Reading too much only asks more.
238const COMMAND_START = String.raw`(?:^|[;&|\n(\`{!]|\$\(|-c\s+["']|\beval\s+["']?)\s*`
239const WRAPPER = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*=\S*|env|command|builtin|exec|time|nice|nohup|xargs|sudo|doas|caffeinate|stdbuf|timeout|ionice|unbuffer|chronic)`
240const GIT_WORD = String.raw`["'\\]?(?:\S*/)?git["']?`
241const GIT_OPTION = String.raw`(?:-[Cc]\s+\S+|--(?:git-dir|work-tree|namespace|exec-path|super-prefix|config-env)(?:=|\s+)\S+|-\S+)`
242const LOOSE_STEP = new RegExp(
243  String.raw`${COMMAND_START}(?:${WRAPPER}(?:\s+(?!["'\\]?(?:\S*/)?git\b)\S+)*?\s+)*${GIT_WORD}(?:\s+${GIT_OPTION})*\s+["']?([A-Za-z][\w-]*)`,
244  'g',
245)
246
247// git's own commands besides add, commit and push: the switches leave them to the usual permissions.
248// Any other word after git may be an alias, which the hook looks up
249const OTHER_VERBS = new Set([
250  'status', 'log', 'diff', 'show', 'branch', 'fetch', 'rev-parse', 'rev-list', 'ls-files', 'ls-tree',
251  'remote', 'config', 'blame', 'grep', 'describe', 'shortlog', 'reflog', 'cat-file', 'check-ignore',
252  'for-each-ref', 'symbolic-ref', 'stash', 'tag', 'help', 'version', 'whatchanged', 'name-rev',
253  'merge-base', 'show-ref', 'var', 'count-objects', 'fsck', 'notes', 'worktree', 'switch', 'checkout',
254  'restore', 'clean', 'reset', 'rm', 'mv', 'pull', 'merge', 'rebase', 'cherry-pick', 'revert', 'init',
255  'clone', 'archive', 'bisect', 'submodule', 'sparse-checkout', 'maintenance', 'gc', 'prune', 'am',
256  'apply', 'format-patch', 'difftool', 'mergetool', 'range-diff', 'cherry', 'request-pull',
257])
258
259export type LooseGit = { steps: GitStep[]; aliases: string[] }
260
261// A heredoc's body is data unless a shell reads it: "python3 - <<'EOF'" or "cat <<EOF" feed text to a
262// program, "bash <<EOF" runs it. Bodies fed to anything but a shell are left out before reading for git
263const HEREDOC_FED = /(^|\n)([^\n]*?)<<-?\s*(['"]?)(\w+)\3[^\n]*\n[\s\S]*?\n\s*\4\s*(?=\n|$)/g
264const SHELL_READS = /(?:^|[\s;&|(])(?:sh|bash|zsh|dash|ksh|fish|eval|source|\.)\s*(?:-\S+\s*)*$/
265
266export const withoutDataHeredocs = (command: string) =>
267  command.replace(HEREDOC_FED, (whole, start: string, before: string) =>
268    SHELL_READS.test(before.trim().replace(/\s*<*$/, '')) ? whole : `${start}${before}<<HEREDOC`,
269  )
270
271export const looseGitSteps = (command: string): LooseGit => {
272  const steps = new Set<GitStep>()
273  const aliases = new Set<string>()
274  for (const [, verb = ''] of withoutDataHeredocs(command).matchAll(LOOSE_STEP)) {
275    if (verb === 'push') steps.add('push')
276    else if (verb === 'add' || verb === 'commit') steps.add('commit')
277    else if (!OTHER_VERBS.has(verb)) aliases.add(verb)
278  }
279  return { steps: [...steps], aliases: [...aliases] }
280}
281
282// The steps a git alias takes, from its expansion: a shell alias ("!...") may do anything, so both
283export const aliasSteps = (expansion: string): GitStep[] => {
284  const text = expansion.trim()
285  if (text.startsWith('!')) return ['commit', 'push']
286  const [verb] = text.split(/\s+/)
287  if (verb === 'push') return ['push']
288  if (verb === 'add' || verb === 'commit') return ['commit']
289  return []
290}
291
292// What the switches make of a command. Allow only a plain one that names its repo (-C /path) with every
293// step on auto, and never in plan mode; ask for any other that takes a git step, since its repo or its
294// shape is uncertain; pass the rest to the usual permissions
295export const gitDecision = (
296  plain: PlainGit | null,
297  steps: GitStep[],
298  auto: Record<GitStep, boolean>,
299  mode?: string,
300): 'pass' | 'ask' | 'allow' => {
301  if (steps.length === 0) return 'pass'
302  if (mode === 'plan') return 'pass'
303  const isCertain = plain !== null && plain.dir !== undefined
304  return isCertain && steps.every(step => auto[step]) ? 'allow' : 'ask'
305}
306
307// Lines that credit Claude in a commit or pull request: Co-Authored-By naming Claude, a Claude-Session
308// link, the "Generated with Claude Code" footer
309const CLAUDE_CREDIT = /^\s*(?:co-authored-by:.*\bclaude\b.*|claude-session:.*|.*generated with \[?claude code\]?.*)$/gim
310
311// A commit message as a model wrote it, made ready to commit: no code fences or wrapping quotes, no line
312// crediting Claude, blank runs collapsed
313export const cleanCommitMessage = (text: string) =>
314  text
315    .replace(/^\s*```\w*\n?|\n?```\s*$/g, '')
316    .replace(/^\s*(["'])([\s\S]*)\1\s*$/, '$2')
317    .replace(CLAUDE_CREDIT, '')
318    .replace(/\n{3,}/g, '\n\n')
319    .trim()
320
321// "--shortstat" output as lines added and removed
322export const parseShortstat = (text: string) => ({
323  added: Number(/(\d+) insertion/.exec(text)?.[1] ?? 0),
324  removed: Number(/(\d+) deletion/.exec(text)?.[1] ?? 0),
325})
326
327// "git status --porcelain=v2 --branch --show-stash" in one read: the branch (a short commit when
328// detached), its upstream, how far ahead and behind, stashes, and how many paths changed
329export const parseStatusV2 = (text: string) => {
330  let oid = ''
331  let head = ''
332  let upstream: string | null = null
333  let ahead = 0
334  let behind = 0
335  let stashed = 0
336  let changed = 0
337  for (const row of text.split('\n')) {
338    if (row.startsWith('# branch.oid ')) oid = row.slice(13).trim()
339    else if (row.startsWith('# branch.head ')) head = row.slice(14).trim()
340    else if (row.startsWith('# branch.upstream ')) upstream = row.slice(18).trim()
341    else if (row.startsWith('# branch.ab ')) {
342      const [a = '0', b = '0'] = row.slice(12).trim().split(/\s+/)
343      ahead = Math.abs(Number(a))
344      behind = Math.abs(Number(b))
345    } else if (row.startsWith('# stash ')) stashed = Number(row.slice(8).trim())
346    else if (row.trim() !== '' && !row.startsWith('#') && !row.startsWith('! ')) changed += 1
347  }
348  const branch = head && head !== '(detached)' ? head : oid && oid !== '(initial)' ? oid.slice(0, 7) : ''
349  return { branch, upstream, ahead, behind, stashed, changed }
350}
351
352// Paths that may hold secrets, to warn about before they are committed
353const SENSITIVE = /(?:^|\/)(?:\.env(?:\..*)?|.*\.(?:pem|key|p12|pfx|keystore|jks)|id_(?:rsa|dsa|ecdsa|ed25519)|credentials(?:\.json)?|secrets?\.[a-z]+|\.npmrc|\.netrc)$/i
354export const isSensitivePath = (path: string) => SENSITIVE.test(path.trim())
355
356// Single-quoted for the shell, or null when the text holds a single quote and cannot be
357export const shellQuote = (text: string) => (text.includes("'") ? null : `'${text}'`)
358
359// What the system prompt tells Claude about git in the session's repo: what each switch asks of it (on
360// auto it commits or pushes on its own; on ask only when asked), the plain form the switches let
361// through, and no credit lines when the person turned attribution off
362export const gitGuide = (root: string, auto: Record<GitStep, boolean>, noAttribution: boolean) => {
363  const name = root.split('/').filter(Boolean).pop() ?? root
364  const state = (step: GitStep) => (auto[step] ? 'auto' : 'ask')
365  const commit = auto.commit
366    ? 'Commit on your own: whenever you finish a complete change, commit it with a clear message, without being asked.'
367    : 'Commit only when the user asks; they confirm each commit.'
368  const push = auto.push
369    ? auto.commit
370      ? 'Push on your own, right after each commit.'
371      : 'When the user has you commit, push right after.'
372    : 'Push only when the user asks; they confirm each push.'
373  const repo = shellQuote(root)
374  const form =
375    repo === null
376      ? 'This repo path holds a single quote, so the plain form cannot name it: every git add, commit and push here asks.'
377      : `When you add, commit or push here, use only the plain form, so the switches can let it through: \`git -C ${repo} add <files>\`, \`git -C ${repo} commit -m '<message>'\` and \`git -C ${repo} push\`, joined with && when you do more than one, every step with that same -C. Put the whole message in single quotes (it may span lines) and leave apostrophes out of it. Do not use heredocs, $(...), cd, pipes, or git options before the step: any of those always asks.`
378  return [
379    `# Git in ${name}`,
380    `The user's git switches for this repo: commit is ${state('commit')}, push is ${state('push')}. ${commit} ${push}`,
381    form,
382    ...(noAttribution
383      ? ['Do not credit Claude in commits or pull requests: no Co-Authored-By line naming Claude, no Claude-Session line, no "Generated with Claude Code" footer.']
384      : []),
385  ].join('\n')
386}
387
388// ── Spotify
389
390// What Spotify is playing, as the AppleScript in register.tsx reports it: fields joined by the unit
391// separator (\x1f), so a track name with a newline or a comma stays whole
392export type NowPlaying = {
393  isPlaying: boolean
394  name: string
395  artist: string
396  album: string
397  durationMs: number
398  positionMs: number
399  artUrl: string
400  trackId: string
401  isShuffling: boolean
402  isRepeating: boolean
403  // 0 to 100
404  volume: number
405}
406
407export const parseSpotify = (text: string): NowPlaying | null => {
408  const [state, name = '', artist = '', album = '', duration = '0', position = '0', artUrl = '', trackId = '', shuffling = '', repeating = '', volume = '0'] = text
409    .replace(/\n$/, '')
410    .split('\x1f')
411  if (state !== 'playing' && state !== 'paused') return null
412  if (!name) return null
413  return {
414    isPlaying: state === 'playing',
415    name,
416    artist,
417    album,
418    durationMs: Number(duration) || 0,
419    // A locale may write the seconds with a decimal comma
420    positionMs: Math.round((Number(position.replace(',', '.')) || 0) * 1000),
421    artUrl,
422    trackId,
423    isShuffling: shuffling === 'true',
424    isRepeating: repeating === 'true',
425    volume: Math.min(100, Math.max(0, Number(volume) || 0)),
426  }
427}
428
429// A track heard, for the band's "recently played": what it was and when it stopped playing
430export type PlayedTrack = { trackId: string; name: string; artist: string; at: number }
431
432// The list after a track has played: newest first, no track twice in a row, at most `keep`
433export const addPlayed = (list: PlayedTrack[], track: PlayedTrack, keep = 20) =>
434  [track, ...list.filter(t => t.trackId !== track.trackId)].slice(0, keep)
435
436// Milliseconds as "3:07", or "1:02:07" past an hour
437export const formatClock = (ms: number) => {
438  const secs = Math.max(0, Math.floor(ms / 1000))
439  const h = Math.floor(secs / 3600)
440  const m = Math.floor((secs % 3600) / 60)
441  const s = String(secs % 60).padStart(2, '0')
442  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
443}
444
445const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
446
447// Base64, written out so it needs nothing from the environment
448export const toBase64 = (bytes: Uint8Array) => {
449  let out = ''
450  for (let i = 0; i < bytes.length; i += 3) {
451    const a = bytes[i] ?? 0
452    const b = bytes[i + 1] ?? 0
453    const c = bytes[i + 2] ?? 0
454    const n = (a << 16) | (b << 8) | c
455    out += B64[(n >> 18) & 63]
456    out += B64[(n >> 12) & 63]
457    out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
458    out += i + 2 < bytes.length ? B64[n & 63] : '='
459  }
460  return out
461}
462
463export const fromBase64 = (text: string) => {
464  const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
465  const bytes = new Uint8Array(Math.floor((clean.length * 3) / 4))
466  let n = 0
467  let bits = 0
468  let at = 0
469  for (const ch of clean) {
470    n = (n << 6) | B64.indexOf(ch)
471    bits += 6
472    if (bits >= 8) {
473      bits -= 8
474      bytes[at++] = (n >> bits) & 0xff
475    }
476  }
477  return bytes.slice(0, at)
478}
479
480// The pixels of an uncompressed 24- or 32-bit BMP (what `sips -s format bmp` writes), top row first, as
481// 0xRRGGBB; null for anything else
482export const readBmp = (bytes: Uint8Array): { width: number; height: number; pixels: number[] } | null => {
483  if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null
484  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
485  const offset = view.getUint32(10, true)
486  const width = view.getInt32(18, true)
487  const rawHeight = view.getInt32(22, true)
488  const bpp = view.getUint16(28, true)
489  const compression = view.getUint32(30, true)
490  // 0: plain RGB; 3: bit fields, which sips uses for 32-bit with the usual BGRA order
491  if ((bpp !== 24 && bpp !== 32) || (compression !== 0 && compression !== 3) || width <= 0 || rawHeight === 0) return null
492  const height = Math.abs(rawHeight)
493  const isBottomUp = rawHeight > 0
494  const step = bpp / 8
495  const stride = Math.ceil((width * step) / 4) * 4
496  if (offset + stride * height > bytes.length) return null
497  const pixels: number[] = []
498  for (let y = 0; y < height; y += 1) {
499    const row = isBottomUp ? height - 1 - y : y
500    for (let x = 0; x < width; x += 1) {
501      const at = offset + row * stride + x * step
502      pixels.push(((bytes[at + 2] ?? 0) << 16) | ((bytes[at + 1] ?? 0) << 8) | (bytes[at] ?? 0))
503    }
504  }
505  return { width, height, pixels }
506}
507
508// A pixel left clear: the terminal's own background shows through it
509export const CLEAR = 0x01000000
510
511// A picture as Raster cells, two pixels a cell: an upper half block (▀) with the upper pixel as ink and
512// the lower as paper. A clear pixel takes the terminal's own background: a clear pair is a space, and
513// one clear half is drawn by the other half's block (▀ or ▄) over the terminal's paper; ink is never
514// left to the terminal, whose own ink is its text color. Little-endian u32 triplets, base64
515export const pixelsToCells = (picture: { width: number; height: number; pixels: number[] }, columns: number, rows: number) => {
516  const words = new Uint32Array(columns * rows * 3)
517  const at = (x: number, y: number) => {
518    const px = Math.min(picture.width - 1, Math.floor((x * picture.width) / columns))
519    const py = Math.min(picture.height - 1, Math.floor((y * picture.height) / (rows * 2)))
520    return picture.pixels[py * picture.width + px] ?? 0
521  }
522  for (let r = 0; r < rows; r += 1) {
523    for (let c = 0; c < columns; c += 1) {
524      const i = (r * columns + c) * 3
525      const upper = at(c, r * 2)
526      const lower = at(c, r * 2 + 1)
527      const cell =
528        upper === CLEAR && lower === CLEAR
529          ? [0x20, CLEAR, CLEAR]
530          : upper === CLEAR
531            ? [0x2584, lower, CLEAR]
532            : lower === CLEAR
533              ? [0x2580, upper, CLEAR]
534              : [0x2580, upper, lower]
535      words.set(cell, i)
536    }
537  }
538  return toBase64(new Uint8Array(words.buffer))
539}
540
541// Raster cells with some replaced by a character in a color on a clear background, such as the Z's
542// beside a sleeping mascot. Cells outside the grid are left out
543export const withGlyphs = (
544  cells: string,
545  columns: number,
546  glyphs: { column: number; row: number; char: string; color: number }[],
547) => {
548  const words = new Uint32Array(fromBase64(cells).slice().buffer)
549  const rows = words.length / 3 / columns
550  for (const g of glyphs) {
551    if (g.column < 0 || g.column >= columns || g.row < 0 || g.row >= rows) continue
552    words.set([g.char.codePointAt(0) ?? 0x20, g.color, CLEAR], (g.row * columns + g.column) * 3)
553  }
554  return toBase64(new Uint8Array(words.buffer))
555}
556
557// The Claude plan, from Claude Code's own ~/.claude.json: "Max 5x", "Max 20x", "Pro", "Team",
558// "Enterprise", or null when the file says no plan (an API key, a missing account)
559export const planLabel = (configText: string): string | null => {
560  let account: { organizationType?: unknown; organizationRateLimitTier?: unknown } | undefined
561  try {
562    account = (JSON.parse(configText) as { oauthAccount?: typeof account }).oauthAccount
563  } catch {
564    return null
565  }
566  const type = typeof account?.organizationType === 'string' ? account.organizationType : ''
567  const tier = typeof account?.organizationRateLimitTier === 'string' ? account.organizationRateLimitTier : ''
568  if (type === 'claude_max') {
569    const times = /(\d+)x/.exec(tier)?.[1]
570    return times ? `Max ${times}x` : 'Max'
571  }
572  const names: Record<string, string> = { claude_pro: 'Pro', claude_team: 'Team', claude_enterprise: 'Enterprise' }
573  return names[type] ?? null
574}
575
576// ── Spotify search, through the Web API with an app's own credentials (no user sign-in)
577
578// UTF-8 bytes of a string, without TextEncoder
579const utf8 = (text: string) => {
580  const bytes: number[] = []
581  for (const ch of unescape(encodeURIComponent(text))) bytes.push(ch.charCodeAt(0))
582  return new Uint8Array(bytes)
583}
584
585// The Authorization header that trades an app's client id and secret for a token
586export const basicAuth = (id: string, secret: string) => `Basic ${toBase64(utf8(`${id}:${secret}`))}`
587
588// A track the search found
589export type FoundTrack = { uri: string; name: string; artist: string; album: string; durationMs: number }
590
591// The tracks in a search response, or null when the response is not one
592export const parseSearch = (text: string): FoundTrack[] | null => {
593  let body: { tracks?: { items?: unknown[] } }
594  try {
595    body = JSON.parse(text) as typeof body
596  } catch {
597    return null
598  }
599  const items = body.tracks?.items
600  if (!Array.isArray(items)) return null
601  const tracks: FoundTrack[] = []
602  for (const item of items) {
603    const t = item as {
604      uri?: unknown
605      name?: unknown
606      duration_ms?: unknown
607      artists?: { name?: unknown }[]
608      album?: { name?: unknown }
609    } | null
610    if (!t || typeof t.uri !== 'string' || typeof t.name !== 'string') continue
611    tracks.push({
612      uri: t.uri,
613      name: t.name,
614      artist: (t.artists ?? []).map(a => (typeof a?.name === 'string' ? a.name : '')).filter(Boolean).join(', '),
615      album: typeof t.album?.name === 'string' ? t.album.name : '',
616      durationMs: typeof t.duration_ms === 'number' ? t.duration_ms : 0,
617    })
618  }
619  return tracks
620}
621
622// A Spotify track URI that is safe to put inside an AppleScript string, or null
623export const safeTrackUri = (uri: string) => (/^spotify:track:[A-Za-z0-9]+$/.test(uri) ? uri : null)
624
625// Text cut or padded to exactly `n` columns, with … where it was cut
626export const fitText = (text: string, n: number) => {
627  const chars = [...text]
628  if (n <= 0) return ''
629  if (chars.length <= n) return text + ' '.repeat(n - chars.length)
630  return `${chars.slice(0, n - 1).join('')}…`
631}
632
633// ── Lyrics: synced lines from LRCLIB (lrclib.net), found by the track's name, artist, album and length
634
635export type LyricLine = { atMs: number; text: string }
636
637// LRCLIB's lookup for one track; the length in whole seconds, which it matches within a couple
638export const lyricsUrl = (m: { name: string; artist: string; album: string; durationMs: number }) =>
639  `https://lrclib.net/api/get?${[
640    ['track_name', m.name],
641    ['artist_name', m.artist],
642    ['album_name', m.album],
643    ['duration', String(Math.round(m.durationMs / 1000))],
644  ]
645    .map(([k, v]) => `${k}=${encodeURIComponent(v ?? '')}`)
646    .join('&')}`
647
648// Synced LRC as lines in time order: "[01:02.34] words", a line with several stamps kept at each,
649// empty lines kept (they clear the line shown), and tags such as [ar:...] skipped
650export const parseLrc = (text: string): LyricLine[] => {
651  const lines: LyricLine[] = []
652  for (const raw of text.split(/\r?\n/)) {
653    const stamps = [...raw.matchAll(/\[(\d+):(\d{1,2}(?:[.:]\d{1,3})?)\]/g)]
654    if (stamps.length === 0) continue
655    const words = raw.replace(/\[[^\]]*\]/g, '').trim()
656    for (const [, min = '0', sec = '0'] of stamps) {
657      lines.push({ atMs: Math.round((Number(min) * 60 + Number(sec.replace(':', '.'))) * 1000), text: words })
658    }
659  }
660  return lines.sort((a, b) => a.atMs - b.atMs)
661}
662
663// The lines in LRCLIB's answer, an empty list when it has none synced (plain lyrics cannot follow the song)
664export const parseLyrics = (json: string): LyricLine[] => {
665  try {
666    const got = JSON.parse(json) as { syncedLyrics?: unknown; instrumental?: unknown }
667    return typeof got.syncedLyrics === 'string' ? parseLrc(got.syncedLyrics) : []
668  } catch {
669    return []
670  }
671}
672
673// The line being sung at a moment and the one after it; before the first line, only what comes next
674export const lyricAt = (lines: LyricLine[], positionMs: number): { current: string; next: string } => {
675  let i = -1
676  while (i + 1 < lines.length && (lines[i + 1]?.atMs ?? Infinity) <= positionMs) i++
677  const after = lines.slice(i + 1).find(l => l.text !== '')
678  return { current: lines[i]?.text ?? '', next: after?.text ?? '' }
679}
680
681// Where a playing track is now, counting on from when Spotify was last read; never past its end
682export const livePosition = (m: { isPlaying: boolean; positionMs: number; durationMs: number; readAt?: number }, now: number) =>
683  m.isPlaying && m.readAt !== undefined ? Math.min(m.durationMs || Infinity, m.positionMs + Math.max(0, now - m.readAt)) : m.positionMs
684
hooks/pane.tsx 141 lines
1// The history pane: each day's cost and tokens over the history window, as bars, with a total and a
2// button that copies the table as text
3import type { Kit } from './band'
4import { dayLabel, fitText, formatClock, formatTokens, formatUsd } from './logic'
5import type { FoundTrack } from './logic'
6import { COLORS } from './state'
7import type { HistoryDay } from '../types'
8
9const HISTORY_BAR = 30
10
11// The same table as plain text, for the clipboard
12export const historyText = (days: HistoryDay[], span: number) => {
13  const usd = days.reduce((sum, d) => sum + d.usd, 0)
14  const tokens = days.reduce((sum, d) => sum + d.tokens, 0)
15  return [
16    ...days.map(d => `${dayLabel(d.day)}  ${formatUsd(d.usd).padStart(9)}  ${(d.tokens > 0 ? formatTokens(d.tokens) : '-').padStart(6)}`),
17    `${span} days  ${formatUsd(usd)} · ${formatTokens(tokens)} tokens`,
18  ].join('\n')
19}
20
21export function drawHistoryPane(
22  { Box, Button, Text }: Kit,
23  view: { days: HistoryDay[]; span: number; rows: number; onCopy: () => void },
24) {
25  const { days, span } = view
26  const most = Math.max(0.01, ...days.map(d => d.usd))
27  const usd = days.reduce((sum, d) => sum + d.usd, 0)
28  const tokens = days.reduce((sum, d) => sum + d.tokens, 0)
29  // The newest days when the pane is too short for all of them
30  const room = Math.max(1, view.rows - 7)
31
32  return (
33    <Box flexDirection="column">
34      {days.slice(-room).map(d => {
35        const cells = d.usd > 0 ? Math.max(1, Math.round((d.usd / most) * HISTORY_BAR)) : 0
36        return (
37          <Text key={d.day} wrap="truncate">
38            <Text dimColor>{`${dayLabel(d.day)}  `}</Text>
39            <Text color={COLORS.ok}>{'█'.repeat(cells)}</Text>
40            <Text dimColor>{'·'.repeat(HISTORY_BAR - cells)}</Text>
41            <Text color={d.usd > 0 ? COLORS.ok : undefined} dimColor={d.usd === 0}>{`  ${formatUsd(d.usd).padStart(9)}`}</Text>
42            <Text color={COLORS.model}>{`  ${(d.tokens > 0 ? formatTokens(d.tokens) : '–').padStart(6)}`}</Text>
43          </Text>
44        )
45      })}
46      <Text> </Text>
47      <Text wrap="truncate">
48        <Text dimColor>{`${span} days  `}</Text>
49        <Text color={COLORS.ok} bold>{formatUsd(usd)}</Text>
50        <Text dimColor>{' · '}</Text>
51        <Text color={COLORS.model}>{formatTokens(tokens)}</Text>
52        <Text dimColor>{' tokens, counted from when the mod was installed'}</Text>
53      </Text>
54      <Text> </Text>
55      <Button key="copy-history" plain dimColor label="copy as text" onPress={view.onCopy} />
56    </Box>
57  )
58}
59
60// The Spotify search, a dialog: a framed box to type in, then up to ten tracks, two lines each: the title
61// (a button that plays it) with the time on the right, and the artist and album under it, dim. Widths come
62// from the pane itself (truncating Text and flex), never from a column count, so nothing wraps. No hotkeys
63// on the tracks, so digits typed into the box never play one
64export type SearchView = {
65  query: string
66  status: 'idle' | 'searching' | 'found' | 'failed'
67  message: string
68  tracks: FoundTrack[]
69  // The track Spotify plays now, marked in the list
70  playingUri: string | null
71  onSearch: (query: string) => void
72  onPlay: (track: FoundTrack) => void
73}
74
75const SPOTIFY_GREEN = '#1db954'
76// Long enough for any title the pane shows; a truncating Text cuts it at the pane's own edge
77const RULE = '─'.repeat(240)
78const TITLE_MAX = 48
79
80export function drawSearchPane({ Box, Button, Text, Input }: Kit, view: SearchView) {
81  const found = view.status === 'found' && view.tracks.length > 0
82  const count = found ? `${view.tracks.length} tracks` : view.status === 'searching' ? 'searching…' : ''
83  return (
84    <Box flexDirection="column" paddingTop={1} paddingLeft={2} paddingRight={3}>
85      <Box justifyContent="space-between">
86        <Text color={SPOTIFY_GREEN} bold>{'● Spotify'}</Text>
87        <Text dimColor>{count}</Text>
88      </Box>
89      <Box marginTop={1} borderStyle="round" borderColor={SPOTIFY_GREEN} paddingX={1}>
90        {Input ? (
91          <Input
92            key="spotify-query"
93            placeholder="a track, an artist, an album"
94            value={view.query}
95            submitLabel="search"
96            autoFocus
97            onSubmit={value => view.onSearch(value)}
98          />
99        ) : (
100          <Text dimColor>{'This surface has no text box.'}</Text>
101        )}
102      </Box>
103      <Box marginTop={1} flexDirection="column">
104        {view.status === 'idle' ? <Text dimColor>{'Type and press Enter; pick a track to play it.'}</Text> : null}
105        {view.status === 'failed' ? <Text color="#f87171">{view.message}</Text> : null}
106        {view.status === 'found' && view.tracks.length === 0 ? <Text dimColor>{`Nothing found for “${view.query}”.`}</Text> : null}
107        {view.tracks.map(track => {
108          const isPlaying = track.uri === view.playingUri
109          const sub = [track.artist, track.album].filter(Boolean).join(' · ')
110          return (
111            <Box key={`found-${track.uri}`} flexDirection="column">
112              <Box justifyContent="space-between">
113                <Box flexShrink={1}>
114                  <Text color={SPOTIFY_GREEN}>{isPlaying ? '♪ ' : '  '}</Text>
115                  <Button
116                    key={`play-${track.uri}`}
117                    plain
118                    label={fitText(track.name, Math.min(TITLE_MAX, [...track.name].length))}
119                    onPress={() => view.onPlay(track)}
120                  />
121                </Box>
122                <Text dimColor>{track.durationMs ? ` ${formatClock(track.durationMs)}` : ''}</Text>
123              </Box>
124              <Text dimColor wrap="truncate">{`  ${sub}`}</Text>
125            </Box>
126          )
127        })}
128      </Box>
129      {found ? (
130        <Box flexDirection="column" marginTop={1}>
131          <Text dimColor wrap="truncate">{RULE}</Text>
132          <Text dimColor wrap="truncate">
133            <Text color={SPOTIFY_GREEN}>{'▶ '}</Text>
134            {'↑↓ or Tab · Enter plays · Esc closes'}
135          </Text>
136        </Box>
137      ) : null}
138    </Box>
139  )
140}
141
hooks/state.ts 40 lines
1// What the band's files share: its colors and keys, and the module-wide runtime. The state values (atoms)
2// live in register.tsx, where the engine's scan can read them
3import { DEFAULTS } from './logic'
4import type { readConfig, GitStep } from './logic'
5
6export const COLORS = {
7  ok: '#4ade80',
8  warn: '#fbbf24',
9  hot: '#f87171',
10  folder: '#fbbf24',
11  branch: '#5eead4',
12  model: '#7dd3fc',
13  border: '#71717a',
14  borderBusy: '#d97757',
15}
16
17export const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
18export const GIT_TIMEOUT = { timeoutMs: 5000 }
19
20export type Config = ReturnType<typeof readConfig>
21export type GitAuto = Record<GitStep, boolean>
22export const REVIEW: GitAuto = { commit: false, push: false }
23
24// The module's own values: they start over on a reload, as the module does. The config is read in
25// register; the permission mode and effort ride on the classic hooks' input
26export const runtime = {
27  config: DEFAULTS as Config,
28  permissionMode: undefined as string | undefined,
29  effort: undefined as string | undefined,
30  // The Claude plan, read once per load from ~/.claude.json; null when there is none
31  plan: undefined as string | null | undefined,
32  // When the person last did something, and until when Clawd cheers
33  lastActiveAt: null as number | null,
34  cheerUntil: 0,
35  // When Clawd's current mood began, so its animation can rest after a while
36  moodSince: 0,
37  // Uncommitted files when the turn began, so its end can tell whether the turn changed any
38  changedAtStart: 0,
39}
40
hooks/spotify-logo.ts 57 lines
1// Beatbot, Spotify's little headphone-wearing companion under Clawd. Raster draws two pixels per
2// terminal cell, including in Warp. Each string is one pixel row, top first; letters name colors below.
3//
4// The box under Clawd is LOGO_COLUMNS cells wide and LOGO_ROWS cells tall, so the grid is LOGO_COLUMNS
5// pixels wide and LOGO_ROWS * 2 tall. A cell is about twice as tall as wide, so one pixel is about square.
6import { CLEAR } from './logic'
7
8// Two columns wider than Beatbot, so the dance can sway him a pixel each way
9export const LOGO_COLUMNS = 10
10export const LOGO_ROWS = 3
11
12// Each letter's color, as 0xRRGGBB; "." is clear, the terminal's own background
13export const PALETTE: Record<string, number> = {
14  '.': CLEAR,
15  G: 0x1ed760, // Spotify green
16  K: 0x121212, // eyes
17  A: 0x18833b, // smile and closed eyes
18  H: 0x167c3b, // headphone band
19  C: 0xb5ffce, // mint ear cushions
20}
21
22export const LOGO: string[] = [
23  '..HHHH..',
24  '.HGGGGH.',
25  'CGKGGKGC',
26  'CGGAAGGC',
27  '.GGGGGG.',
28  '..G..G..',
29]
30
31// Beatbot in the middle of his box, or swayed a pixel left or right
32const place = (rows: string[], at: 'left' | 'middle' | 'right') =>
33  rows.map(row => (at === 'left' ? `${row}..` : at === 'right' ? `..${row}` : `.${row}.`))
34
35// One step at a time, so each frame differs from the last by little and the dance reads smooth: sway
36// right with a glance and an arm and leg out, back to the middle with a happy squint, the same to the
37// left, then a little hop with the legs tucked. One frame each DANCE_EVERY_MS while a track plays
38const pose = (eyes: string, body: string, legs: string) => [LOGO[0] ?? '', LOGO[1] ?? '', eyes, LOGO[3] ?? '', body, legs]
39const EYES = { ahead: 'CGKGGKGC', right: 'CGGKGGKC', left: 'CKGGKGGC', happy: 'CGAGGAGC' }
40const BODY = { still: '.GGGGGG.', right: '.GGGGGGG', left: 'GGGGGGG.' }
41const LEGS = { still: '..G..G..', right: '..G...G.', left: '.G...G..', hop: '........' }
42
43export const STANDING = place(LOGO, 'middle')
44
45export const DANCE_FRAMES: string[][] = [
46  place(pose(EYES.ahead, BODY.still, LEGS.still), 'middle'),
47  place(pose(EYES.right, BODY.still, LEGS.still), 'right'),
48  place(pose(EYES.right, BODY.right, LEGS.right), 'right'),
49  place(pose(EYES.happy, BODY.still, LEGS.still), 'middle'),
50  place(pose(EYES.left, BODY.still, LEGS.still), 'left'),
51  place(pose(EYES.left, BODY.left, LEGS.left), 'left'),
52  place(pose(EYES.happy, BODY.still, LEGS.still), 'middle'),
53  place(pose(EYES.happy, BODY.still, LEGS.hop), 'middle'),
54]
55
56export const BLINK_FRAME = STANDING.map(row => row.replaceAll('K', 'A'))
57
types/index.d.ts 117 lines
1export type Limit = {
2  kind: string
3  percent: number
4  resetsAt?: string
5  resetsOn?: string
6  // Percent per millisecond over the last few hours, once there is enough to tell
7  recentRate?: number
8}
9
10// Kept in $.store across sessions: readings of each window, by "kind@resetsAt"
11export type PaceLog = Record<string, { t: number; pct: number }[]>
12
13// Kept in $.store across sessions: tokens per day, and per session, counted at each turn's end
14export type TokenLedger = {
15  days: Record<string, number>
16  sessions: Record<string, number>
17}
18
19// The last turn's tokens: read in, cache included, and written out
20export type TurnTokens = { input: number; output: number; cached?: number }
21
22// Clawd's mood while Claude is not working
23export type Mood = 'idle' | 'cheer' | 'sleep' | 'sweat'
24
25// What Spotify plays, with the last few tracks heard
26export type Music = {
27  isPlaying: boolean
28  name: string
29  artist: string
30  album: string
31  durationMs: number
32  positionMs: number
33  artUrl: string
34  trackId: string
35  isShuffling: boolean
36  isRepeating: boolean
37  volume: number
38  // When the track was paused, epoch ms; null while it plays
39  pausedAt: number | null
40  // When Spotify was read, epoch ms, so a playing track's position can be counted on between reads
41  readAt?: number
42  // The last few tracks heard before this one, newest first
43  recent: { trackId: string; name: string; artist: string; at: number }[]
44}
45
46// A track's synced lyrics from LRCLIB, by its Spotify id; no lines when it has none synced
47export type Lyrics = { trackId: string; lines: { atMs: number; text: string }[] }
48
49// The Spotify search dialog: what was asked, how it went, and the tracks found
50export type SpotifySearch = {
51  query: string
52  status: 'idle' | 'searching' | 'found' | 'failed'
53  message: string
54  tracks: { uri: string; name: string; artist: string; album: string; durationMs: number }[]
55}
56
57// One day in the history pane
58export type HistoryDay = { day: string; usd: number; tokens: number }
59
60export type Snapshot = {
61  folder: string
62  // The path below the repo's root, as "/mod/x", when the session runs in a subfolder
63  subdir: string
64  git: {
65    root: string | null
66    branch: string
67    changed: number
68    // The upstream branch, as "origin/main"; null when the branch has none
69    upstream: string | null
70    hasUpstream: boolean
71    ahead: number
72    behind: number
73    // Lines in uncommitted work, and in commits not yet pushed
74    added: number
75    removed: number
76    unpushedAdded: number
77    unpushedRemoved: number
78    stashed: number
79    // Epoch ms of the last commit, null in a repo with none
80    lastCommitAt: number | null
81  } | null
82  model: string
83  // The effort level, as the last prompt or tool call carried it
84  effort?: string
85  // The Claude plan, as "Max 5x" or "Pro"
86  plan?: string
87  startedAt: number
88  now: number
89  // The local date, "2026-10-02", and time, "11:42 AM"
90  day: string
91  time: string
92  context: { percent: number; tokens: number; window: number } | null
93  costUsd: number | null
94  todayUsd: number | null
95  // Tokens read and written, cache included, as counted since the mod was installed
96  tokens: { session: number; today: number; rolling: number }
97  // Spend over the last 30 days, today included
98  rollingUsd: number | null
99  limits: Limit[]
100  // Subagents running right now
101  agents: number
102  // The repo's switches: whether Claude may commit, and push, without asking; null outside a repo
103  gitAuto: { commit: boolean; push: boolean } | null
104}
105
106// Kept in $.store across sessions: spend per day, and each session's last reading
107export type CostLedger = {
108  days: Record<string, number>
109  sessions: Record<string, number>
110}
111
112declare module 'claude-code' {
113  interface PluginState {
114    'statusline-band': { snap: Snapshot | null; isCompacting: boolean; frame: number; isBlinking: boolean; lastTurn: TurnTokens | null; history: HistoryDay[]; mood: Mood; moodTick: number; music: Music | null; danceTick: number; search: SpotifySearch; musicCompact: boolean; musicOn: boolean; lyrics: Lyrics | null; lyricsOn: boolean; cardCompact: boolean }
115  }
116}
117