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

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.
▲ out ~7h appears when the pace would use a limit up before it resets. Then context usage as a bar.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.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 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 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.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.
/plugin marketplace add AsyrafHussin/claude-code-statusline
/plugin install statusline-band@claude-code-statusline
Each is a row in /config, or run /plugin configure statusline-band@claude-code-statusline.
| Setting | Default | What it does |
|---|---|---|
initials | empty | Up to two letters on Clawd's shirt |
cardColor | #0a0a0a | Background of the band; empty ("") for none, so it takes the terminal's own |
padRows | 1 | Empty rows above and below the stats, 0 to 2 |
historyDays | 30 | Days the rolling cost and the history pane cover, 7 to 62 |
noAttribution | false | Leave Co-Authored-By, Claude-Session and the Claude Code footer out of commits and pull requests |
gitStrict | true | On: 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 |
spotify | true | While Spotify runs, show what it plays under the band, with Beatbot dancing, a progress bar and controls (macOS) |
plan | empty | Your 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) |
spotifyClientId | empty | Client ID of your own app at developer.spotify.com/dashboard, for searching Spotify |
spotifyClientSecret | empty | Client 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) |
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 folderstatus --porcelain=v2 --branch --show-stash for the branch, its upstream, commits ahead and behind, uncommitted files and stashesdiff --shortstat HEAD and diff --shortstat @{upstream}...HEAD for the lines uncommitted and unpushedlog -1 --format=%ct for the time of the last commitThese 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>).~/.claude.json: only your account's plan type and rate-limit tier, once per load, to show the plan.statusline-band-spotify, the Spotify client secret, when you search.All of it stays in the plugin's own store on your machine, nothing else:
spotify and lyrics switches, on or offgit push itself, to the branch's upstream, with the git credentials you already have.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.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.$.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).hooks/register.tsx 1093 lines1// 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}
1093hooks/band.tsx 699 lines1// 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}
699hooks/layout.ts 55 lines1// 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}
55hooks/logic.ts 684 lines1// 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
684hooks/pane.tsx 141 lines1// 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}
141hooks/state.ts 40 lines1// 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}
40hooks/spotify-logo.ts 57 lines1// 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'))
57types/index.d.ts 117 lines1export 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