SLOPSHOPPER

status-band

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…

newpanebandguardcommandtoast
★ 1v0.7.6MITupdated 2026-10-13finalpantasy/claude-mod-pack/status-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-band
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ This chat │ ┃ Compact now Handoff ⏺ Read(src/auth.ts) │ ┃ Week pace: midnight to 8am counted as quiet ⎿ Read 6 lines │ ┃ until it has 3 days of your activity. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Band rows ⏺ Bash(bun test) │ ┃ Change right away. ⎿ 3 pass, 1 fail │ ┃ Turn off on Week: weekly limit and pace │ ┃ Turn off on 5-hour: the 5-hour window ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Turn off on Context: fill and forecast (red │ ┃ Turn off on Spend: this chat, today, 7 days ✻ Worked for 42s · done 4:20 PM │ ┃ Turn off on Tokens: this chat, today, 7 day │ ┃ Turn off on Last turn: what it did › /band │ ┃ Turn off on Tasks: open work-state list ⎿ status-band: Status band hidden. /band brings it back. │ ┃ Turn off on Agents: the crabs (savvy-progre │ ┃ │ ┃ Mods │ ┃ Switches apply from your next session. │ ┃ No mods found. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
This chat Compact now Handoff Week pace: midnight to 8am counted as quiet until it has 3 days of your activity. Band rows Change right away. Turn off on Week: weekly limit and pace Turn off on 5-hour: the 5-hour window Turn off on Context: fill and forecast (red from 75%) Turn off on Spend: this chat, today, 7 days, all time Turn off on Tokens: this chat, today, 7 days, all time Turn off on Last turn: what it did Turn off on Tasks: open work-state list Turn off on Agents: the crabs (savvy-progress) Mods Switches apply from your next session. No mods found.
README

Claude Mod Pack

Support me on Ko-fi

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.

Claude Status Band above the Claude Code prompt: week and 5-hour limit rings, context, spend and token rows, with Compact now and Handoff buttons, and the Claude Mod Hider arrow (boxed) in the corner

A real screenshot from the Claude desktop app. The dollar and token figures are pixelated.

Install

/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).

The mods

Claude Status Band (status-band)

One tidy band above the prompt, each row labelled:

  • Week and 5-hour: your plan's limits as rings, with a pace for the week that learns when you actually work. Nights and weekends you are away are not counted as burn, and it gets more accurate the longer you use it.
  • Context: how full the chat is and when it will need compacting (red from 75%).
  • Spend and Tokens: this chat, today, 7 days and all time, across every open chat.
  • Last turn: how long it took, files edited with lines added and removed, commands run, failures, reads and agents.
  • Tasks: your open to-do list, from TodoWrite or claude-mem's work state.
  • Compact now and Handoff buttons, always there. Handoff has Claude write a handoff packet and move the work to a fresh chat.

Claude Status Band with sample data: week, 5-hour and context rings, spend and token tiles for this chat, today, 7 days and all time, Compact now and Handoff buttons, the last turn and an open task list

/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.

The /mods panel: Compact now and Handoff, a switch for each band row, and every installed mod with an on/off switch and its full description

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 (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.

Claude Push Guard (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:

  • Your words: your real name, personal email, company, anything you list in the plugin's settings (/plugin configure push-guard@claude-mod-pack). Empty until you fill it in.
  • Your home folder path, read from your own machine, in any spelling (C:\Users\you, /c/Users/you, escaped JSON, /home/you).
  • Your everyday git name and email, but only when the commits are made under a different identity, the way a real name slips into a repo you publish under a handle.
  • Em dashes, if you turn that on.

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.

Claude Codex Bridge (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.

  • Needs the Codex CLI installed and signed in.
  • Set which models you get in the plugin's settings (models, as name=model pairs).

Support

These are free hobby projects. If one saves you time, you can buy me a coffee on Ko-fi.

License

MIT

Source 4 files
hooks/register.tsx 635 lines
1import { 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}
635
hooks/mods.ts 13 lines
1// 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
13
hooks/logic.ts 449 lines
1// 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}
449
types/index.d.ts 83 lines
1/** 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