SLOPSHOPPER

usage-dashboard

Claude usage dashboard pane (/usage-dashboard): 5h and weekly limit %, current and top model, pending / in-progress tasks, ETA and limit risk.

newpaneguardcommandstatustimer
v0.1.1MITupdated 2026-10-12wablevaibhav/claude-mods/usage-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-dashboard
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ Claude usage updated <1m ago │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ 5-hour session resets in NaNh NaNm │ ⎿ Read 6 lines │ ┃ │ ██████████▌░░░░░░░░░░░░░░░░░░░░░░░ 31% │ ⏺ Update(src/auth.ts) │ ┃ │ burn rate shows after a few replies │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ Weekly │ │ ┃ │ No reading yet. It appears after the │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ first reply. │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ ● Limit risk green session cost ~$0.42 │ ┃ ╭──────────────────────────────────────────╮ › /usage-dashboard │ ┃ │ Models active: opus-5-5 (<1m ago) │ ⎿ usage-dashboard: Usage dashboard opened. │ ┃ │ ★ opus-5-5 ██████████ 100% 98.9k 1… │ │ ┃ │ tokens: in / out / cache = 2.1k / 1.5k / │ │ ┃ │ 95.3k │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Tasks ▸ 0 in progress ○ 0 pending ✓ │ │ ┃ │ 0 today │ │ ┃ │ No todo list yet. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Last 7 days │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-dashboard: 5h 31% · opus-5-5

Draws

Pane · Usage
Claude usage updated <1m ago ╭──────────────────────────────────────────────────────────╮ │ 5-hour session resets in NaNh NaNm │ │ ██████████▌░░░░░░░░░░░░░░░░░░░░░░░ 31% │ │ burn rate shows after a few replies │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Weekly │ │ No reading yet. It appears after the first reply. │ ╰──────────────────────────────────────────────────────────╯ ● Limit risk green session cost ~$0.42 ╭──────────────────────────────────────────────────────────╮ │ Models active: opus-5-5 (<1m ago) │ │ ★ opus-5-5 ██████████ 100% 98.9k 1 msgs ~$0.10 │ │ tokens: in / out / cache = 2.1k / 1.5k / 95.3k │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Tasks ▸ 0 in progress ○ 0 pending ✓ 0 today │ │ No todo list yet. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Last 7 days │ │ Fri 03 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 │ │ Sat 04 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 │ │ Sun 05 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 │ │ Mon 06 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 │ │ Tue 07 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 │ │ Wed 08 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0 │ │ Thu 09 ████████████████████████████ 98.9k │ ╰──────────────────────────────────────────────────────────╯
README

claude-mods

Small, independent mods for Claude Code: a live progress bar, a usage dashboard, a context-window meter, a safety guard, a git status band, and your own notes and todos.

Each mod is a plugin of function hooks. Install only the ones you want; none depends on another.

ModCommandWhat it gives you
task-progress/progressA progress band above the prompt that follows the todo list, with a working timer and your usage.
usage-dashboard/usage-dashboardA pane with 5h and weekly limits, models, tasks, time left and a 7-day chart.
context-meter/ctxmeterContext-window percentage in the status line, with warnings at 70% and 90%.
safety-guard/safeguardBlocks destructive commands and writes to secret or key files before they run.
git-band/gitbandBranch, ahead/behind and change counts on one line above the prompt.
notes-todos/mytodo, /mynoteYour own todo list and notes that persist across sessions.

Install

Run these in a terminal Claude Code session. The desktop app's Code tab does not run /plugin install.

/plugin install task-progress --marketplace wablevaibhav/claude-mods

Answer y to add the marketplace the first time, then pick a scope. Repeat with any other mod name from the table:

/plugin install usage-dashboard --marketplace wablevaibhav/claude-mods
/plugin install context-meter --marketplace wablevaibhav/claude-mods
/plugin install safety-guard --marketplace wablevaibhav/claude-mods
/plugin install git-band --marketplace wablevaibhav/claude-mods
/plugin install notes-todos --marketplace wablevaibhav/claude-mods

Try without installing

Clone the repo and point Claude Code at the mod folders:

git clone https://github.com/wablevaibhav/claude-mods.git
cd claude-mods
claude --plugin-dir ./task-progress --plugin-dir ./usage-dashboard

Add one --plugin-dir per mod.

What it looks like

task-progress is a real screenshot. The other previews are illustrative text with sample numbers; real colours (green / amber / red, orange accents) follow your terminal theme.

task-progress: the band above the prompt

task-progress band: title, smooth bar, step list, 5h / weekly usage and active model

usage-dashboard: the /usage-dashboard pane

Claude usage                                     updated 12s ago
╭──────────────────────────────────────────────────────────────────╮
│ 5-hour session                                  resets in 2h 10m │
│ ██████████████▌░░░░░░░░░░░░░░░░░░░░░░░░░  35%                    │
│ burning 0.40%/min · full in ~2h 5m                               │
╰──────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────╮
│ Weekly                                           resets in 4d 3h │
│ █████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  12%                    │
╰──────────────────────────────────────────────────────────────────╯
● Limit risk green   session cost ~$1.42
╭──────────────────────────────────────────────────────────────────╮
│ Models                                 active: opus-5-5 (3m ago) │
│ ★ opus-5-5    ████████░░   78%    1.2M   34 msgs  ~$0.91         │
│   sonnet-5-5  ██░░░░░░░░   22%    340k   12 msgs  ~$0.18         │
│ tokens: in / out / cache = 80k / 190k / 1.3M                     │
╰──────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────╮
│ Tasks  ▸ 1 in progress   ○ 3 pending   ✓ 2 today                 │
│ ▸ Wire the charts  1m 20s                                        │
│ ○ Add the tasks board                                            │
│ ○ Polish and test                                                │
│ Time left 25m (best case 14m) · confidence medium, 4 finished    │
│ Should finish within the current 5h window.                      │
╰──────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────╮
│ Last 7 days                                                      │
│ Wed 07 ██████████████████░░░░░░░░░░░░░░░░░░░░░░  420k            │
│ Thu 08 ███████████████████████████████░░░░░░░░░  740k            │
│ Fri 09 ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  240k            │
│ Sat 10 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  0               │
│ Sun 11 ██████████████████████░░░░░░░░░░░░░░░░░░  530k            │
│ Mon 12 ████████████████████████████████████████  1.0M            │
╰──────────────────────────────────────────────────────────────────╯

context-meter and git-band each add one line above the prompt:

▲ Context 78% ███████████████████░░░░░ 156k / 200k  Getting full: consider /compact soon.   [Hide]
⎇ feature/login ↑2 ↓1 ● 3 staged ✎ 2 changed ? 2 untracked

The mods

task-progress

A progress band above the prompt, visible while you type.

  • Follows your todo list by itself. No setup: when Claude writes a todo list, the bar, step count and current step follow it.
  • A progress tool. Claude can report title, total, done, note, items and finished explicitly (the tool is mcp__task-progress__progress), for work that doesn't use a todo list.
  • A working timer (⠹ Working 12s) whenever a turn is running and no job is shown.
  • Footer: your 5h and weekly usage with time to reset, plus the active model.
  • Controls: Collapse / Expand hides the step list, Hide or /progress hides the band. A finished job clears when you send your next prompt.

usage-dashboard

Open it with /usage-dashboard. (/usage is Claude Code's own built-in panel; this mod uses a different name so the two don't collide.)

  • Limits: 5-hour and weekly gauges coloured green below 60%, amber from 60–85%, red above 85%, with reset times.
  • Burn rate and limit risk: percent per minute over the last 30 minutes, when the 5h window would fill, and whether your open tasks are likely to finish first.
  • Models: tokens by model (input, output, cache), share, messages and a rough cost, with the top consumer starred and the active model shown.
  • Tasks: in progress, pending and done today, with a time-left estimate from the median time of tasks that finished.
  • Last 7 days: daily tokens as bars.
  • Status line: 5h 34% · wk 12% · opus-5-5 after your first reply.

context-meter

  • Status line: ctx 43%, the share of the context window in use.
  • Warning band from 70%, with the token count and advice. It stays out of the way below that.
  • Toasts once each time usage climbs into 70% and into 90%, so you compact or start fresh before quality drops.
  • /ctxmeter hides or shows the band.

safety-guard

Stops a call before it runs and tells Claude why, so it asks you first.

BlocksExamples
Recursive deletes of root, home or wildcardsrm -rf /, rm -r ~, rm -rf *, rm -rf /etc
Force pushes without a leasegit push --force, git push -f (--force-with-lease is allowed)
Piping a download into a shell`curl … \sh, wget … \sudo bash`
Disk and filesystem damagedd … of=/dev/sda, mkfs.*, > /dev/sda, fork bombs, chmod -R 777
Writing secrets.env, .env.production (not .env.example), *.pem, *.key, id_rsa, service-account JSON, .npmrc, .aws/credentials, .git/ internals
Reading private keysid_rsa, *.pem, *.key, .ssh/, .aws/credentials

/safeguard shows status and how many calls it blocked. /safeguard pause and /safeguard resume turn it off and on for the session. If the guard itself errors, it blocks the call rather than letting it through.

git-band

  • One line: branch (or detached HEAD), ↑ahead ↓behind against the upstream, staged, changed and untracked counts, conflicts, or ✓ clean.
  • Refreshes after every turn and every 15 seconds. Hidden outside a git repository.
  • /gitband hides or shows it.

notes-todos

Your own lists, separate from Claude's todo list, stored locally and kept across sessions. The open-todo count shows in the status line.

/mytodo add Renew the domain      add a todo (a bare /mytodo <text> also adds)
/mytodo                           list todos
/mytodo done 2     /mytodo undo 2     /mytodo rm 2     /mytodo clear

/mynote Wifi password is on the fridge     save a note
/mynote                                    list notes
/mynote find wifi     /mynote rm 3

Notes and limits

  • Usage figures come from the rate-limit windows Anthropic reports on each reply (five_hour, seven_day). Accounts without those windows (some API-key setups) show "No reading yet".
  • Model totals, daily tokens and finished-task durations count only turns the mod has seen since you installed it, and are kept per machine.
  • Cost is a rough per-model price table, not a bill.
  • Task tracking follows Claude's TodoWrite list. If your session uses another task tool, task-progress can still be driven through its progress tool.
  • safety-guard is a pattern-based safety net, not a security boundary. It catches common destructive commands and secret-file writes; an unusual command can get past it.
  • Command names avoid Claude Code built-ins such as /usage, /context and /todos.
  • Everything runs locally and nothing is sent anywhere. git-band runs git status.

Troubleshooting

ProblemWhat to try
/usage shows "Session limit / Breakdown"That is Claude Code's built-in panel. Use /usage-dashboard.
A mod's command isn't foundThe mod isn't loaded. Install it, or start Claude with --plugin-dir ./<mod>.
Gauges say "No reading yet"Send one message; the reading arrives with the first reply.
Band shows the old layout after an updateClose the pane and reopen it, or restart Claude.
A command was blocked unexpectedlyRun /safeguard to see why, /safeguard pause if you really want it.
/plugin install isn't availableUse a terminal Claude Code session, not the desktop app's Code tab.

Develop

claude plugin validate .                 # the marketplace
claude plugin validate ./<mod>           # one mod
claude plugin test ./<mod>               # its tests

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, a hooks module, pure logic with *.test.ts files, and (where it keeps state) a types/index.d.ts contract. The marketplace file is .claude-plugin/marketplace.json.

Releases are git tags: v0.2.0 added context-meter, safety-guard, git-band and notes-todos.

License

MIT, see LICENSE.

Source 3 files
hooks/register.tsx 361 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CurrentModel, Limit, ModelTotals, Sample, Task } from '../types'
5import {
6  burnRate,
7  dayKey,
8  emptyTotals,
9  estimateCostUsd,
10  estimateEta,
11  exhaustionAt,
12  formatDuration,
13  formatTokens,
14  lastSevenDays,
15  paneBarWidth,
16  limitRisk,
17  pushSample,
18  reduceTasks,
19  shortModel,
20  smoothBar,
21  themeColor,
22  topModel,
23  totalTokens,
24  weekdayLabel,
25} from './usage'
26
27const PANE = 'usage-dashboard'
28const STORE_KEY = 'usage-dashboard.v1'
29const REFRESH_MS = 30_000
30
31const tasks = atom({ plugin: 'usage-dashboard', key: 'tasks' } as const, [] as Task[])
32const models = atom({ plugin: 'usage-dashboard', key: 'models' } as const, {} as Record<string, ModelTotals>)
33const days = atom({ plugin: 'usage-dashboard', key: 'days' } as const, {} as Record<string, number>)
34const durations = atom({ plugin: 'usage-dashboard', key: 'durations' } as const, [] as number[])
35const samples = atom({ plugin: 'usage-dashboard', key: 'samples' } as const, [] as Sample[])
36const limits = atom({ plugin: 'usage-dashboard', key: 'limits' } as const, [] as Limit[])
37const current = atom({ plugin: 'usage-dashboard', key: 'current' } as const, null as CurrentModel | null)
38const costUsd = atom({ plugin: 'usage-dashboard', key: 'costUsd' } as const, null as number | null)
39const refreshedAt = atom({ plugin: 'usage-dashboard', key: 'refreshedAt' } as const, 0)
40
41type Persisted = {
42  models: Record<string, ModelTotals>
43  days: Record<string, number>
44  durations: number[]
45}
46
47
48
49async function persist($: EngineInterface) {
50  const snapshot: Persisted = {
51    models: await read($, models),
52    days: await read($, days),
53    durations: await read($, durations),
54  }
55  await $.store.set(STORE_KEY, snapshot)
56}
57
58async function statusLine($: EngineInterface) {
59  const list = await read($, limits)
60  const five = list.find(l => l.kind === 'five_hour')
61  const week = list.find(l => l.kind === 'seven_day')
62  const now = await read($, current)
63  const parts: string[] = []
64  if (five) parts.push(`5h ${Math.round(five.percentUsed)}%`)
65  if (week) parts.push(`wk ${Math.round(week.percentUsed)}%`)
66  if (now) parts.push(shortModel(now.model))
67  $.ui.status(parts.length === 0 ? undefined : parts.join(' · '))
68}
69
70async function refreshLimits($: EngineInterface) {
71  const usage = await $.session.usage()
72  const list: Limit[] = usage.rateLimits.map(l => ({
73    kind: l.kind,
74    percentUsed: l.percentUsed,
75    resetsAt: l.resetsAt ?? null,
76  }))
77  const t = await $.clock.now()
78  await update($, limits, () => list)
79  await update($, costUsd, () => usage.cost?.usd ?? null)
80  await update($, refreshedAt, () => t)
81
82  const five = list.find(l => l.kind === 'five_hour')
83  if (five) {
84    await update($, samples, prev => pushSample(prev, { t, pct: five.percentUsed }))
85  }
86  await statusLine($)
87}
88
89export const register: Register = on => {
90  on('session.start', async ($, e, next) => {
91    await $.command.register({
92      name: 'usage-dashboard',
93      description: 'Show the usage dashboard: 5h and weekly limits, models, tasks, ETA',
94    })
95
96    const saved = (await $.store.get(STORE_KEY)) as Persisted | undefined
97    if (saved) {
98      await update($, models, () => saved.models ?? {})
99      await update($, days, () => saved.days ?? {})
100      await update($, durations, () => saved.durations ?? [])
101    }
102
103    await refreshLimits($).catch(() => undefined)
104    $.clock.every(REFRESH_MS, () => {
105      void refreshLimits($).catch(() => undefined)
106    })
107
108    return next(e)
109  })
110
111  on('command.run', { command: 'usage-dashboard' }, async $ => {
112    await refreshLimits($).catch(() => undefined)
113    await $.ui.open({ id: PANE, title: 'Usage' })
114
115    return { text: 'Usage dashboard opened.' }
116  }).catch(() => ({ text: 'Usage dashboard could not open.' }))
117
118  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
119    const now = await $.clock.now()
120    const prev = await read($, tasks)
121    const { tasks: nextTasks, finishedMs } = reduceTasks(prev, e.todos, now)
122
123    await update($, tasks, () => nextTasks)
124    if (finishedMs.length > 0) {
125      await update($, durations, list => [...list, ...finishedMs].slice(-200))
126      await persist($)
127    }
128
129    return next(e)
130  }).catch(($, e, next) => next(e))
131
132  on('turn.complete', async ($, e, next) => {
133    const usage = e.usage
134    if (usage !== undefined) {
135      const now = await $.clock.now()
136      await update($, models, all => {
137        const before = all[usage.model] ?? emptyTotals()
138        return {
139          ...all,
140          [usage.model]: {
141            input: before.input + usage.input_tokens,
142            output: before.output + usage.output_tokens,
143            cacheRead: before.cacheRead + usage.cache_read_input_tokens,
144            cacheWrite: before.cacheWrite + usage.cache_creation_input_tokens,
145            messages: before.messages + 1,
146          },
147        }
148      })
149      const spent =
150        usage.input_tokens +
151        usage.output_tokens +
152        usage.cache_read_input_tokens +
153        usage.cache_creation_input_tokens
154      await update($, days, all => {
155        const key = dayKey(now)
156        return { ...all, [key]: (all[key] ?? 0) + spent }
157      })
158      await update($, current, () => ({ model: usage.model, at: now }))
159      await persist($)
160    }
161    await refreshLimits($).catch(() => undefined)
162
163    return next(e)
164  })
165
166  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
167    const { Box, Text } = $.ui.resolve(e)
168    const now = await $.clock.now()
169    const list = await read($, limits)
170    const taskList = await read($, tasks)
171    const modelMap = await read($, models)
172    const dayMap = await read($, days)
173    const finished = await read($, durations)
174    const sampleList = await read($, samples)
175    const cur = await read($, current)
176    const cost = await read($, costUsd)
177    const updated = await read($, refreshedAt)
178
179    const five = list.find(l => l.kind === 'five_hour')
180    const week = list.find(l => l.kind === 'seven_day')
181    const fiveReset = five?.resetsAt ? Date.parse(five.resetsAt) : null
182    const weekReset = week?.resetsAt ? Date.parse(week.resetsAt) : null
183    const width = paneBarWidth(e.props.bodyColumns ?? e.viewport?.columns)
184
185    const firstSeen = taskList.length > 0 ? Math.min(...taskList.map(t => t.seenAt)) : now
186    const doneCount = taskList.filter(t => t.status === 'completed').length
187    const fallbackMs = doneCount > 0 ? (now - firstSeen) / doneCount : 10 * 60_000
188    const eta = estimateEta(taskList, finished, fallbackMs)
189
190    const rate = burnRate(sampleList)
191    const exhaustion = five ? exhaustionAt(five.percentUsed, rate, now) : null
192    const risk = five ? limitRisk(five.percentUsed, exhaustion, fiveReset, eta.likelyMs, now) : 'green'
193    const riskColor = { green: 'success', amber: 'warning', red: 'error' } as const
194
195    const top = topModel(modelMap)
196    const rows = Object.entries(modelMap)
197      .map(([model, m]) => ({ model, m, tokens: totalTokens(m) }))
198      .sort((a, b) => b.tokens - a.tokens)
199    const sum = rows.reduce((acc, r) => acc + r.tokens, 0)
200    const week7 = lastSevenDays(dayMap, now)
201    const weekMax = Math.max(1, ...week7.map(d => d.tokens))
202    const today = dayKey(now)
203    const nameWidth = Math.max(8, ...rows.map(r => shortModel(r.model).length))
204
205    const inProgress = taskList.filter(t => t.status === 'in_progress')
206    const pending = taskList.filter(t => t.status === 'pending')
207    const startOfDay = new Date(today).getTime()
208    const doneToday = taskList.filter(t => t.completedAt !== null && t.completedAt >= startOfDay)
209
210    const gauge = (title: string, limit: typeof five, reset: number | null, extra: string | null) => {
211      if (!limit) {
212        return (
213          <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
214            <Text bold>{title}</Text>
215            <Text dimColor>No reading yet. It appears after the first reply.</Text>
216          </Box>
217        )
218      }
219      const pct = Math.max(0, Math.min(100, limit.percentUsed))
220      const color = themeColor(pct)
221      const track = smoothBar(pct, width)
222      return (
223        <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
224          <Box justifyContent="space-between" gap={2}>
225            <Text bold>{title}</Text>
226            <Text dimColor>{reset !== null ? `resets in ${formatDuration(reset - now)}` : ''}</Text>
227          </Box>
228          <Text>
229            <Text color={color}>{track.filled}</Text>
230            <Text dimColor>{track.empty}</Text>
231            <Text bold color={color}>
232              {'  '}
233              {Math.round(pct)}%
234            </Text>
235          </Text>
236          {extra !== null && <Text dimColor>{extra}</Text>}
237        </Box>
238      )
239    }
240
241    const burn =
242      rate > 0
243        ? `burning ${rate.toFixed(2)}%/min${exhaustion !== null ? ` · full in ~${formatDuration(exhaustion - now)}` : ''}`
244        : 'burn rate shows after a few replies'
245
246    return (
247      <Box flexDirection="column">
248        <Box justifyContent="space-between">
249          <Text bold color="claude">
250            Claude usage
251          </Text>
252          <Text dimColor>
253            {updated > 0 ? `updated ${formatDuration(now - updated)} ago` : 'waiting for first reply'}
254          </Text>
255        </Box>
256
257        {gauge('5-hour session', five, fiveReset, burn)}
258        {gauge('Weekly', week, weekReset, null)}
259
260        <Text>
261          <Text color={riskColor[risk]}>● </Text>
262          <Text bold>Limit risk </Text>
263          <Text color={riskColor[risk]}>{risk}</Text>
264          {cost !== null && <Text dimColor>{`   session cost ~$${cost.toFixed(2)}`}</Text>}
265        </Text>
266
267        <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
268          <Box justifyContent="space-between">
269            <Text bold>Models</Text>
270            <Text dimColor>
271              {cur ? `active: ${shortModel(cur.model)} (${formatDuration(now - cur.at)} ago)` : 'active: n/a'}
272            </Text>
273          </Box>
274          {rows.length === 0 && <Text dimColor>No turns recorded yet.</Text>}
275          {rows.map(r => {
276            const share = sum > 0 ? (r.tokens / sum) * 100 : 0
277            const mini = smoothBar(share, 10)
278            const isTop = top?.model === r.model
279            return (
280              <Text key={r.model} wrap="truncate-end">
281                <Text color={isTop ? 'claude' : undefined}>{isTop ? '★ ' : '  '}</Text>
282                <Text bold={isTop}>{shortModel(r.model).padEnd(nameWidth)}</Text>
283                <Text color={isTop ? 'claude' : undefined}>{` ${mini.filled}`}</Text>
284                <Text dimColor>{mini.empty}</Text>
285                <Text>{` ${String(Math.round(share)).padStart(3)}%`}</Text>
286                <Text dimColor>
287                  {`  ${formatTokens(r.tokens).padStart(6)}  ${String(r.m.messages).padStart(3)} msgs  ~$${estimateCostUsd(r.model, r.m).toFixed(2)}`}
288                </Text>
289              </Text>
290            )
291          })}
292          {rows.length > 0 && (
293            <Text dimColor>
294              tokens: in / out / cache = {formatTokens(rows.reduce((a, r) => a + r.m.input, 0))} /{' '}
295              {formatTokens(rows.reduce((a, r) => a + r.m.output, 0))} /{' '}
296              {formatTokens(rows.reduce((a, r) => a + r.m.cacheRead + r.m.cacheWrite, 0))}
297            </Text>
298          )}
299        </Box>
300
301        <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
302          <Text>
303            <Text bold>Tasks </Text>
304            <Text color="claude">{`▸ ${inProgress.length} in progress`}</Text>
305            <Text dimColor>{`   ○ ${pending.length} pending`}</Text>
306            <Text color="success">{`   ✓ ${doneToday.length} today`}</Text>
307          </Text>
308          {taskList.length === 0 && <Text dimColor>No todo list yet.</Text>}
309          {inProgress.map(t => (
310            <Text key={`ip-${t.content}`} wrap="truncate-end">
311              <Text color="claude">▸ </Text>
312              <Text bold>{t.activeForm}</Text>
313              <Text dimColor>{t.startedAt !== null ? `  ${formatDuration(now - t.startedAt)}` : ''}</Text>
314            </Text>
315          ))}
316          {pending.slice(0, 6).map(t => (
317            <Text key={`p-${t.content}`} dimColor wrap="truncate-end">
318              ○ {t.content}
319            </Text>
320          ))}
321          {pending.length > 6 && <Text dimColor>… {pending.length - 6} more pending</Text>}
322          {eta.remaining > 0 && (
323            <Text>
324              <Text dimColor>Time left </Text>
325              <Text bold>{formatDuration(eta.likelyMs)}</Text>
326              <Text dimColor>
327                {` (best case ${formatDuration(eta.optimisticMs)}) · confidence ${eta.confidence}, ${finished.length} finished`}
328              </Text>
329            </Text>
330          )}
331          {eta.remaining > 0 && five && (
332            <Text color={riskColor[risk]}>
333              {risk === 'red'
334                ? 'Likely to hit the 5h limit before these tasks finish.'
335                : risk === 'amber'
336                  ? 'Close to the 5h limit for this much work.'
337                  : 'Should finish within the current 5h window.'}
338            </Text>
339          )}
340        </Box>
341
342        <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
343          <Text bold>Last 7 days</Text>
344          {week7.map(d => {
345            const track = smoothBar((d.tokens / weekMax) * 100, Math.max(10, width - 6))
346            const isToday = d.day === today
347            return (
348              <Text key={d.day} dimColor={d.tokens === 0}>
349                <Text bold={isToday}>{`${weekdayLabel(d.day)} ${d.day.slice(8)} `}</Text>
350                <Text color={isToday ? 'claude' : undefined}>{track.filled}</Text>
351                <Text dimColor>{track.empty}</Text>
352                <Text>{`  ${formatTokens(d.tokens)}`}</Text>
353              </Text>
354            )
355          })}
356        </Box>
357      </Box>
358    )
359  })
360}
361
hooks/usage.ts 218 lines
1import type { ModelTotals, Sample, Task, TaskStatus } from '../types'
2
3export type TodoInput = { content: string; activeForm: string; status: TaskStatus }
4
5export type Eta = {
6  remaining: number
7  optimisticMs: number
8  likelyMs: number
9  confidence: 'low' | 'medium' | 'high'
10}
11
12export type Risk = 'green' | 'amber' | 'red'
13
14export const WINDOW_MS = 30 * 60 * 1000
15
16export const emptyTotals = (): ModelTotals => ({
17  input: 0,
18  output: 0,
19  cacheRead: 0,
20  cacheWrite: 0,
21  messages: 0,
22})
23
24export const totalTokens = (m: ModelTotals): number =>
25  m.input + m.output + m.cacheRead + m.cacheWrite
26
27/** Latest todo list wins; status transitions stamp start and completion times. */
28export const reduceTasks = (
29  prev: readonly Task[],
30  todos: readonly TodoInput[],
31  now: number,
32): { tasks: Task[]; finishedMs: number[] } => {
33  const byContent = new Map(prev.map(task => [task.content, task]))
34  const finishedMs: number[] = []
35
36  const tasks = todos.map((todo): Task => {
37    const before = byContent.get(todo.content)
38    const seenAt = before?.seenAt ?? now
39    let startedAt = before?.startedAt ?? null
40    let completedAt = before?.completedAt ?? null
41
42    if (todo.status === 'in_progress' && startedAt === null) {
43      startedAt = now
44    }
45    if (todo.status === 'completed' && completedAt === null) {
46      completedAt = now
47      finishedMs.push(Math.max(0, now - (startedAt ?? seenAt)))
48    }
49
50    return {
51      content: todo.content,
52      activeForm: todo.activeForm,
53      status: todo.status,
54      seenAt,
55      startedAt,
56      completedAt,
57    }
58  })
59
60  return { tasks, finishedMs }
61}
62
63export const percentile = (values: readonly number[], p: number): number => {
64  if (values.length === 0) return 0
65  const sorted = [...values].sort((a, b) => a - b)
66  const at = (sorted.length - 1) * p
67  const lo = Math.floor(at)
68  const hi = Math.ceil(at)
69  return sorted[lo] + (sorted[hi] - sorted[lo]) * (at - lo)
70}
71
72export const estimateEta = (
73  tasks: readonly Task[],
74  durations: readonly number[],
75  fallbackMs: number,
76): Eta => {
77  const remaining = tasks.filter(task => task.status !== 'completed').length
78  const hasHistory = durations.length > 0
79  const likely = hasHistory ? percentile(durations, 0.5) : fallbackMs
80  const optimistic = hasHistory ? percentile(durations, 0.25) : fallbackMs * 0.6
81  const confidence = durations.length >= 5 ? 'high' : durations.length >= 2 ? 'medium' : 'low'
82
83  return {
84    remaining,
85    optimisticMs: Math.round(remaining * optimistic),
86    likelyMs: Math.round(remaining * likely),
87    confidence,
88  }
89}
90
91/** Appends a sample; a drop in percent means the window reset, so older samples go. */
92export const pushSample = (samples: readonly Sample[], sample: Sample): Sample[] => {
93  const last = samples[samples.length - 1]
94  const base = last !== undefined && sample.pct < last.pct ? [] : [...samples]
95  return [...base, sample].filter(s => sample.t - s.t <= WINDOW_MS).slice(-120)
96}
97
98/** Percent per minute over the retained samples; 0 when there is no trend yet. */
99export const burnRate = (samples: readonly Sample[]): number => {
100  if (samples.length < 2) return 0
101  const first = samples[0]
102  const last = samples[samples.length - 1]
103  const minutes = (last.t - first.t) / 60000
104  return minutes > 0 ? Math.max(0, (last.pct - first.pct) / minutes) : 0
105}
106
107/** Epoch ms when the window hits 100%, or null with no burn. */
108export const exhaustionAt = (pct: number, ratePerMin: number, now: number): number | null =>
109  ratePerMin > 0 ? now + ((100 - pct) / ratePerMin) * 60000 : null
110
111export const gaugeColor = (pct: number): 'green' | 'yellow' | 'red' =>
112  pct > 85 ? 'red' : pct >= 60 ? 'yellow' : 'green'
113
114/**
115 * red: at the current burn the window runs out before the tasks finish and before it resets.
116 * amber: usage is above 85%, or it would run out within a quarter of the ETA after finishing.
117 */
118export const limitRisk = (
119  pct: number,
120  exhaustion: number | null,
121  resetAt: number | null,
122  etaLikelyMs: number,
123  now: number,
124): Risk => {
125  const finishAt = now + etaLikelyMs
126  const runsOutBeforeReset = exhaustion !== null && (resetAt === null || exhaustion < resetAt)
127
128  if (runsOutBeforeReset && etaLikelyMs > 0 && exhaustion !== null && exhaustion < finishAt) {
129    return 'red'
130  }
131  if (pct > 85) return 'amber'
132  if (runsOutBeforeReset && etaLikelyMs > 0 && exhaustion !== null) {
133    return exhaustion < finishAt + etaLikelyMs * 0.25 ? 'amber' : 'green'
134  }
135  return 'green'
136}
137
138/** Rough USD per million tokens by model family: [input, output]. An estimate, not a bill. */
139export const priceFor = (model: string): [number, number] => {
140  const id = model.toLowerCase()
141  if (id.includes('fable') || id.includes('mythos')) return [10, 50]
142  if (id.includes('haiku-5')) return [0.1, 0.5]
143  if (id.includes('haiku')) return [1, 5]
144  if (id.includes('opus-5-5')) return [4, 20]
145  if (id.includes('opus')) return [5, 25]
146  if (id.includes('sonnet-5')) return [2, 10]
147  if (id.includes('sonnet')) return [3, 15]
148  return [3, 15]
149}
150
151export const estimateCostUsd = (model: string, m: ModelTotals): number => {
152  const [inp, out] = priceFor(model)
153  return (m.input * inp + m.output * out + m.cacheRead * inp * 0.1 + m.cacheWrite * inp * 1.25) / 1_000_000
154}
155
156export const topModel = (
157  models: Readonly<Record<string, ModelTotals>>,
158): { model: string; tokens: number; share: number } | null => {
159  const entries = Object.entries(models).map(([model, m]) => ({ model, tokens: totalTokens(m) }))
160  const sum = entries.reduce((acc, e) => acc + e.tokens, 0)
161  if (entries.length === 0 || sum === 0) return null
162  const best = entries.reduce((a, b) => (b.tokens > a.tokens ? b : a))
163  return { ...best, share: best.tokens / sum }
164}
165
166export const dayKey = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
167
168/** Last 7 calendar days (UTC), oldest first, zero-filled. */
169export const lastSevenDays = (
170  days: Readonly<Record<string, number>>,
171  now: number,
172): Array<{ day: string; tokens: number }> =>
173  Array.from({ length: 7 }, (_, i) => {
174    const day = dayKey(now - (6 - i) * 86_400_000)
175    return { day, tokens: days[day] ?? 0 }
176  })
177
178export const formatTokens = (n: number): string =>
179  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
180
181export const formatDuration = (ms: number): string => {
182  const minutes = Math.round(ms / 60000)
183  if (minutes < 1) return '<1m'
184  if (minutes < 60) return `${minutes}m`
185  const h = Math.floor(minutes / 60)
186  return `${h}h ${minutes % 60}m`
187}
188
189export const bar = (pct: number, width = 20): string => {
190  const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
191  return '█'.repeat(filled) + '░'.repeat(width - filled)
192}
193
194export const shortModel = (model: string): string => model.replace(/^claude-/, '')
195
196const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
197
198/** A bar with 1/8-cell resolution, split into the filled and the empty part for two colours. */
199export const smoothBar = (pct: number, width: number): { filled: string; empty: string } => {
200  const clamped = Math.max(0, Math.min(100, pct))
201  const eighths = Math.round((clamped / 100) * width * 8)
202  const full = Math.floor(eighths / 8)
203  const part = EIGHTHS[eighths % 8]
204  const used = full + (part === '' ? 0 : 1)
205  return { filled: '█'.repeat(full) + part, empty: '░'.repeat(Math.max(0, width - used)) }
206}
207
208export const themeColor = (pct: number): 'success' | 'warning' | 'error' =>
209  pct > 85 ? 'error' : pct >= 60 ? 'warning' : 'success'
210
211const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
212
213export const weekdayLabel = (day: string): string => WEEKDAYS[new Date(`${day}T00:00:00Z`).getUTCDay()]
214
215/** Bar width that fits the pane: leaves room for borders, padding and the figures beside it. */
216export const paneBarWidth = (columns: number | undefined): number =>
217  Math.max(12, Math.min(50, (columns ?? 80) - 22))
218
types/index.d.ts 41 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = {
4  content: string
5  activeForm: string
6  status: TaskStatus
7  seenAt: number
8  startedAt: number | null
9  completedAt: number | null
10}
11
12export type ModelTotals = {
13  input: number
14  output: number
15  cacheRead: number
16  cacheWrite: number
17  messages: number
18}
19
20export type Sample = { t: number; pct: number }
21
22export type Limit = { kind: string; percentUsed: number; resetsAt: string | null }
23
24export type CurrentModel = { model: string; at: number }
25
26declare module 'claude-code' {
27  interface PluginState {
28    'usage-dashboard': {
29      tasks: Task[]
30      models: Record<string, ModelTotals>
31      days: Record<string, number>
32      durations: number[]
33      samples: Sample[]
34      limits: Limit[]
35      current: CurrentModel | null
36      costUsd: number | null
37      refreshedAt: number
38    }
39  }
40}
41