One tidy band above the prompt: usage limits and spend, context weather, what the last turn did, and your open tasks, each on its own labelled row, with…

Small Claude Code mods: a status band for your usage limits, a switchboard for your mods, a button that hides them all, a guard that keeps your name out of public pushes, and OpenAI Codex as native subagents.
Each mod installs on its own. Pick the ones you want.

A real screenshot from the Claude desktop app. The dollar and token figures are pixelated.
/plugin marketplace add finalpantasy/claude-mod-pack
/plugin install status-band@claude-mod-pack
/plugin install claude-mod-hider@claude-mod-pack
/plugin install push-guard@claude-mod-pack
/plugin install codex-bridge@claude-mod-pack
Restart Claude Code after installing. Mods need Claude Code with mod support (function hooks).
status-band)One tidy band above the prompt, each row labelled:

/mods opens a panel listing every installed mod with its full description and an on/off switch, plus a switch for each band row. /band hides or shows the band.

These two pictures are drawn from status-band's own output with sample data: its screenshot test records exactly what the mod draws, and tools/shots.py lays that out in the desktop app's dark style and photographs it. The mods named focus-timer and pixel-pet are made-up examples.
claude-mod-hider)One small arrow in the corner of the band. Click it and every mod above the prompt folds down to a single ▸ mods button; click again and they are back. It remembers your choice across sessions. More, including how other mods can opt in.
push-guard)Stops a git push Claude runs when the commits about to leave would publish something about you, and tells Claude exactly where it is so it can fix the files and the history first. It checks only what is new in the push: added lines, file names, commit messages and author names.
What it looks for is yours, never anyone else's:
/plugin configure push-guard@claude-mod-pack). Empty until you fill it in.C:\Users\you, /c/Users/you, escaped JSON, /home/you).What Claude reads when a push is stopped (from the mod's tests, with a made-up name):
Push Guard stopped this push: one place in the commits would publish something private.
- README.md:13: "Alex Rivera"
Remove it from the files and from the commit history (amend or rebase the commits, and fix author names with the repo's own git identity), then push again.
If it is meant to be public, the person can run /push-guard off to push without the check for this session.
Private GitHub repos are skipped when the GitHub CLI can see they are private; any other host is checked. /push-guard says what it checks, and /push-guard off lets a push through for the rest of the session.
codex-bridge)Runs OpenAI Codex models as real Claude Code subagents. Each model becomes an agent type (codex-bridge:sol, codex-bridge:sol-ro for read-only, and so on) that the Agent tool can start like any other. You get the same task row, timer, token count, tool rows, background notice and SendMessage follow-ups as a native agent, which is handy for a second opinion from a different model family.
models, as name=model pairs).These are free hobby projects. If one saves you time, you can buy me a coffee on Ko-fi.
MIT
hooks/register.tsx 635 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Hours, Ledger, ModInfo, Reading, TaskList, Turn, UsageSnap } from '../types'
5import { ROWS } from './mods'
6import {
7 BAR_H, BAR_W, CONTEXT_COMPACT, RING, WORK_STATE, activityProfile, addActivity, addCall, addCost, addReading, addTokens, applyWorkState, barSvg, blankSvg, contextColor,
8 dayKey, dollars, duration, emptyLedger, forecast, fromTodos, isEmptyTurn, levelColor, lineTotals, newTurn, parseHistory, parseLedger,
9 resetsIn, ringSvg, short, snapOf, taskSummary, tokensShort, totalsOf, turnTokens, turnUnits, weekPace,
10} from './logic'
11
12// One band above the prompt, rows in a fixed order: week, 5-hour, context, spend, tokens, last turn, tasks.
13// It reads on events (session start, each prompt, each turn's end) and every 30 seconds for the usage row;
14// a reading that changed nothing redraws nothing.
15
16const usage = atom({ plugin: 'status-band', key: 'usage' } as const, null as UsageSnap | null)
17const readings = atom({ plugin: 'status-band', key: 'readings' } as const, [] as Reading[])
18const turn = atom({ plugin: 'status-band', key: 'turn' } as const, null as Turn | null)
19const lastTurn = atom({ plugin: 'status-band', key: 'lastTurn' } as const, null as Turn | null)
20const tasks = atom({ plugin: 'status-band', key: 'tasks' } as const, null as TaskList | null)
21const isHidden = atom({ plugin: 'status-band', key: 'isHidden' } as const, false)
22const rowsOff = atom({ plugin: 'status-band', key: 'rowsOff' } as const, [] as string[])
23const mods = atom({ plugin: 'status-band', key: 'mods' } as const, null as ModInfo[] | null)
24const ledger = atom({ plugin: 'status-band', key: 'ledger' } as const, emptyLedger() as Ledger)
25const others = atom({ plugin: 'status-band', key: 'others' } as const, {} as Record<string, { mtimeMs: number; ledger: Ledger }>)
26const compactState = atom({ plugin: 'status-band', key: 'compact' } as const, 'idle' as 'idle' | 'queued' | 'running')
27// Claude Mod Hider's switch: while it is on, the band passes so nothing of ours shows.
28const modsHidden = atom({ plugin: 'claude-mod-hider', key: 'hidden' } as const, false)
29// Mods draw in an order nobody sets, so Claude Mod Hider's arrow could land under the band: the band draws it instead.
30const hiderArrow = atom({ plugin: 'status-band', key: 'hiderArrow' } as const, false)
31const history = atom({ plugin: 'status-band', key: 'history' } as const, null as { until: number; hours: Hours } | null)
32const handoffState = atom({ plugin: 'status-band', key: 'handoff' } as const, 'idle' as 'idle' | 'queued' | 'running')
33const MODS_PANE = 'status-band-mods'
34
35const LABEL_W = 9 // cells of the label column, so every row's value starts at the same place
36const MUTED = '#888780'
37const GREEN = '#1D9E75'
38const RED = '#E24B4A'
39const BLUE = '#378ADD'
40const REFRESH_MS = 30_000
41const TILE = 'rgba(136, 135, 128, 0.16)'
42const TILE_W = 12 // cells, so the four tiles are even
43
44async function readUsage($: EngineInterface, withReading: boolean): Promise<void> {
45 const u = await $.session.usage()
46 const snap = snapOf(u.rateLimits ?? [], u.cost?.usd ?? 0)
47 if (JSON.stringify(snap) !== JSON.stringify(await read($, usage))) await update($, usage, () => snap)
48 await saveCost($, snap.usd)
49 const c = u.context
50 const tokens = c?.tokens ?? 0
51 if (withReading && c && c.window && tokens > 0) {
52 const window = c.window
53 const percent = Math.round(c.percent ?? (tokens / window) * 100)
54 await update($, readings, list => addReading(list, { tokens, window, percent }))
55 }
56}
57
58// Compact now. The engine refuses to compact while a turn runs and any hook may veto it, so a press
59// during a turn waits for the turn to end, and every outcome says so in a toast instead of vanishing.
60async function compactNow($: EngineInterface): Promise<void> {
61 if ((await read($, compactState)) !== 'idle') return
62 if (await read($, turn)) {
63 await update($, compactState, () => 'queued')
64 $.ui.toast('Claude is working. The chat compacts as soon as this turn ends.')
65 // Checked once a second, only while queued; the turn's end clears the band's running turn.
66 const wait = $.clock.every(1000, () => {
67 void (async () => {
68 if ((await read($, compactState)) !== 'queued') return wait.cancel()
69 if (await read($, turn)) return
70 wait.cancel()
71 await runCompact($, 3)
72 })().catch(() => undefined)
73 })
74 return
75 }
76 await runCompact($, 1)
77}
78
79// The desktop app and other SDK hosts have no between-turns compaction for a plugin yet: there the
80// /compact command does it, run as the person would type it. Once seen, the button goes straight to it.
81let compactByPrompt = false
82const NO_PLUGIN_COMPACT = /headless|not available/i
83
84async function compactWithPrompt($: EngineInterface): Promise<void> {
85 try {
86 await $.command.run({ command: 'compact' })
87 $.ui.toast('Compacting: /compact is running in this chat.')
88 } catch (err) {
89 $.ui.toast(`Could not compact (${err instanceof Error ? err.message : String(err)}). Type /compact to compact this chat.`)
90 }
91}
92
93async function runCompact($: EngineInterface, tries: number): Promise<void> {
94 await update($, compactState, () => 'running')
95 if (compactByPrompt) {
96 await compactWithPrompt($)
97 await update($, compactState, () => 'idle')
98 return
99 }
100 try {
101 const r = await $.session.compact()
102 $.ui.toast('skip' in r && typeof r.skip === 'string' ? `Not compacted: ${r.skip}` : 'Compacted. The next turns are cheaper.')
103 } catch (err) {
104 const message = err instanceof Error ? err.message : String(err)
105 if (NO_PLUGIN_COMPACT.test(message)) {
106 compactByPrompt = true
107 await compactWithPrompt($)
108 } else if (tries > 1) {
109 // Just after a turn the engine can still count it as running for a moment: try again shortly.
110 await $.clock.sleep(1500)
111 return runCompact($, tries - 1)
112 } else {
113 $.ui.toast(`Could not compact: ${message}`)
114 }
115 }
116 await update($, compactState, () => 'idle')
117}
118
119const compactLabel = (s: 'idle' | 'queued' | 'running') => (s === 'queued' ? 'Compact queued: after this turn' : s === 'running' ? 'Compacting…' : 'Compact now')
120const handoffLabel = (s: 'idle' | 'queued' | 'running') => (s === 'queued' ? 'Handoff queued' : s === 'running' ? 'Handing off…' : 'Handoff')
121
122// Handoff. A prompt that has Claude write a handoff packet and move the work on: to the
123// coordinator that started this chat, or to a fresh chat on the model and effort the rest of the work needs.
124// The engine runs a plugin's prompt once the session is idle, so a press mid-turn queues it.
125export const HANDOFF_PROMPT = [
126 'Hand off this chat.',
127 '1. Write a handoff packet: the goal, what is done (with evidence), what is open, the next concrete step, files and branches touched, decisions made, and anything blocked. Save it with work_state_write when that tool is available, otherwise in HANDOFF.md at the repo root. If a git repo has uncommitted changes, make a WIP commit (do not push).',
128 '2. If a coordinator started this chat, send it the packet with SendMessage and stop there.',
129 '3. Otherwise open a fresh chat to continue: start_session when it is available, else a spawn_task chip. Pick the cheapest model and effort that will finish the rest (Sonnet, medium effort by default; Opus only when the work needs it, and say why). Put the whole packet in its first prompt so it continues without this chat, then open it in split view.',
130 '4. Reply with one line: where the work went, and a link to the new chat.',
131].join('\n')
132
133const isHandoff = (text: unknown) => typeof text === 'string' && text.startsWith('Hand off this chat.')
134
135// Two quick presses both read 'idle' before either writes, so a plain flag takes the press first.
136let isHandoffPressed = false
137
138async function handoffNow($: EngineInterface): Promise<void> {
139 if (isHandoffPressed) return
140 isHandoffPressed = true
141 try {
142 if ((await read($, handoffState)) !== 'idle') return
143 await update($, handoffState, () => 'queued')
144 } finally {
145 isHandoffPressed = false
146 }
147 $.ui.toast((await read($, turn)) ? 'Handoff queued: it starts as soon as this turn ends.' : 'Handing off: Claude writes the packet and opens the next chat.')
148 try {
149 await $.prompt.submit({ text: HANDOFF_PROMPT, asUser: true })
150 } catch (err) {
151 await update($, handoffState, () => 'idle')
152 $.ui.toast(`Could not hand off: ${err instanceof Error ? err.message : String(err)}`)
153 }
154}
155
156// The week's pace in the person's own hours: the seed mined from past transcripts, then every session's
157// ledger from where the seed stops.
158async function profileOf($: EngineInterface, now: number) {
159 const seed = await read($, history)
160 const mine = await read($, ledger)
161 const rest = Object.values(await read($, others)).map(o => o.ledger)
162 const from = seed?.until ?? 0
163 return activityProfile([
164 ...(seed ? [{ hours: seed.hours, before: seed.until }] : []),
165 ...[mine, ...rest].map(l => ({ hours: l.hours ?? {}, from })),
166 ], now)
167}
168
169// The spend ledger: a file per session under ~/.claude/status-band/spend, written only when it changed.
170async function spendDir($: EngineInterface): Promise<string> {
171 const home = slash((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '')
172 return `${home}/.claude/status-band/spend`
173}
174
175async function writeLedger($: EngineInterface, next: Ledger): Promise<void> {
176 await update($, ledger, () => next)
177 await $.fs.write(`${await spendDir($)}/${await $.session.id()}.json`, JSON.stringify(next)).catch(() => undefined)
178}
179
180async function saveCost($: EngineInterface, usd: number): Promise<void> {
181 const was = await read($, ledger)
182 const next = addCost(was, usd, dayKey(await $.clock.now()))
183 if (next !== was) await writeLedger($, next)
184}
185
186/** Reads the other sessions' ledgers, only the files that changed since they were last read; this session's own on the first read. */
187async function readOthers($: EngineInterface, isFirst: boolean): Promise<void> {
188 const dir = await spendDir($)
189 const mine = `${await $.session.id()}.json`
190 const entries = await $.fs.list(dir).catch(() => [])
191 const had = await read($, others)
192 const next: Record<string, { mtimeMs: number; ledger: Ledger }> = {}
193 let changed = false
194 for (const f of entries) {
195 if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
196 if (f.name === mine) {
197 // A resumed session carries on from its own file.
198 if (isFirst) {
199 const own = parseLedger(await $.fs.read(`${dir}/${f.name}`).catch(() => ''))
200 if (own) await update($, ledger, () => own)
201 }
202 continue
203 }
204 const old = had[f.name]
205 if (old && old.mtimeMs === f.mtimeMs) {
206 next[f.name] = old
207 continue
208 }
209 const l = parseLedger(await $.fs.read(`${dir}/${f.name}`).catch(() => ''))
210 if (l) next[f.name] = { mtimeMs: f.mtimeMs, ledger: l }
211 changed = true
212 }
213 if (changed || Object.keys(next).length !== Object.keys(had).length) await update($, others, () => next)
214}
215
216// /mods: read only when the panel opens; a switch runs `claude plugin enable|disable` once, when pressed.
217type Installed = { plugins?: Record<string, { installPath?: string }[]> }
218
219const slash = (p: string) => p.replace(/\\/g, '/')
220
221/** Mods are plugins whose hooks.json names a hooks module; skills-only plugins are left out. */
222export async function listMods($: EngineInterface): Promise<ModInfo[]> {
223 const home = slash((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '')
224 const settings = await $.settings.read()
225 const enabled = (settings.enabledPlugins ?? {}) as Record<string, unknown>
226 let installed: Installed = {}
227 try {
228 installed = JSON.parse(await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`)) as Installed
229 } catch {
230 return []
231 }
232 const mods: ModInfo[] = []
233 for (const [id, installs] of Object.entries(installed.plugins ?? {})) {
234 const root = installs?.[0]?.installPath
235 if (!root) continue
236 const dir = slash(root)
237 try {
238 const hooks = JSON.parse(await $.fs.read(`${dir}/hooks/hooks.json`)) as { modules?: unknown }
239 if (!Array.isArray(hooks.modules) || hooks.modules.length === 0) continue
240 } catch {
241 continue
242 }
243 let description = ''
244 try {
245 description = String((JSON.parse(await $.fs.read(`${dir}/.claude-plugin/plugin.json`)) as { description?: unknown }).description ?? '')
246 } catch {
247 // a manifest that will not read leaves the line empty
248 }
249 mods.push({ id, name: id.split('@')[0] ?? id, description, isOn: enabled[id] === true, pending: null })
250 }
251 return mods.sort((a, b) => a.name.localeCompare(b.name))
252}
253
254/** Turns a mod on or off for the next session; the error's first line on failure. */
255export async function switchMod($: EngineInterface, id: string, on: boolean): Promise<string | null> {
256 try {
257 const run = await $.process.run(['claude', 'plugin', on ? 'enable' : 'disable', id], { timeoutMs: 60_000 })
258 return run.exitCode === 0 ? null : (run.stderr || run.stdout).trim().split('\n')[0] || `exit ${run.exitCode}`
259 } catch (err) {
260 return err instanceof Error ? err.message : String(err)
261 }
262}
263
264export const register: Register = on => {
265 on('session.start', async ($, e, next) => {
266 const started = await next(e)
267 // A session with no interactive terminal (the desktop app, the SDK, -p) compacts through a /compact prompt.
268 compactByPrompt = (e as { isInteractive?: boolean }).isInteractive === false
269 // A compact cut off by a reload must not leave the button stuck.
270 if ((await read($, compactState)) !== 'idle') await update($, compactState, () => 'idle')
271 if ((await read($, handoffState)) !== 'idle') await update($, handoffState, () => 'idle')
272 await $.command.register({ name: 'band', description: 'Status band above the prompt: /band hides or shows it' })
273 await $.command.register({ name: 'mods', description: 'Switch mods and the status band rows on or off' })
274 const enabled = ((await $.settings.read().catch(() => ({}))) as { enabledPlugins?: Record<string, unknown> }).enabledPlugins ?? {}
275 await update($, hiderArrow, () => Object.entries(enabled).some(([id, isEnabled]) => id.startsWith('claude-mod-hider@') && isEnabled === true))
276 // The row switches kept from earlier sessions; none kept, every row shows.
277 const saved = await $.store.get('rowsOff').catch(() => undefined)
278 if (Array.isArray(saved)) await update($, rowsOff, () => saved.filter((k): k is string => typeof k === 'string'))
279 await readOthers($, true).catch(() => undefined)
280 const seed = parseHistory(await $.fs.read(`${(await spendDir($)).replace(/spend$/, 'activity')}/history.json`).catch(() => ''))
281 await update($, history, () => seed)
282 await readUsage($, true).catch(() => undefined)
283 $.clock.every(REFRESH_MS, () => void readUsage($, false).catch(() => undefined))
284 return started
285 })
286
287 on('prompt.submit', async ($, e, next) => {
288 void readUsage($, false).catch(() => undefined)
289 return next(e)
290 })
291
292 on('turn.start', async ($, e, next) => {
293 if ((e as { agentId?: string }).agentId === undefined) {
294 const now = await $.clock.now()
295 await update($, turn, () => newTurn(now))
296 if (isHandoff((e as { text?: unknown }).text)) await update($, handoffState, () => 'running')
297 }
298 return next(e)
299 })
300
301 // Every call once it has run, subagents' included; a refused call did nothing.
302 on('tool.call', async ($, e, next) => {
303 const ran = await next(e)
304 if (ran.deny !== undefined) return ran
305 const { tool, tool_use_id: _, agentId, ...input } = e as unknown as Record<string, unknown> & { tool: string; agentId?: string }
306 const isError = ran.isError === true
307 if (!isError && WORK_STATE.test(tool)) await update($, tasks, was => applyWorkState(was, input))
308 else if (!isError && tool === 'TodoWrite') {
309 const list = fromTodos(input.todos)
310 if (list) await update($, tasks, () => list)
311 }
312 const was = await read($, turn)
313 if (was && was.durationMs === null) {
314 await update($, turn, () => addCall(was, { tool, input, result: ran.result, isError }, agentId === undefined))
315 }
316 return ran
317 })
318
319 on('turn.complete', async ($, e, next) => {
320 const done = await next(e)
321 // Every turn's tokens count, a subagent's too.
322 const turnUsage = (e as { usage?: Parameters<typeof turnTokens>[0] }).usage
323 const used = turnTokens(turnUsage)
324 if (used > 0) {
325 const at = await $.clock.now()
326 // The hour it ran in feeds the activity profile the week's pace is measured in.
327 await writeLedger($, addActivity(addTokens(await read($, ledger), used, dayKey(at)), turnUnits(turnUsage), at))
328 }
329 if (e.agentId !== undefined) return done
330 if ((await read($, handoffState)) === 'running') await update($, handoffState, () => 'idle')
331 const was = await read($, turn)
332 if (was) {
333 const finished = { ...was, durationMs: e.durationMs ?? (await $.clock.now()) - was.startedAt }
334 if (!isEmptyTurn(finished)) await update($, lastTurn, () => finished)
335 await update($, turn, () => null)
336 }
337 await readUsage($, true).catch(() => undefined)
338 await readOthers($, false).catch(() => undefined)
339 return done
340 })
341
342 on('command.run', { command: 'mods' }, async $ => {
343 await update($, mods, () => null)
344 await $.ui.open({ id: MODS_PANE, title: 'Mods', focus: true })
345 const list = await listMods($)
346 await update($, mods, () => list)
347 return { text: `Mods panel opened: ${list.length} mods.` }
348 })
349
350 on('ui.render', { component: 'Pane', requestId: MODS_PANE }, async ($, e) => {
351 const { Box, Text, Button } = $.ui.resolve(e)
352 const off = await read($, rowsOff)
353 const list = await read($, mods)
354 const toggleRow = async (key: string) => {
355 const next = off.includes(key) ? off.filter(k => k !== key) : [...off, key]
356 await update($, rowsOff, () => next)
357 await $.store.set('rowsOff', next)
358 }
359 const toggleMod = async (m: ModInfo) => {
360 const turnOn = !m.isOn
361 const error = await switchMod($, m.id, turnOn)
362 await update($, mods, ms =>
363 (ms ?? []).map((x): ModInfo => (x.id !== m.id ? x : error ? { ...x, pending: 'failed', error } : { ...x, isOn: turnOn, pending: turnOn ? 'on' : 'off' })))
364 }
365 const state = (isOn: boolean) => <Text color={isOn ? GREEN : MUTED} bold>{isOn ? 'on ' : 'off'}</Text>
366 const cState = await read($, compactState)
367 const hState = await read($, handoffState)
368 const learned = await profileOf($, await $.clock.now())
369 return (
370 <Box flexDirection="column" gap={1}>
371 <Box flexDirection="column">
372 <Text bold>This chat</Text>
373 <Box flexDirection="row" gap={1}>
374 <Button key="p-compact" label={compactLabel(cState)} plain onPress={() => void compactNow($)} />
375 <Button key="p-handoff" label={handoffLabel(hState)} plain onPress={() => void handoffNow($)} />
376 </Box>
377 <Text dimColor>{learned.days > 0 ? `Week pace: learned from ${learned.days} days of your activity, recent weeks counting most.` : 'Week pace: midnight to 8am counted as quiet until it has 3 days of your activity.'}</Text>
378 </Box>
379 <Box flexDirection="column">
380 <Text bold>Band rows</Text>
381 <Text dimColor>Change right away.</Text>
382 {ROWS.map(r => (
383 <Box key={`row-${r.key}`} flexDirection="row" gap={1}>
384 <Button key={`r-${r.key}`} label={off.includes(r.key) ? 'Turn on' : 'Turn off'} plain onPress={() => void toggleRow(r.key)} />
385 {state(!off.includes(r.key))}
386 <Text>{r.label}</Text>
387 </Box>
388 ))}
389 </Box>
390 <Box flexDirection="column">
391 <Text bold>Mods</Text>
392 <Text dimColor>Switches apply from your next session.</Text>
393 {list === null ? <Text dimColor>Reading your mods…</Text> : null}
394 {list?.length === 0 ? <Text dimColor>No mods found.</Text> : null}
395 {(list ?? []).map(m => (
396 <Box key={`mod-${m.id}`} flexDirection="column">
397 <Box flexDirection="row" gap={1}>
398 <Button key={`m-${m.id}`} label={m.isOn ? 'Turn off' : 'Turn on'} plain onPress={() => void toggleMod(m)} />
399 {state(m.isOn)}
400 <Text bold>{m.name}</Text>
401 {m.pending === 'on' || m.pending === 'off' ? <Text color={BLUE}>{`(${m.pending} next session)`}</Text> : null}
402 {m.pending === 'failed' ? <Text color={RED}>{`could not switch: ${m.error ?? ''}`}</Text> : null}
403 </Box>
404 {m.description ? (
405 <Box marginLeft={5}>
406 <Text dimColor wrap="wrap">{m.description}</Text>
407 </Box>
408 ) : null}
409 </Box>
410 ))}
411 </Box>
412 </Box>
413 )
414 })
415
416 on('command.run', { command: 'band' }, async $ => {
417 const hidden = !(await read($, isHidden))
418 await update($, isHidden, () => hidden)
419 return { text: hidden ? 'Status band hidden. /band brings it back.' : 'Status band shown.' }
420 })
421
422 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
423 if (e.props.hasSurvey || (await read($, isHidden)) || (await read($, modsHidden))) return next(e)
424 const ui = $.ui.resolve(e)
425 const { Box, Text } = ui
426 const Svg = e.surface === 'desktop' && 'Svg' in ui ? ui.Svg : null
427 const Button = 'Button' in ui ? ui.Button : null
428 const now = await $.clock.now()
429 const u = await read($, usage)
430 const rs = await read($, readings)
431 const t = await read($, lastTurn)
432 const todo = taskSummary(await read($, tasks))
433 const off = await read($, rowsOff)
434 const isOn = (key: string) => !off.includes(key)
435
436 const row = (key: string, label: string, ...cells: RenderChildren[]) => (
437 <Box key={key} flexDirection="row" gap={1} alignItems="center">
438 <Box width={LABEL_W} flexShrink={0}>
439 <Text dimColor>{label}</Text>
440 </Box>
441 {cells}
442 </Box>
443 )
444 // Every row's bar is the same size; a row with no bar keeps the space, so the text lines up.
445 const bar = (key: string, source: string | null, alt: string) =>
446 Svg ? <Svg key={key} source={source ?? blankSvg()} alt={alt} width={BAR_W} height={BAR_H} /> : null
447 const dot = (k: string) => <Text key={k} color={MUTED}>{' · '}</Text>
448 const rows: RenderChildren[] = []
449
450 const last = rs[rs.length - 1]
451 const mine = await read($, ledger)
452 const rest = Object.values(await read($, others)).map(o => o.ledger)
453 const sum = totalsOf([mine, ...rest], now)
454 const isFull = !!last && last.percent >= CONTEXT_COMPACT
455 const cState = await read($, compactState)
456 const hState = await read($, handoffState)
457 // Compact now and Handoff always show; Compact now is the loud one once the context is full.
458 const actions = Button
459 ? [
460 <Button key="compact" label={compactLabel(cState)} variant={isFull && cState === 'idle' ? 'primary' : 'secondary'} onPress={() => void compactNow($)} />,
461 <Button key="handoff" label={handoffLabel(hState)} variant="secondary" onPress={() => void handoffNow($)} />,
462 ]
463 : null
464 let isActionsPlaced = false
465
466 if (Svg) {
467 // The desktop: three rings, four money tiles, and a line under them only when something needs you.
468 const ring = (key: string, source: string, alt: string, label: string, sub: string, color?: string) => (
469 <Box key={key} flexDirection="column" alignItems="center">
470 <Svg source={source} alt={alt} width={RING} height={RING} />
471 <Text color={color ?? MUTED} bold={color !== undefined}>{label}</Text>
472 {sub ? <Text color={MUTED}>{sub}</Text> : null}
473 </Box>
474 )
475 const rings: RenderChildren[] = []
476 const pace = u && u.week >= 0 ? weekPace(u.week, u.weekResets, now, await profileOf($, now)) : null
477 const weekLeft = u ? resetsIn(u.weekResets, now) : ''
478 if (isOn('week') && u && pace) {
479 rings.push(ring('rw', ringSvg(u.week, pace.color, [{ at: pace.elapsed, color: '#D3D1C7' }]), `weekly limit ${Math.round(u.week)}% used`,
480 pace.level === 'ok' ? 'week' : 'week ⚠', weekLeft ? `resets ${weekLeft}` : '', pace.level === 'ok' ? undefined : pace.color))
481 }
482 if (isOn('usage') && u && u.fiveHour >= 0) {
483 const five = resetsIn(u.fiveResets, now)
484 rings.push(ring('r5', ringSvg(u.fiveHour, levelColor(u.fiveHour)), `5-hour window ${Math.round(u.fiveHour)}% used`, '5-hour', five ? `resets ${five}` : ''))
485 }
486 if (isOn('context') && last) {
487 rings.push(ring('rc', ringSvg(last.percent, contextColor(last.percent), [{ at: CONTEXT_COMPACT, color: '#E24B4A' }]), `context ${last.percent}% full`,
488 isFull ? 'context ⚠' : 'context', `${short(last.tokens)} / ${short(last.window)}`, isFull ? RED : undefined))
489 }
490 const showSpend = isOn('spend') && !!u
491 const showTokens = isOn('tokens')
492 const tile = (key: string, label: string, usd: number, tokens: number) => (
493 <Box key={key} flexDirection="column" paddingX={1} backgroundColor={TILE} minWidth={TILE_W}>
494 <Text color={MUTED}>{label}</Text>
495 {showSpend ? <Text bold>{dollars(usd)}</Text> : null}
496 {showTokens ? <Text color={MUTED}>{`${tokensShort(tokens)} tok`}</Text> : null}
497 </Box>
498 )
499 const tiles = showSpend || showTokens
500 ? [tile('t0', 'this chat', u ? u.usd : 0, mine.tokens), tile('t1', 'today', sum.today.usd, sum.today.tokens),
501 tile('t2', '7 days', sum.week.usd, sum.week.tokens), tile('t3', 'all time', sum.all.usd, sum.all.tokens)]
502 : []
503 if (rings.length || tiles.length) {
504 rows.push(
505 <Box key="dash" flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
506 {rings.length ? <Box key="rings" flexDirection="row" gap={2}>{rings}</Box> : null}
507 {tiles.length ? <Box key="tiles" flexDirection="row" gap={1}>{tiles}</Box> : null}
508 {actions}
509 </Box>,
510 )
511 isActionsPlaced = true
512 }
513 if (isOn('week') && pace && pace.level !== 'ok') {
514 rows.push(<Text key="wwarn" color={pace.color} wrap="truncate-end">{`⚠ Week: ${pace.word}${weekLeft ? ` · resets in ${weekLeft}` : ''}`}</Text>)
515 }
516 if (isOn('context') && isFull && last) {
517 rows.push(<Text key="cwarn" color={RED} wrap="truncate-end">{`⚠ Context ${last.percent}% full: compact to keep the next turns cheap`}</Text>)
518 }
519 } else {
520 // The weekly limit leads: it is the one that runs out for days.
521 if (isOn('week') && u && u.week >= 0) {
522 const pace = weekPace(u.week, u.weekResets, now, await profileOf($, now))
523 const week = resetsIn(u.weekResets, now)
524 rows.push(row('week', 'week',
525 bar('bw', barSvg(u.week, pace.color, [{ at: pace.elapsed, color: '#D3D1C7' }]), `weekly limit ${Math.round(u.week)}% used`),
526 <Text key="wk" wrap="truncate-end">
527 <Text color={pace.color} bold>{`${Math.round(u.week)}% used`}</Text>
528 <Text color={pace.level === 'ok' ? MUTED : pace.color} bold={pace.level !== 'ok'}>{` ${pace.level === 'ok' ? '' : '⚠ '}${pace.word}`}</Text>
529 {week ? <Text color={MUTED}>{` · resets in ${week}`}</Text> : null}
530 </Text>,
531 ))
532 }
533
534 if (isOn('usage') && u && u.fiveHour >= 0) {
535 const five = resetsIn(u.fiveResets, now)
536 rows.push(row('usage', '5-hour',
537 bar('b5', barSvg(u.fiveHour, levelColor(u.fiveHour)), `5-hour window ${Math.round(u.fiveHour)}%`),
538 <Text key="use" wrap="truncate-end">
539 <Text color={levelColor(u.fiveHour)} bold>{`${Math.round(u.fiveHour)}% used`}</Text>
540 {five ? <Text color={MUTED}>{` resets in ${five}`}</Text> : null}
541 </Text>,
542 ))
543 }
544
545 if (isOn('context') && last) {
546 const prev = rs[rs.length - 2]
547 const delta = prev ? last.tokens - prev.tokens : 0
548 const color = contextColor(last.percent)
549 rows.push(row('context', 'context',
550 bar('bc', barSvg(last.percent, color, [{ at: CONTEXT_COMPACT, color: '#E24B4A' }]), `context ${last.percent}% full`),
551 <Text key="ctx" wrap="truncate-end">
552 <Text color={color} bold>{`${last.percent}% full`}</Text>
553 <Text color={MUTED}>{` ${short(last.tokens)} / ${short(last.window)}`}</Text>
554 {delta !== 0 ? <Text color={MUTED}>{` ${delta > 0 ? '▲ +' : '▼ '}${short(Math.abs(delta))} last turn`}</Text> : null}
555 <Text color={isFull ? RED : MUTED} bold={isFull}>{` ${isFull ? '⚠ ' : ''}${forecast(last.percent)}`}</Text>
556 </Text>,
557 actions,
558 ))
559 isActionsPlaced = true
560 }
561
562 if (isOn('spend') && u) {
563 rows.push(row('spend', 'spend', bar('bs', null, ''),
564 <Text key="sp" wrap="truncate-end">
565 <Text bold>{dollars(u.usd)}</Text><Text color={MUTED}>{' this chat'}</Text>{dot('s1')}
566 <Text bold>{dollars(sum.today.usd)}</Text><Text color={MUTED}>{' today'}</Text>{dot('s2')}
567 <Text bold>{dollars(sum.week.usd)}</Text><Text color={MUTED}>{' 7d'}</Text>{dot('s3')}
568 <Text bold>{dollars(sum.all.usd)}</Text><Text color={MUTED}>{' all time'}</Text>
569 </Text>,
570 ))
571 }
572 if (isOn('tokens') && sum.all.tokens > 0) {
573 rows.push(row('tokens', 'tokens', bar('bt', null, ''),
574 <Text key="tk" wrap="truncate-end">
575 <Text bold>{tokensShort(mine.tokens)}</Text><Text color={MUTED}>{' this chat'}</Text>{dot('t1')}
576 <Text bold>{tokensShort(sum.today.tokens)}</Text><Text color={MUTED}>{' today'}</Text>{dot('t2')}
577 <Text bold>{tokensShort(sum.week.tokens)}</Text><Text color={MUTED}>{' 7d'}</Text>{dot('t3')}
578 <Text bold>{tokensShort(sum.all.tokens)}</Text><Text color={MUTED}>{' all time'}</Text>
579 </Text>,
580 ))
581 }
582
583 }
584
585 if (isOn('turn') && t) {
586 const { added, removed } = lineTotals(t)
587 const parts: RenderChildren[] = [<Text key="ok" color={t.failed ? RED : GREEN}>{t.failed ? '! ' : '✓ '}</Text>, <Text key="d">{duration(t.durationMs ?? 0)}</Text>]
588 if (t.files.length) {
589 parts.push(dot('d1'), <Text key="ed">{`edited ${t.files.length} `}</Text>, <Text key="ad" color={GREEN}>{`+${added}`}</Text>, <Text key="rm" color={RED}>{` −${removed}`}</Text>)
590 }
591 if (t.commands) parts.push(dot('d2'), <Text key="cm">{`${t.commands} command${t.commands === 1 ? '' : 's'}`}</Text>)
592 if (t.failed) parts.push(dot('d3'), <Text key="fl" color={RED}>{`${t.failed} failed`}</Text>)
593 if (t.reads) parts.push(dot('d4'), <Text key="rd" color={MUTED}>{`${t.reads} read${t.reads === 1 ? '' : 's'}`}</Text>)
594 if (t.agents) parts.push(dot('d5'), <Text key="ag" color={MUTED}>{`${t.agents} agent${t.agents === 1 ? '' : 's'}`}</Text>)
595 rows.push(row('turn', 'last turn', bar('bl', null, ''), <Text key="tl" wrap="truncate-end">{parts}</Text>))
596 }
597
598 if (isOn('tasks') && todo) {
599 rows.push(row('tasks', 'tasks',
600 bar('bk', barSvg((todo.done / todo.total) * 100, BLUE), `${todo.done} of ${todo.total} done`),
601 <Text key="tk" wrap="truncate-end">
602 <Text color={BLUE}>{'● '}</Text>
603 <Text bold>{`${todo.done}/${todo.total}`}</Text>
604 <Text color={MUTED}>{` ${todo.list} · `}</Text>
605 <Text>{todo.current}</Text>
606 {todo.next ? <Text color={MUTED}>{` · next: ${todo.next}`}</Text> : null}
607 </Text>,
608 ))
609 }
610
611 // No row took the buttons (context row off, or no reading yet): they get a row of their own.
612 if (actions && !isActionsPlaced) rows.push(row('chat', 'chat', bar('ba', null, ''), <Box key="acts" flexDirection="row" gap={1}>{actions}</Box>))
613
614 const below = await next(e).catch(() => null)
615 const arrow = (await read($, hiderArrow))
616 ? Button
617 ? <Button key="hide-mods" label="▾" plain onPress={() => void $.command.run({ command: 'hide-mods' }).catch(() => undefined)} />
618 : <Text key="hide-mods" dimColor>▾</Text>
619 : null
620 if (rows.length === 0 && !arrow) return <Box flexDirection="column">{below}</Box>
621 const band = <Box flexDirection="column" paddingX={1}>{rows}</Box>
622 return (
623 <Box flexDirection="column">
624 {arrow ? (
625 <Box flexDirection="row">
626 <Box flexDirection="column" flexGrow={1} flexShrink={1}>{band}</Box>
627 <Box flexShrink={0} marginLeft={1} alignSelf="flex-start">{arrow}</Box>
628 </Box>
629 ) : band}
630 {below}
631 </Box>
632 )
633 })
634}
635hooks/mods.ts 13 lines1// The band's rows that the /mods panel can switch off.
2/** The band's rows the /mods panel can switch off, in the order they draw. */
3export const ROWS = [
4 { key: 'week', label: 'Week: weekly limit and pace' },
5 { key: 'usage', label: '5-hour: the 5-hour window' },
6 { key: 'context', label: 'Context: fill and forecast (red from 75%)' },
7 { key: 'spend', label: 'Spend: this chat, today, 7 days, all time' },
8 { key: 'tokens', label: 'Tokens: this chat, today, 7 days, all time' },
9 { key: 'turn', label: 'Last turn: what it did' },
10 { key: 'tasks', label: 'Tasks: open work-state list' },
11 { key: 'agents', label: 'Agents: the crabs (savvy-progress)' },
12] as const
13hooks/logic.ts 449 lines1// Pure helpers for the band: no `$`, so the tests call them directly.
2// changeOf and the turn counting are adapted from receipt by hoobnn (MIT).
3import type { DayTotal, FileChange, Hours, Ledger, Reading, TaskItem, TaskList, Turn, UsageSnap } from '../types'
4
5export const WORK_STATE = /work_state_write$/
6
7const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
8const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
9const SHELLS = new Set(['Bash', 'PowerShell'])
10const AGENTS = new Set(['Agent', 'Task'])
11
12const str = (v: unknown) => (typeof v === 'string' ? v : '')
13
14type Limit = { kind: string; percentUsed: number; resetsAt?: string }
15
16export const snapOf = (limits: readonly Limit[], usd: number): UsageSnap => {
17 const find = (kind: string) => limits.find(l => l.kind === kind)
18 const five = find('five_hour')
19 const week = find('seven_day')
20 return {
21 fiveHour: five ? five.percentUsed : -1,
22 week: week ? week.percentUsed : -1,
23 fiveResets: five?.resetsAt ?? '',
24 weekResets: week?.resetsAt ?? '',
25 usd,
26 }
27}
28
29/** `3h 55m`, `4d 17h`, `12m`; '' when unknown or past. */
30export const resetsIn = (iso: string, now: number) => {
31 const ms = iso ? Date.parse(iso) - now : NaN
32 if (!Number.isFinite(ms) || ms <= 0) return ''
33 const m = Math.floor(ms / 60_000)
34 const d = Math.floor(m / 1440)
35 const h = Math.floor((m % 1440) / 60)
36 return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m % 60}m` : `${m}m`
37}
38
39
40/** `842`, `84.2k`, `1.2M`. */
41export const short = (n: number) =>
42 n >= 1e6 ? `${(n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1)}M` : n >= 1000 ? `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k` : `${Math.round(n)}`
43
44/** `42s`, `5m 16s`, `1h 3m`. */
45export const duration = (ms: number) => {
46 const s = Math.max(0, Math.round(ms / 1000))
47 if (s < 60) return `${s}s`
48 const m = Math.floor(s / 60)
49 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
50}
51
52const WEEK_MS = 7 * 86_400_000
53const DAY_MS = 86_400_000
54
55export type WeekPace = {
56 /** How far through the week we are, 0 to 100. */
57 elapsed: number
58 /** ok: lasts to the reset; ahead: runs out before it; low: 90% or more; out: none left. */
59 level: 'ok' | 'ahead' | 'low' | 'out'
60 word: string
61 color: string
62}
63
64// The week's pace, measured in the person's own hours. An activity profile gives each hour of the week
65// (Sunday 00:00 is slot 0, local time) a weight: how much of the limit that hour usually burns. It is
66// learned from past transcripts (the seed) and from every turn since, recent weeks counting more, so the
67// pace stops treating a night asleep as hours of burn and keeps adjusting as the person's habits change.
68
69const HOUR_MS = 3_600_000
70/** A week ago counts half as much as today; two weeks ago, a quarter. */
71const HALF_LIFE_DAYS = 7
72const LOOKBACK_DAYS = 56
73/** Days of activity before the learned profile replaces the default one. */
74const MIN_DAYS = 3
75/** Typical hours that must have passed this week before the pace means anything. */
76const MIN_ACTIVE = 6
77const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
78
79/** The local hour of the week, 0 (Sunday 00:00) to 167. */
80const slotOf = (ms: number) => {
81 const d = new Date(ms)
82 return d.getDay() * 24 + d.getHours()
83}
84
85const normalize = (raw: readonly number[]): number[] => {
86 const mean = raw.reduce((a, b) => a + b, 0) / raw.length
87 if (!(mean > 0)) return raw.map(() => 1)
88 // No hour is ever quite zero: a night owl session still counts.
89 const floored = raw.map(v => Math.max(v, 0.03 * mean))
90 const m = floored.reduce((a, b) => a + b, 0) / floored.length
91 return floored.map(v => v / m)
92}
93
94export type Profile = {
95 /** 168 weights, one per local hour of the week, averaging 1. */
96 weights: number[]
97 /** Days of activity it was learned from; 0 for the default. */
98 days: number
99}
100
101/** Before there is anything to learn from: midnight to 8am is quiet, the rest even. */
102export const DEFAULT_PROFILE: Profile = { weights: normalize(Array.from({ length: 168 }, (_, s) => (s % 24 < 8 ? 0.1 : 1))), days: 0 }
103/** Every hour the same: the plain wall-clock pace. */
104export const FLAT_PROFILE: Profile = { weights: Array.from({ length: 168 }, () => 1), days: 0 }
105
106/** Roughly what a turn costs against the limit: cache reads are cheap, everything else full price. */
107export const turnUnits = (u: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | undefined) =>
108 u ? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + 0.1 * (u.cache_read_input_tokens ?? 0) : 0
109
110/** Adds a turn's units to the ledger's hour it ran in. */
111export const addActivity = (l: Ledger, units: number, now: number): Ledger => {
112 if (!(units > 0)) return l
113 const h = String(Math.floor(now / HOUR_MS))
114 const hours = l.hours ?? {}
115 return { ...l, hours: { ...hours, [h]: (hours[h] ?? 0) + Math.round(units) } }
116}
117
118/** Activity by epoch hour, counted only from `from` (inclusive) to `before` (exclusive), so the seed and the live ledgers never overlap. */
119export type ActivitySource = { hours: Hours; from?: number; before?: number }
120
121/** The activity profile: recent weeks weigh more, each slot leaning on its hour of the day while data is thin. */
122export const activityProfile = (sources: readonly ActivitySource[], now: number): Profile => {
123 const nowH = Math.floor(now / HOUR_MS)
124 const slots = Array.from({ length: 168 }, () => 0)
125 const days = new Set<string>()
126 for (const src of sources) {
127 for (const [k, v] of Object.entries(src.hours)) {
128 const h = Number(k)
129 if (!Number.isFinite(h) || !(v > 0)) continue
130 if (src.from !== undefined && h < src.from) continue
131 if (src.before !== undefined && h >= src.before) continue
132 const age = (nowH - h) / 24
133 if (age < 0 || age > LOOKBACK_DAYS) continue
134 const s = slotOf(h * HOUR_MS)
135 slots[s] = (slots[s] ?? 0) + v * 0.5 ** (age / HALF_LIFE_DAYS)
136 days.add(dayKey(h * HOUR_MS))
137 }
138 }
139 if (days.size < MIN_DAYS) return DEFAULT_PROFILE
140 const byHour = Array.from({ length: 24 }, (_, x) => {
141 let sum = 0
142 for (let d = 0; d < 7; d++) sum += slots[d * 24 + x] ?? 0
143 return sum / 7
144 })
145 return { weights: normalize(slots.map((v, s) => (v + (byHour[s % 24] ?? 0)) / 2)), days: days.size }
146}
147
148/** The profile's weight between two times: typical hours, a quiet hour counting for little. */
149const weightBetween = (a: number, b: number, w: readonly number[]) => {
150 let total = 0
151 for (let t = a; t < b;) {
152 const next = Math.min(b, (Math.floor(t / HOUR_MS) + 1) * HOUR_MS)
153 total += ((next - t) / HOUR_MS) * (w[slotOf(t)] ?? 1)
154 t = next
155 }
156 return total
157}
158
159/** When `need` percent more is used at `rate` percent per typical hour, or null when that is not before `limit`. */
160const runOutAt = (from: number, need: number, rate: number, w: readonly number[], limit: number): number | null => {
161 let left = need
162 for (let t = from; t < limit;) {
163 const next = Math.min(limit, (Math.floor(t / HOUR_MS) + 1) * HOUR_MS)
164 const burn = rate * ((next - t) / HOUR_MS) * (w[slotOf(t)] ?? 1)
165 if (burn >= left) return t + (next - t) * (left / burn)
166 left -= burn
167 t = next
168 }
169 return null
170}
171
172/** `today 5pm`, `tomorrow 9am`, `Thu 2pm`, local time. */
173export const clockLabel = (ms: number, now: number) => {
174 const d = new Date(ms)
175 const h = d.getHours()
176 const time = `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`
177 const day = dayKey(ms) === dayKey(now) ? 'today' : dayKey(ms) === dayKey(now + DAY_MS) ? 'tomorrow' : WEEKDAYS[d.getDay()]
178 return `${day} ${time}`
179}
180
181/** Whether the weekly limit lasts until it resets, at the pace used so far this week, in the person's own hours. */
182export const weekPace = (used: number, resetsIso: string, now: number, profile: Profile = DEFAULT_PROFILE): WeekPace => {
183 const reset = resetsIso ? Date.parse(resetsIso) : NaN
184 const w = profile.weights
185 const start = reset - WEEK_MS
186 const past = Number.isFinite(reset) ? weightBetween(start, Math.min(now, reset), w) : 0
187 const ahead = Number.isFinite(reset) ? weightBetween(Math.max(now, start), reset, w) : 0
188 // How far through the week's typical activity we are: where the usage would sit if it kept to pace.
189 const elapsed = past + ahead > 0 ? (past / (past + ahead)) * 100 : 0
190 if (used >= 100) return { elapsed, level: 'out', word: 'limit reached', color: '#E24B4A' }
191 // Too early in the week for the pace to mean anything.
192 if (past < MIN_ACTIVE || used <= 0 || !Number.isFinite(reset)) {
193 return used >= 90 ? { elapsed, level: 'low', word: 'nearly out', color: '#E24B4A' } : { elapsed, level: 'ok', word: 'on pace', color: '#1D9E75' }
194 }
195 const rate = used / past
196 const at = runOutAt(now, 100 - used, rate, w, reset)
197 const when = at === null ? '' : `${clockLabel(at, now)}, in ${resetsIn(new Date(at).toISOString(), now) || '<1m'}`
198 if (used >= 90) return { elapsed, level: 'low', word: at === null ? 'nearly out, lasts to reset' : `nearly out, runs out ~${when}`, color: '#E24B4A' }
199 if (at !== null) return { elapsed, level: 'ahead', word: `runs out ~${when}`, color: at - now < DAY_MS ? '#E24B4A' : '#EF9F27' }
200 const byReset = Math.round(used + rate * ahead)
201 return { elapsed, level: 'ok', word: `on pace, ~${byReset}% by reset`, color: used >= 75 ? '#EF9F27' : '#1D9E75' }
202}
203
204/** Green, then amber, then red as a share fills. */
205export const levelColor = (percent: number) => (percent < 50 ? '#1D9E75' : percent < 80 ? '#EF9F27' : '#E24B4A')
206
207/** Where the context bar warns: yellow from here, */
208export const CONTEXT_WARN = 50
209/** and red from here, with the red line and the Compact now button. */
210export const CONTEXT_COMPACT = 75
211
212/** The context window: green, yellow from half full, red from three quarters. */
213export const contextColor = (percent: number) =>
214 percent < CONTEXT_WARN ? '#1D9E75' : percent < CONTEXT_COMPACT ? '#E8C547' : '#E24B4A'
215
216/** The context window as weather: clear skies while it is empty, a storm as it fills. */
217export const forecast = (percent: number) =>
218 percent < 25 ? 'Clear'
219 : percent < CONTEXT_WARN ? 'Cloudy'
220 : percent < CONTEXT_COMPACT ? 'Showers'
221 : percent < 90 ? 'Storm'
222 : 'Compact soon'
223
224export const addReading = (readings: readonly Reading[], next: Reading, keep = 8): Reading[] => {
225 const kept = readings.filter(r => r.tokens > 0)
226 if (kept[kept.length - 1]?.tokens === next.tokens) return kept
227 return [...kept, next].slice(-keep)
228}
229
230export const newTurn = (now: number): Turn => ({ startedAt: now, durationMs: null, files: [], commands: 0, failed: 0, reads: 0, agents: 0 })
231
232export type Call = { tool: string; input: Record<string, unknown>; result: unknown; isError: boolean }
233
234/** Lines an edit added and removed: git's count, else the patch's, else a new file's length. */
235export const changeOf = (call: Call): FileChange | null => {
236 if (!EDITS.has(call.tool) || call.isError) return null
237 const path = str(call.input.file_path) || str(call.input.notebook_path)
238 if (!path) return null
239 const result = (call.result ?? {}) as Record<string, unknown>
240 const git = result.gitDiff as { additions?: unknown; deletions?: unknown } | undefined
241 if (typeof git?.additions === 'number' && typeof git.deletions === 'number') return { path, added: git.additions, removed: git.deletions }
242 let added = 0
243 let removed = 0
244 for (const hunk of Array.isArray(result.structuredPatch) ? (result.structuredPatch as { lines?: unknown }[]) : []) {
245 for (const line of Array.isArray(hunk.lines) ? hunk.lines : []) {
246 if (typeof line !== 'string') continue
247 if (line.startsWith('+')) added++
248 else if (line.startsWith('-')) removed++
249 }
250 }
251 if (added === 0 && removed === 0) added = (str(result.content) || str(call.input.content)).split('\n').length
252 return { path, added, removed }
253}
254
255export const addCall = (turn: Turn, call: Call, isMain: boolean): Turn => {
256 const change = changeOf(call)
257 if (change) {
258 const had = turn.files.find(f => f.path === change.path)
259 return {
260 ...turn,
261 files: had
262 ? turn.files.map(f => (f === had ? { ...f, added: f.added + change.added, removed: f.removed + change.removed } : f))
263 : [...turn.files, change],
264 }
265 }
266 if (SHELLS.has(call.tool)) return { ...turn, commands: turn.commands + 1, failed: turn.failed + (call.isError ? 1 : 0) }
267 if (READS.has(call.tool)) return { ...turn, reads: turn.reads + 1 }
268 if (AGENTS.has(call.tool) && isMain) return { ...turn, agents: turn.agents + 1 }
269 return turn
270}
271
272export const isEmptyTurn = (t: Turn) => t.files.length === 0 && t.commands === 0 && t.reads === 0 && t.agents === 0
273
274export const lineTotals = (t: Turn) =>
275 t.files.reduce((s, f) => ({ added: s.added + f.added, removed: s.removed + f.removed }), { added: 0, removed: 0 })
276
277/** One work_state_write call folded into the list it names; another list's call starts that list afresh. */
278export const applyWorkState = (was: TaskList | null, input: { list?: unknown; fields?: unknown }): TaskList | null => {
279 const list = str(input.list)
280 const fields = (input.fields ?? {}) as Record<string, unknown>
281 if (!list) return was
282 const base: TaskList = was && was.list === list ? was : { list, items: [], isClosed: false }
283 const task = str(fields.task)
284 if (task) {
285 const status = str(fields.status) || 'todo'
286 const items = base.items.some(i => i.task === task)
287 ? base.items.map(i => (i.task === task ? { task, status } : i))
288 : [...base.items, { task, status }]
289 return { ...base, items, isClosed: false }
290 }
291 if (fields.status === 'done') return { ...base, isClosed: true }
292 return base
293}
294
295/** The built-in TodoWrite: the whole list each call. */
296export const fromTodos = (todos: unknown): TaskList | null => {
297 if (!Array.isArray(todos)) return null
298 const items: TaskItem[] = todos.map(t => {
299 const o = (t ?? {}) as Record<string, unknown>
300 const status = str(o.status) === 'in_progress' ? 'doing' : str(o.status) === 'completed' ? 'done' : 'todo'
301 return { task: str(o.activeForm) && status === 'doing' ? str(o.activeForm) : str(o.content), status }
302 })
303 return { list: 'to-dos', items, isClosed: false }
304}
305
306/** What the tasks row says, or null when there is nothing open to show. */
307export const taskSummary = (t: TaskList | null) => {
308 if (!t || t.isClosed) return null
309 const live = t.items.filter(i => i.status !== 'dropped')
310 const done = live.filter(i => i.status === 'done').length
311 if (live.length === 0 || done === live.length) return null
312 const current = live.find(i => i.status === 'doing') ?? live.find(i => i.status === 'todo')
313 const next = live.filter(i => i.status === 'todo' && i !== current)[0]
314 return { list: t.list, done, total: live.length, current: current?.task ?? '', next: next?.task ?? '' }
315}
316
317// Spend and tokens across sessions: each session keeps a ledger file of its own, by local day,
318// so open sessions never write over each other; the band adds the files up.
319
320/** The local calendar day, `2026-10-12`. */
321export const dayKey = (now: number) => {
322 const d = new Date(now)
323 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
324}
325
326export const emptyLedger = (): Ledger => ({ days: {}, usd: 0, tokens: 0 })
327
328const bump = (l: Ledger, day: string, usd: number, tokens: number): Ledger => {
329 const was = l.days[day] ?? { usd: 0, tokens: 0 }
330 return { ...l, days: { ...l.days, [day]: { usd: was.usd + usd, tokens: was.tokens + tokens } } }
331}
332
333/**
334 * Folds the session's running cost into its ledger: what it grew by since the last reading counts to today.
335 * A total that fell (the session cleared or restarted) starts the count again from there.
336 */
337export const addCost = (l: Ledger, usdNow: number, day: string): Ledger => {
338 if (usdNow < l.usd) return { ...l, usd: usdNow }
339 if (usdNow === l.usd) return l
340 return { ...bump(l, day, usdNow - l.usd, 0), usd: usdNow }
341}
342
343/** A turn's tokens: what it sent, wrote to and read from the cache, and what came back. */
344export const turnTokens = (u: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | undefined) =>
345 u ? (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) : 0
346
347export const addTokens = (l: Ledger, tokens: number, day: string): Ledger =>
348 tokens > 0 ? { ...bump(l, day, 0, tokens), tokens: l.tokens + tokens } : l
349
350export type Totals = { today: DayTotal; week: DayTotal; all: DayTotal }
351
352/** Today, the last 7 days (today among them) and every day, over all the ledgers. */
353export const totalsOf = (ledgers: readonly Ledger[], now: number): Totals => {
354 const today = dayKey(now)
355 const week = new Set(Array.from({ length: 7 }, (_, i) => dayKey(now - i * 86_400_000)))
356 const t: Totals = { today: { usd: 0, tokens: 0 }, week: { usd: 0, tokens: 0 }, all: { usd: 0, tokens: 0 } }
357 for (const l of ledgers) {
358 for (const [day, v] of Object.entries(l.days)) {
359 const add = (into: DayTotal) => { into.usd += v.usd; into.tokens += v.tokens }
360 add(t.all)
361 if (week.has(day)) add(t.week)
362 if (day === today) add(t.today)
363 }
364 }
365 return t
366}
367
368/** A ledger file's text as a ledger, or null when it will not read as one. */
369export const parseLedger = (text: string): Ledger | null => {
370 try {
371 const v = JSON.parse(text) as Partial<Ledger>
372 if (!v || typeof v !== 'object' || !v.days || typeof v.days !== 'object') return null
373 const hours = v.hours && typeof v.hours === 'object' ? v.hours : undefined
374 return { days: v.days, usd: Number(v.usd) || 0, tokens: Number(v.tokens) || 0, ...(hours ? { hours } : {}) }
375 } catch {
376 return null
377 }
378}
379
380/** The seed file's text (history.json, from tools/seed_activity.py), or null when it will not read as one. */
381export const parseHistory = (text: string): { until: number; hours: Hours } | null => {
382 try {
383 const v = JSON.parse(text) as { until?: unknown; hours?: unknown }
384 if (typeof v.until !== 'number' || !v.hours || typeof v.hours !== 'object') return null
385 return { until: v.until, hours: v.hours as Hours }
386 } catch {
387 return null
388 }
389}
390
391/** `$1,234`, `$56.70`. */
392export const dollars = (usd: number) =>
393 usd >= 1000 ? `$${Math.round(usd).toLocaleString('en-US')}` : `$${usd.toFixed(2)}`
394
395/** `950`, `45.2k`, `12.3M`, `1.4B`. */
396export const tokensShort = (n: number) =>
397 n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : short(n)
398
399// One drawing for every row on the desktop surface: the same bar, the same size.
400export const BAR_W = 120
401export const BAR_H = 14
402
403const svg = (w: number, h: number, body: string) =>
404 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
405
406/** A mark across the bar at a share of it: the week's even pace, the context's compact line. */
407export type Mark = { at: number; color: string }
408
409/** The bar: the share filled in its colour, with marks across it. */
410export const barSvg = (percent: number, color: string, marks: readonly Mark[] = [], w = BAR_W, h = BAR_H) => {
411 const bar = 8
412 const y = (h - bar) / 2
413 const p = Math.max(0, Math.min(100, percent))
414 const fill = Math.round((w * p) / 100)
415 const lines = marks
416 .filter(m => m.at > 0 && m.at < 100)
417 .map(m => `<rect x="${Math.max(0, Math.min(w - 2, Math.round((w * m.at) / 100) - 1))}" width="2" height="${h}" rx="1" fill="${m.color}"/>`)
418 return svg(w, h, `<rect y="${y}" width="${w}" height="${bar}" rx="${bar / 2}" fill="#888780" opacity="0.25"/>` +
419 (fill > 0 ? `<rect y="${y}" width="${Math.max(fill, bar)}" height="${bar}" rx="${bar / 2}" fill="${color}"/>` : '') +
420 lines.join(''))
421}
422
423/** The bar's space with nothing in it, for a row with no bar, so its text lines up with the rest. */
424export const blankSvg = () => svg(BAR_W, BAR_H, '')
425
426export const RING = 52
427
428/** A ring: the share filled round from the top, the number in the middle, marks across it. */
429export const ringSvg = (percent: number, color: string, marks: readonly Mark[] = [], size = RING) => {
430 const r = 21
431 const c = 2 * Math.PI * r
432 const mid = size / 2
433 const p = Math.max(0, Math.min(100, percent))
434 const len = (c * p) / 100
435 const ticks = marks
436 .filter(m => m.at > 0 && m.at < 100)
437 .map(m => {
438 const a = (m.at / 100) * 2 * Math.PI - Math.PI / 2
439 const at = (d: number) => `${(mid + d * Math.cos(a)).toFixed(1)}`
440 const atY = (d: number) => `${(mid + d * Math.sin(a)).toFixed(1)}`
441 return `<line x1="${at(r - 5)}" y1="${atY(r - 5)}" x2="${at(r + 5)}" y2="${atY(r + 5)}" stroke="${m.color}" stroke-width="2" stroke-linecap="round"/>`
442 })
443 return svg(size, size,
444 `<circle cx="${mid}" cy="${mid}" r="${r}" fill="none" stroke="#888780" stroke-opacity="0.25" stroke-width="6"/>` +
445 (len > 0 ? `<circle cx="${mid}" cy="${mid}" r="${r}" fill="none" stroke="${color}" stroke-width="6" stroke-linecap="round" stroke-dasharray="${len.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${mid} ${mid})"/>` : '') +
446 ticks.join('') +
447 `<text x="${mid}" y="${mid + 4}" text-anchor="middle" font-family="Segoe UI, system-ui, sans-serif" font-size="12" font-weight="600" fill="${color}">${Math.round(p)}%</text>`)
448}
449types/index.d.ts 83 lines1/** The subscription windows and the session's spend, as last read. */
2export type UsageSnap = {
3 /** 0..100, or -1 with no reading (API key, before the first response). */
4 fiveHour: number
5 week: number
6 /** ISO 8601, or '' unknown. */
7 fiveResets: string
8 weekResets: string
9 usd: number
10}
11
12/** Spend and tokens over a span. */
13export type DayTotal = { usd: number; tokens: number }
14
15/** Weighted tokens by epoch hour (`Math.floor(ms / 3_600_000)`, as a string): when the limit was being used. */
16export type Hours = Record<string, number>
17
18/** One session's ledger file: spend and tokens by local day, the running totals last seen, and its activity by hour. */
19export type Ledger = { days: Record<string, DayTotal>; usd: number; tokens: number; hours?: Hours }
20
21/** The context window at the end of a turn. */
22export type Reading = { tokens: number; window: number; percent: number }
23
24export type FileChange = { path: string; added: number; removed: number }
25
26/** What a turn did, counted from its tool calls. */
27export type Turn = {
28 startedAt: number
29 durationMs: number | null
30 files: FileChange[]
31 commands: number
32 failed: number
33 reads: number
34 agents: number
35}
36
37export type TaskItem = { task: string; status: string }
38
39/** The to-do list touched last: claude-mem's work state, or the built-in TodoWrite. */
40export type TaskList = { list: string; items: TaskItem[]; isClosed: boolean }
41
42/** One installed mod as the /mods panel lists it. */
43export type ModInfo = {
44 id: string
45 name: string
46 description: string
47 isOn: boolean
48 /** A switch pressed this session: it applies from the next session. */
49 pending: 'on' | 'off' | 'failed' | null
50 error?: string
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 /** Claude Mod Hider's switch, read here so the band passes while mods are hidden. */
56 'claude-mod-hider': { hidden: boolean }
57 'status-band': {
58 usage: UsageSnap | null
59 readings: Reading[]
60 turn: Turn | null
61 lastTurn: Turn | null
62 tasks: TaskList | null
63 isHidden: boolean
64 /** True when Claude Mod Hider is on: the band draws its arrow in its own corner, and Mod Hider draws none beside it. */
65 hiderArrow: boolean
66 /** Band rows switched off in /mods ('week', 'usage', 'context', 'spend', 'tokens', 'turn', 'tasks', 'agents'); kept across sessions. */
67 rowsOff: string[]
68 /** The /mods panel's list, read when it opens. */
69 mods: ModInfo[] | null
70 /** This session's own ledger. */
71 ledger: Ledger
72 /** Every other session's ledger, by file name, with the time it was read at. */
73 others: Record<string, { mtimeMs: number; ledger: Ledger }>
74 /** The Compact now button: idle, queued until the running turn ends, or compacting. */
75 compact: 'idle' | 'queued' | 'running'
76 /** The Handoff button: idle, queued until the session is idle, or the handoff turn running. */
77 handoff: 'idle' | 'queued' | 'running'
78 /** Activity mined from past transcripts, up to the epoch hour `until`; null until read or when there is none. */
79 history: { until: number; hours: Hours } | null
80 }
81 }
82}
83