Your control panel: a pill above the prompt, or /cp, opens a panel that launches your mods and shows your token stats.

Mods for Claude Code: small plugins that change its interface.
Three rings above the prompt showing how much of your plan's usage limits you've used: the 5-hour limit, the weekly limit across all models, and the weekly Fable limit. Each shows the percent used and the time until it resets, and turns amber at 50% and red at 80%.
A control panel for your mods. A pill at the right end of the band above the prompt says "✦ Moa's CP" (with your own name), and clicking it, or typing /cp, opens the panel.
USER, or USERNAME on Windows)./diff. Launch runs that command as if you'd typed it. Skills aren't listed, and nor are mods with nothing to launch. Mods it doesn't know yet get a card too, coloured by their name.~/.claude/control-panel/stats.json, so transcripts Claude Code later deletes take nothing away. The first count reads every transcript; after that it reads only what's new. Press 1 or 2 in the panel to switch tabs.In a Claude Code terminal session, run:
/plugin install usage-rings --marketplace moa-os/claude-mods
/plugin install control-panel --marketplace moa-os/claude-mods
Answer y to add the marketplace, then pick the user scope. Once installed from a terminal, the mods also load in the desktop app's sessions on that machine.
After a new version is pushed here:
claude plugin update usage-rings
claude plugin update control-panel
Then restart Claude Code, or run /reload-plugins in a terminal session.
Each mod is a folder listed in .claude-plugin/marketplace.json. To check one:
claude plugin validate usage-rings
claude plugin test usage-rings
To work on the mods from a clone, add the clone itself as a marketplace and install from it. Claude Code then reads the mods straight from the clone, so /reload-plugins picks up your edits:
claude plugin marketplace add <path to this clone>
claude plugin install usage-rings@claude-modshooks/register.tsx 475 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Period, Scope, Tab } from '../types'
5import {
6 DAY_COLORS,
7 PILL_HEIGHT,
8 STRIP_HEIGHT,
9 TILE_SIZE,
10 accountNameOf,
11 barSvg,
12 cardSvg,
13 chipSvg,
14 chipWidth,
15 dateLabel,
16 dayIconSvg,
17 displayName,
18 greetingOf,
19 hairlineSvg,
20 launchablesOf,
21 partOfDay,
22 pillSvg,
23 pillWidth,
24 shortDay,
25 stripSvg,
26 tileSvg,
27 titleOf,
28 underlineSvg,
29} from './panel'
30import { claudeFolder, countTranscripts, loadLedger } from './scan'
31import type { Ledger } from './stats'
32import { compact, dayOf, summarize } from './stats'
33
34const PANE = 'control-panel'
35const COMMAND = 'cp'
36const MINUTE = 60_000
37
38// The panel's choices: which tab, and on the Stats tab which conversations over which days.
39const tab = atom({ plugin: 'control-panel', key: 'tab' } as const, 'mods' as Tab)
40const scope = atom({ plugin: 'control-panel', key: 'scope' } as const, 'all' as Scope)
41const period = atom({ plugin: 'control-panel', key: 'period' } as const, 'all' as Period)
42
43const TABS: readonly { id: Tab; label: string; hotkey: string }[] = [
44 { id: 'mods', label: 'Mods', hotkey: '1' },
45 { id: 'stats', label: 'Stats', hotkey: '2' },
46]
47const SCOPES: readonly { id: Scope; label: string }[] = [
48 { id: 'session', label: 'This conversation' },
49 { id: 'all', label: 'All conversations' },
50]
51const PERIODS: readonly { id: Period; label: string }[] = [
52 { id: 'today', label: 'Today' },
53 { id: '7d', label: '7 days' },
54 { id: '30d', label: '30 days' },
55 { id: 'all', label: 'All time' },
56]
57
58// The height of a stat card's face, in CSS pixels.
59const CARD_HEIGHT = 76
60
61// The terminal's stand-ins for the strip's icons.
62const DAY_GLYPH = { morning: '☀', afternoon: '☀', evening: '☾', night: '☾' } as const
63
64// The Claude account's name, read once a load: Claude Code's config file can be large.
65let accountName: string | undefined
66
67// What the Stats tab counts: the ledger in memory (saved to a file of its own as it fills), the
68// count running now if any, how far it has got, and when the last one finished.
69let ledger: Ledger | null = null
70let counting: Promise<void> | null = null
71let progress = { done: 0, total: 0 }
72let countedAt: number | null = null
73
74// Claude Code keeps the signed-in account in `.claude.json`, in CLAUDE_CONFIG_DIR when set, else
75// in the home folder: HOME on macOS and Linux, USERPROFILE on Windows.
76async function readAccountName($: EngineInterface): Promise<string | undefined> {
77 const folder = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
78 if (folder === undefined) return undefined
79 try {
80 return accountNameOf(await $.fs.read(`${folder}/.claude.json`))
81 } catch {
82 return undefined
83 }
84}
85
86// The name the panel is titled with: the Claude account's, else the login name, capitalised
87// (USER on macOS and Linux, USERNAME on Windows).
88async function nameOf($: EngineInterface): Promise<string> {
89 const account = accountName ?? (await readAccountName($))
90 if (account !== undefined) return account
91 return displayName((await $.env.get('USER')) ?? (await $.env.get('USERNAME')))
92}
93
94async function timeOf($: EngineInterface) {
95 const date = new Date(await $.clock.now())
96 return { date, part: partOfDay(date.getHours()) }
97}
98
99async function openPanel($: EngineInterface) {
100 await $.ui.open({ id: PANE, title: titleOf(await nameOf($)), focus: true })
101}
102
103async function launch($: EngineInterface, command: string) {
104 try {
105 await $.command.run({ command })
106 } catch (error) {
107 $.ui.toast(`Couldn't launch /${command}: ${error instanceof Error ? error.message : String(error)}`)
108 }
109}
110
111// Counts what the transcripts added since the last count. The first count of a machine reads
112// every transcript and can take a while: the Stats tab fills in as it goes.
113async function count($: EngineInterface) {
114 const folder = await claudeFolder($)
115 if (folder === undefined) return
116 const book = ledger ?? (await loadLedger($, folder))
117 ledger = book
118 await countTranscripts($, folder, book, (done, total) => {
119 progress = { done, total }
120 $.ui.invalidate('ui.render')
121 })
122 countedAt = await $.clock.now()
123}
124
125// Starts a count unless one is running or the last finished within a minute.
126async function ensureCounted($: EngineInterface): Promise<void> {
127 if (counting !== null) return counting
128 const now = await $.clock.now()
129 if (ledger !== null && countedAt !== null && now - countedAt < MINUTE) return
130 counting = count($).finally(() => {
131 counting = null
132 $.ui.invalidate('ui.render')
133 })
134 return counting
135}
136
137async function chooseTab($: EngineInterface, next: Tab) {
138 await update($, tab, () => next)
139 try {
140 await $.store.set('tab', next)
141 } catch {
142 // Only the next session's first tab depends on it.
143 }
144 if (next === 'stats') await ensureCounted($)
145}
146
147function clockTime(ms: number): string {
148 const date = new Date(ms)
149 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
150}
151
152// What the Stats tab shows: the numbers for its choices, and a line on where they come from.
153async function statsOf($: EngineInterface) {
154 const chosenScope = await read($, scope)
155 const chosenPeriod = await read($, period)
156 const today = dayOf(new Date(await $.clock.now()))
157 const summary =
158 ledger === null ? null : summarize(ledger, { scope: chosenScope, period: chosenPeriod, session: await $.session.id(), today })
159 const since = summary?.since ?? null
160 const footnote =
161 counting !== null
162 ? `Counting your conversations · ${progress.done} of ${progress.total}`
163 : `Counted on this machine${since === null ? '' : ` · since ${shortDay(since)}`}${countedAt === null ? '' : ` · updated ${clockTime(countedAt)}`}`
164 return { chosenScope, chosenPeriod, summary, footnote }
165}
166
167export const register: Register = on => {
168 on('session.start', async ($, e, next) => {
169 const started = await next(e)
170 accountName = await readAccountName($)
171 await $.command.register({ name: COMMAND, description: 'Open your control panel' })
172 try {
173 const saved = await $.store.get('tab')
174 if (saved === 'mods' || saved === 'stats') await update($, tab, () => saved)
175 } catch {
176 // The panel opens on its Mods tab.
177 }
178 // The greeting and the pill's colours follow the time of day.
179 $.clock.every(MINUTE, () => $.ui.invalidate('ui.render'))
180 // Count in the background once the session has settled, so the Stats tab is ready.
181 $.clock.after(1500, () => void ensureCounted($))
182 // Draw the pill as soon as the mod loads, not on the next thing that redraws the band.
183 $.ui.invalidate('ui.render')
184 return started
185 })
186
187 on('command.run', { command: COMMAND }, async $ => {
188 await openPanel($)
189 return {}
190 }).catch(() => ({ text: "Couldn't open your control panel." }))
191
192 // The band is shared with other mods (usage-rings' rings, say): what they drew fills the row
193 // and the pill sits at its right end. The engine's own answer there is its empty band, which
194 // draws nothing, so it is left out.
195 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196 if (e.props.hasSurvey) return next(e)
197
198 const below = await next(e)
199 const others = below.type === 'engine' ? null : below
200 const name = await nameOf($)
201 const label = `✦ ${titleOf(name)}`
202
203 if (e.surface === 'terminal') {
204 const { Box, Button } = $.ui.resolve(e)
205 return (
206 <Box flexDirection="row" alignItems="center" flexGrow={1}>
207 <Box flexDirection="row" flexGrow={1}>{others}</Box>
208 <Button key="cp-open" variant="primary" label={label} onPress={() => void openPanel($)} />
209 </Box>
210 )
211 }
212 if (e.surface !== 'desktop') return below
213
214 const { Box, Button, Svg, Text } = $.ui.resolve(e)
215 const { part } = await timeOf($)
216 const width = pillWidth(label)
217 return (
218 <Box flexDirection="row" alignItems="center" flexGrow={1}>
219 <Box flexDirection="row" flexGrow={1}>{others}</Box>
220 <Box key="cp-pill" position="relative">
221 <Svg source={pillSvg(DAY_COLORS[part], width)} alt={titleOf(name)} width={width} height={PILL_HEIGHT} />
222 <Box position="absolute" top={0} left={0} right={0} bottom={0} flexDirection="row" alignItems="center" justifyContent="center">
223 <Button key="cp-open" plain onPress={() => void openPanel($)}>
224 <Text size="footnote" weight="semibold" color="#ffffff">{label}</Text>
225 </Button>
226 </Box>
227 </Box>
228 </Box>
229 )
230 })
231
232 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233 const name = await nameOf($)
234 const { date, part } = await timeOf($)
235 const greeting = greetingOf(part, name)
236 const accent = DAY_COLORS[part].from
237 const current = await read($, tab)
238 if (current === 'stats') void ensureCounted($)
239 const launchables = current === 'mods' ? launchablesOf(await $.command.list()) : []
240 const stats = current === 'stats' ? await statsOf($) : null
241 const empty = 'No mods with a command to launch yet.'
242 const noReplies = 'No replies in this period.'
243
244 // Every surface but the desktop gets rows of text and buttons.
245 if (e.surface !== 'desktop') {
246 const { Box, Button, Text } = $.ui.resolve(e)
247 const summary = stats?.summary ?? null
248 return (
249 <Box flexDirection="column" rowGap={1}>
250 <Text color={accent} bold>
251 {DAY_GLYPH[part]} {greeting} <Text dimColor>· {dateLabel(date)}</Text>
252 </Text>
253 <Box flexDirection="row" columnGap={2}>
254 {TABS.map(t => (
255 <Button
256 key={`tab-${t.id}`}
257 label={t.label}
258 hotkey={t.hotkey}
259 variant={t.id === current ? 'primary' : 'secondary'}
260 dimColor={t.id !== current}
261 onPress={() => void chooseTab($, t.id)}
262 />
263 ))}
264 </Box>
265 {current === 'mods' && launchables.length === 0 && <Text dimColor>{empty}</Text>}
266 {current === 'mods' &&
267 launchables.map(l => (
268 <Button key={`launch-${l.command}`} plain label={l.title} onPress={() => void launch($, l.command)}>
269 <Text color={l.colors.from} bold>{l.glyph}</Text> <Text bold>{l.title}</Text> <Text dimColor>{l.blurb}</Text>
270 </Button>
271 ))}
272 {stats !== null && (
273 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
274 {SCOPES.map(s => (
275 <Button
276 key={`scope-${s.id}`}
277 label={s.label}
278 dimColor={s.id !== stats.chosenScope}
279 onPress={() => void update($, scope, () => s.id)}
280 />
281 ))}
282 {stats.chosenScope === 'all' &&
283 PERIODS.map(p => (
284 <Button
285 key={`period-${p.id}`}
286 label={p.label}
287 dimColor={p.id !== stats.chosenPeriod}
288 onPress={() => void update($, period, () => p.id)}
289 />
290 ))}
291 </Box>
292 )}
293 {summary !== null && (
294 <Box flexDirection="column">
295 <Text>
296 Conversations <Text bold>{compact(summary.conversations)}</Text> · Replies <Text bold>{compact(summary.replies)}</Text> · Total tokens{' '}
297 <Text bold>{compact(summary.total)}</Text>
298 </Text>
299 <Text>
300 Input tokens <Text bold>{compact(summary.input)}</Text>{' '}
301 <Text dimColor>
302 ({compact(summary.cacheRead)} cached · {compact(summary.cacheWrite)} written · {compact(summary.fresh)} new)
303 </Text>{' '}
304 · Output tokens <Text bold>{compact(summary.output)}</Text>
305 </Text>
306 {summary.models.length === 0 && <Text dimColor>{noReplies}</Text>}
307 {summary.models.map(m => (
308 <Text key={`model-${m.model}`}>
309 <Text color={accent}>{'█'.repeat(Math.max(1, Math.round(m.share * 16)))}</Text> {m.label} <Text dimColor>{compact(m.output)}</Text>
310 </Text>
311 ))}
312 </Box>
313 )}
314 {stats !== null && <Text dimColor>{stats.footnote}</Text>}
315 </Box>
316 )
317 }
318
319 const { Box, Button, Svg, Text } = $.ui.resolve(e)
320
321 // A switch's chip: a pill picture with its label pressed over it.
322 const chip = (key: string, label: string, isOn: boolean, choose: () => void) => {
323 const width = chipWidth(label)
324 return (
325 <Box key={`chip-${key}`} position="relative">
326 <Svg source={chipSvg(width, 24, isOn)} alt={isOn ? `${label}, chosen` : label} width={width} height={24} />
327 <Box position="absolute" top={0} left={0} right={0} bottom={0} flexDirection="row" alignItems="center" justifyContent="center">
328 <Button key={key} plain onPress={choose}>
329 <Text size="footnote" weight={isOn ? 'semibold' : 'regular'} dimColor={!isOn}>{label}</Text>
330 </Button>
331 </Box>
332 </Box>
333 )
334 }
335
336 // A stat card: a rounded face with its number, its name and, for input, how it splits.
337 const card = (key: string, value: string, label: string, detail?: string) => (
338 <Box key={`kpi-${key}`} position="relative" width="32%">
339 <Svg source={cardSvg(CARD_HEIGHT)} alt={`${label}: ${value}`} height={CARD_HEIGHT} />
340 <Box position="absolute" top={0} left={0} right={0} bottom={0} flexDirection="column" justifyContent="center" paddingX="md">
341 <Text size="title" weight="bold" tabular wrap="truncate-end">{value}</Text>
342 <Text size="caption" dimColor wrap="truncate-end">{label}</Text>
343 {detail !== undefined && (
344 <Text size="caption" dimColor wrap="truncate-end">
345 {detail}
346 </Text>
347 )}
348 </Box>
349 </Box>
350 )
351
352 const mods = (
353 <Box key="launch-grid" flexDirection="row" flexWrap="wrap" justifyContent="flex-start" columnGap="sm" rowGap="sm">
354 {launchables.length === 0 && <Text dimColor>{empty}</Text>}
355 {launchables.map(l => (
356 <Box key={`card-${l.command}`} position="relative">
357 <Svg source={tileSvg(l)} alt={l.title} width={TILE_SIZE} height={TILE_SIZE} />
358 <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
359 <Svg source={tileSvg(l, true)} alt={l.title} width={TILE_SIZE} height={TILE_SIZE} />
360 </Box>
361 <Box
362 key={`card-${l.command}-text`}
363 position="absolute"
364 top={0}
365 left={0}
366 right={0}
367 bottom={0}
368 flexDirection="column"
369 alignItems="center"
370 justifyContent="flex-end"
371 paddingX="sm"
372 paddingBottom="md"
373 overflow="hidden"
374 >
375 <Text size="footnote" weight="semibold" wrap="truncate-end">{l.title}</Text>
376 <Text size="caption" mono dimColor wrap="truncate-end">/{l.command}</Text>
377 <Box marginTop="sm">
378 <Button key={`launch-${l.command}`} variant="secondary" onPress={() => void launch($, l.command)}>
379 Launch
380 </Button>
381 </Box>
382 </Box>
383 </Box>
384 ))}
385 </Box>
386 )
387
388 const summary = stats?.summary ?? null
389 const statsView = stats !== null && (
390 <Box key="stats" flexDirection="column" rowGap="md">
391 <Box key="stats-controls" flexDirection="row" flexWrap="wrap" columnGap="md" rowGap="sm" alignItems="center">
392 <Box flexDirection="row" columnGap="xs">
393 {SCOPES.map(s => chip(`scope-${s.id}`, s.label, s.id === stats.chosenScope, () => void update($, scope, () => s.id)))}
394 </Box>
395 {stats.chosenScope === 'all' && (
396 <Box flexDirection="row" columnGap="xs">
397 {PERIODS.map(p => chip(`period-${p.id}`, p.label, p.id === stats.chosenPeriod, () => void update($, period, () => p.id)))}
398 </Box>
399 )}
400 </Box>
401 {summary === null && <Text dimColor>Counting your conversations…</Text>}
402 {summary !== null && (
403 <Box key="kpis" flexDirection="row" flexWrap="wrap" justifyContent="space-between" rowGap="sm">
404 {card('conversations', compact(summary.conversations), 'Conversations')}
405 {card('replies', compact(summary.replies), 'Replies')}
406 {card('total', compact(summary.total), 'Total tokens')}
407 {card(
408 'input',
409 compact(summary.input),
410 'Input tokens',
411 `${compact(summary.cacheRead)} cached · ${compact(summary.cacheWrite)} written · ${compact(summary.fresh)} new`,
412 )}
413 {card('output', compact(summary.output), 'Output tokens')}
414 {card('model', summary.topModel ?? '–', 'Most used model')}
415 </Box>
416 )}
417 {summary !== null && (
418 <Box key="models" flexDirection="column" rowGap="xs">
419 <Text size="caption" weight="semibold" dimColor>OUTPUT BY MODEL</Text>
420 {summary.models.length === 0 && <Text size="footnote" dimColor>{noReplies}</Text>}
421 {summary.models.map(m => (
422 <Box key={`model-${m.model}`} flexDirection="row" alignItems="center" columnGap="sm">
423 <Box width={12}>
424 <Text size="footnote" wrap="truncate-end">{m.label}</Text>
425 </Box>
426 <Box flexGrow={1}>
427 <Svg source={barSvg(m.share, accent)} alt={`${m.label}: ${compact(m.output)} output tokens`} height={8} />
428 </Box>
429 <Box width={7} flexDirection="row" justifyContent="flex-end">
430 <Text size="footnote" dimColor tabular>{compact(m.output)}</Text>
431 </Box>
432 </Box>
433 ))}
434 </Box>
435 )}
436 <Text size="caption" dimColor>{stats.footnote}</Text>
437 </Box>
438 )
439
440 return (
441 <Box flexDirection="column" rowGap="md">
442 <Box key="strip" position="relative">
443 <Svg source={stripSvg(DAY_COLORS[part])} alt={`${greeting}, ${dateLabel(date)}`} height={STRIP_HEIGHT} />
444 <Box position="absolute" top={0} left={0} right={0} bottom={0} flexDirection="row" alignItems="center" columnGap="sm" paddingX="md">
445 <Svg source={dayIconSvg(part)} alt={part} width={18} height={18} />
446 <Text weight="semibold" color="#ffffff">{greeting}</Text>
447 <Box flexGrow={1} />
448 <Box key="strip-date" paddingRight="md">
449 <Text size="footnote" color="#ffffff">{dateLabel(date)}</Text>
450 </Box>
451 </Box>
452 </Box>
453 <Box key="tabs" flexDirection="column">
454 <Box flexDirection="row" columnGap="lg">
455 {TABS.map(t => {
456 const isOn = t.id === current
457 const width = Math.round(7 * t.label.length + 4)
458 return (
459 <Box key={`tab-box-${t.id}`} flexDirection="column" alignItems="flex-start" rowGap="xs">
460 <Button key={`tab-${t.id}`} plain hotkey={t.hotkey} onPress={() => void chooseTab($, t.id)}>
461 <Text size="footnote" weight={isOn ? 'semibold' : 'regular'} dimColor={!isOn}>{t.label}</Text>
462 </Button>
463 <Svg source={underlineSvg(width, accent, isOn)} alt={isOn ? `${t.label} tab, open` : `${t.label} tab`} width={width} height={3} />
464 </Box>
465 )
466 })}
467 </Box>
468 <Svg source={hairlineSvg()} alt="divider" height={1} />
469 </Box>
470 {current === 'mods' ? mods : statsView}
471 </Box>
472 )
473 })
474}
475hooks/panel.ts 329 lines1import type { CommandInfo } from 'claude-code'
2
3export type PartOfDay = 'morning' | 'afternoon' | 'evening' | 'night'
4export type Colors = { from: string; to: string }
5type IconName = 'replay' | 'flow' | 'tasks' | 'diff'
6
7/** One card of the Launch section: a slash command a mod adds. */
8export type Launchable = {
9 command: string
10 title: string
11 blurb: string
12 /** A symbol for a mod the panel knows (the terminal's icon), else the command's first letter. */
13 glyph: string
14 /** The line art the desktop tile draws for a mod the panel knows; null draws `glyph`. */
15 icon: IconName | null
16 colors: Colors
17}
18
19export function partOfDay(hour: number): PartOfDay {
20 if (hour >= 5 && hour < 12) return 'morning'
21 if (hour >= 12 && hour < 17) return 'afternoon'
22 if (hour >= 17 && hour < 22) return 'evening'
23 return 'night'
24}
25
26// The greeting strip's and the pill's gradient through the day.
27export const DAY_COLORS: Record<PartOfDay, Colors> = {
28 morning: { from: '#fb923c', to: '#f472b6' },
29 afternoon: { from: '#0ea5e9', to: '#6366f1' },
30 evening: { from: '#7c3aed', to: '#ea580c' },
31 night: { from: '#312e81', to: '#7c3aed' },
32}
33
34export function greetingOf(part: PartOfDay, name: string): string {
35 const greeting = part === 'night' ? 'Working late' : `Good ${part}`
36 return name === '' ? greeting : `${greeting}, ${name}`
37}
38
39/** The login name, capitalised ("moa" is "Moa"); empty when there is none. */
40export function displayName(user: string | undefined): string {
41 if (user === undefined || user === '') return ''
42 return user.charAt(0).toUpperCase() + user.slice(1)
43}
44
45/**
46 * The Claude account's name from Claude Code's config file (`.claude.json`): its display name,
47 * else its full name; undefined when the file holds neither, or is no JSON.
48 */
49export function accountNameOf(configText: string): string | undefined {
50 let config: unknown
51 try {
52 config = JSON.parse(configText)
53 } catch {
54 return undefined
55 }
56 const account = (config as { oauthAccount?: { displayName?: unknown; fullName?: unknown } } | null)?.oauthAccount
57 for (const name of [account?.displayName, account?.fullName]) {
58 if (typeof name === 'string' && name.trim() !== '') return name.trim()
59 }
60 return undefined
61}
62
63export function titleOf(name: string): string {
64 return name === '' ? 'Control panel' : `${name}'s CP`
65}
66
67const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
68const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
69
70/** "Sun 18 Oct", in local time. */
71export function dateLabel(date: Date): string {
72 return `${DAYS[date.getDay()] ?? ''} ${date.getDate()} ${MONTHS[date.getMonth()] ?? ''}`
73}
74
75// The mods the panel knows by name get their own title, line and colours; the rest are drawn
76// from their command and a colour picked by its name, so a new mod shows up without a change here.
77const KNOWN: Record<string, Omit<Launchable, 'command'>> = {
78 replay: {
79 title: 'Replay Theater',
80 blurb: "Step through the last turn's edits",
81 glyph: '↻',
82 icon: 'replay',
83 colors: { from: '#f59e0b', to: '#ea580c' },
84 },
85 flow: {
86 title: 'Agent Flow',
87 blurb: 'Live tree of running subagents',
88 glyph: '⋔',
89 icon: 'flow',
90 colors: { from: '#14b8a6', to: '#0ea5e9' },
91 },
92 'my-tasks': {
93 title: 'My Tasks',
94 blurb: 'What Claude needs from you',
95 glyph: '☑',
96 icon: 'tasks',
97 colors: { from: '#8b5cf6', to: '#d946ef' },
98 },
99 diff: {
100 title: 'Diff',
101 blurb: 'Uncommitted changes, file by file',
102 glyph: '±',
103 icon: 'diff',
104 colors: { from: '#22c55e', to: '#15803d' },
105 },
106}
107
108const PALETTE: readonly Colors[] = [
109 { from: '#f43f5e', to: '#be123c' },
110 { from: '#3b82f6', to: '#1d4ed8' },
111 { from: '#eab308', to: '#ca8a04' },
112 { from: '#06b6d4', to: '#0e7490' },
113 { from: '#a855f7', to: '#7e22ce' },
114 { from: '#84cc16', to: '#4d7c0f' },
115]
116
117// Built-in commands that are mods of their own; everything else built in is left out.
118const BUILT_IN_MODS = new Set(['diff'])
119const SELF = 'control-panel'
120
121function hashOf(text: string): number {
122 let hash = 0
123 for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0
124 return hash
125}
126
127function titleCase(name: string): string {
128 return name
129 .split(/[-_]/)
130 .filter(word => word !== '')
131 .map(word => word.charAt(0).toUpperCase() + word.slice(1))
132 .join(' ')
133}
134
135/**
136 * The commands worth a Launch card: those a mod registered (a plugin's command with a plain
137 * name; a skill's is always `plugin:name`), then the built-in mods, never this panel's own.
138 */
139export function launchablesOf(commands: readonly CommandInfo[]): Launchable[] {
140 const fromMods = commands.filter(c => c.source === 'plugin' && !c.name.includes(':') && c.plugin !== SELF)
141 const builtIn = commands.filter(c => c.source === 'builtin' && BUILT_IN_MODS.has(c.name))
142 return [...fromMods, ...builtIn].map(c => {
143 const known = KNOWN[c.name]
144 if (known !== undefined) return { command: c.name, ...known }
145 return {
146 command: c.name,
147 title: titleCase(c.name),
148 blurb: c.description,
149 glyph: c.name.charAt(0).toUpperCase(),
150 icon: null,
151 colors: PALETTE[hashOf(c.name) % PALETTE.length] ?? { from: '#8a8a8a', to: '#5a5a5a' },
152 }
153 })
154}
155
156function escapeXml(text: string): string {
157 return text.replace(/[&<>"']/g, ch => `&#${ch.charCodeAt(0)};`)
158}
159
160function gradient(id: string, colors: Colors): string {
161 return (
162 `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0.35">` +
163 `<stop offset="0" stop-color="${colors.from}"/><stop offset="1" stop-color="${colors.to}"/>` +
164 `</linearGradient>`
165 )
166}
167
168export const PILL_HEIGHT = 22
169
170/** Wide enough for the pill's label at the band's footnote size. */
171export function pillWidth(label: string): number {
172 return 16 + 7 * label.length
173}
174
175/** The pill's face: the time of day's gradient, a soft top highlight and a hairline edge. */
176export function pillSvg(colors: Colors, width: number): string {
177 const h = PILL_HEIGHT
178 const rect = `x="0.5" y="0.5" width="${width - 1}" height="${h - 1}" rx="${(h - 1) / 2}"`
179 return (
180 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${h}" width="${width}" height="${h}">` +
181 `<defs>${gradient('cp-pill', colors)}` +
182 `<linearGradient id="cp-shine" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".22"/><stop offset=".55" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
183 `</defs>` +
184 `<rect ${rect} fill="url(#cp-pill)"/>` +
185 `<rect ${rect} fill="url(#cp-shine)" stroke="#fff" stroke-opacity=".18"/>` +
186 `</svg>`
187 )
188}
189
190export const STRIP_HEIGHT = 40
191
192/**
193 * The greeting strip's backdrop: the time of day's gradient in a rounded band, stretched to the
194 * pane's width (the Svg gets no width of its own). Drawn at about a pane's width, so the corners
195 * keep their shape when it stretches.
196 */
197export function stripSvg(colors: Colors): string {
198 const w = 720
199 const h = STRIP_HEIGHT
200 return (
201 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" preserveAspectRatio="none">` +
202 `<defs>${gradient('cp-strip', colors)}</defs>` +
203 `<rect width="${w}" height="${h}" rx="10" fill="url(#cp-strip)"/>` +
204 `</svg>`
205 )
206}
207
208const DAY_ICON: Record<PartOfDay, string> = {
209 morning: '<path d="M3 18h18M7 18a5 5 0 0 1 10 0M12 7v3M5.6 10.6l1.4 1.4M18.4 10.6 17 12"/>',
210 afternoon:
211 '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
212 evening: '<path d="M3 18h18M7 18a5 5 0 0 1 10 0M12 7v3M5.6 10.6l1.4 1.4M18.4 10.6 17 12"/>',
213 night: '<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/>',
214}
215
216/** The strip's time-of-day icon, white line art. */
217export function dayIconSvg(part: PartOfDay): string {
218 return (
219 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` +
220 DAY_ICON[part] +
221 `</svg>`
222 )
223}
224
225const GLYPHS: Record<IconName, string> = {
226 replay: '<path d="M4 12a8 8 0 1 0 2.3-5.6"/><path d="M4 4v4h4"/><path d="M10 9l5 3-5 3z" fill="#fff"/>',
227 flow: '<circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="12" r="2"/><path d="M6 7v10M8 6l8 5M8 18l8-5"/>',
228 tasks: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 12l3 3 5-6"/>',
229 diff: '<path d="M12 4v8M8 8h8M8 18h8"/>',
230}
231
232/** The side of a Launch tile, in CSS pixels. */
233export const TILE_SIZE = 140
234
235/**
236 * A Launch tile's face: a rounded square with a faint grey fill and hairline edge (they read on
237 * light and dark themes alike), a soft glow of the mod's colour, and the mod's icon centred near
238 * the top: a rounded square in its gradient with its line art or first letter. `isLit` draws the
239 * brighter face shown under the pointer. The tile's text is drawn over it as real text, so it
240 * follows the theme.
241 */
242export function tileSvg(launchable: Launchable, isLit = false): string {
243 const id = `cp-tile-${launchable.command.replace(/[^a-zA-Z0-9-]/g, '')}${isLit ? '-lit' : ''}`
244 const size = TILE_SIZE
245 const icon = 40
246 const x = (size - icon) / 2
247 const y = 14
248 const mark =
249 launchable.icon === null
250 ? `<text x="${size / 2}" y="${y + icon / 2}" text-anchor="middle" dominant-baseline="central" font-family="system-ui,-apple-system,'Segoe UI',sans-serif" font-size="17" font-weight="700" fill="#fff">${escapeXml(launchable.glyph)}</text>`
251 : `<g transform="translate(${x + 8} ${y + 8})" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">${GLYPHS[launchable.icon]}</g>`
252 return (
253 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
254 `<defs>${gradient(`${id}-icon`, launchable.colors)}` +
255 `<radialGradient id="${id}-glow" cx="0.5" cy="0.3" r="0.75">` +
256 `<stop offset="0" stop-color="${launchable.colors.from}" stop-opacity="${isLit ? 0.42 : 0.24}"/>` +
257 `<stop offset="1" stop-color="${launchable.colors.from}" stop-opacity="0"/>` +
258 `</radialGradient></defs>` +
259 `<rect width="${size}" height="${size}" rx="16" fill="#808080" fill-opacity="${isLit ? 0.18 : 0.12}"/>` +
260 `<rect width="${size}" height="${size}" rx="16" fill="url(#${id}-glow)"/>` +
261 `<rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="15.5" fill="none" stroke="${isLit ? launchable.colors.from : '#808080'}" stroke-opacity="${isLit ? 0.7 : 0.28}"/>` +
262 `<rect x="${x}" y="${y}" width="${icon}" height="${icon}" rx="12" fill="url(#${id}-icon)"/>` +
263 mark +
264 `</svg>`
265 )
266}
267
268// The Stats tab's pictures. Each is drawn as an Svg because a desktop Box has square corners
269// and no gradient; real text is laid over them so it follows the theme.
270
271/** A stat card's face: a rounded box with a faint grey fill and hairline edge, stretched to its card's width. */
272export function cardSvg(height: number): string {
273 const w = 300
274 return (
275 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${height}" width="${w}" height="${height}" preserveAspectRatio="none">` +
276 `<rect x="0.5" y="0.5" width="${w - 1}" height="${height - 1}" rx="12" fill="#808080" fill-opacity="0.12" stroke="#808080" stroke-opacity="0.22"/>` +
277 `</svg>`
278 )
279}
280
281/** A switch's chip: a pill, filled when it is the one chosen. */
282export function chipSvg(width: number, height: number, isOn: boolean): string {
283 return (
284 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
285 `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${(height - 1) / 2}" fill="#808080" fill-opacity="${isOn ? 0.3 : 0}" stroke="#808080" stroke-opacity="${isOn ? 0.35 : 0.18}"/>` +
286 `</svg>`
287 )
288}
289
290/** About how wide a chip's label is at the footnote size, padding included. */
291export function chipWidth(label: string): number {
292 return Math.round(20 + 6.6 * label.length)
293}
294
295/** The line under the chosen tab, in the time of day's colour; `isOn` false draws it clear. */
296export function underlineSvg(width: number, color: string, isOn: boolean): string {
297 return (
298 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} 3" width="${width}" height="3">` +
299 `<rect width="${width}" height="3" rx="1.5" fill="${color}" fill-opacity="${isOn ? 1 : 0}"/>` +
300 `</svg>`
301 )
302}
303
304/** A hairline across the width, under the tabs. */
305export function hairlineSvg(): string {
306 return (
307 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 1" width="100" height="1" preserveAspectRatio="none">` +
308 `<rect width="100" height="1" fill="#808080" fill-opacity="0.25"/>` +
309 `</svg>`
310 )
311}
312
313/** A model's bar: `share` (0 to 1) of the width filled, on a faint track; stretched to its row. */
314export function barSvg(share: number, color: string): string {
315 const filled = Math.max(share > 0 ? 0.6 : 0, Math.min(100, share * 100))
316 return (
317 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 8" width="100" height="8" preserveAspectRatio="none">` +
318 `<rect width="100" height="8" fill="#808080" fill-opacity="0.12"/>` +
319 `<rect width="${filled}" height="8" fill="${color}"/>` +
320 `</svg>`
321 )
322}
323
324/** "15 Aug" from a `YYYY-MM-DD` day. */
325export function shortDay(day: string): string {
326 const [, m, d] = day.split('-').map(Number)
327 return `${d ?? ''} ${MONTHS[(m ?? 1) - 1] ?? ''}`
328}
329hooks/scan.ts 168 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Ledger } from './stats'
4import { dayOf, ledgerOf, prune, record, replyOf } from './stats'
5
6// A mod reads at most 4 MiB a call; a larger transcript is streamed through a host command.
7const READ_LIMIT = 4 * 1024 * 1024
8// How long the ledger remembers which replies it counted: a conversation resumed later than this
9// could count its copied replies again, which is rare; the list would otherwise grow without end.
10const KEEP_SEEN_DAYS = 60
11// Saving every few transcripts keeps a long first count from starting over if the session ends.
12const SAVE_EVERY = 10
13// How deep under `projects` transcripts sit: a workflow's agents keep theirs five folders down
14// (`<project>/<session>/subagents/workflows/<run>/`); the rest is room to spare.
15const MAX_DEPTH = 8
16
17/** Claude Code's folder: CLAUDE_CONFIG_DIR when set, else `.claude` in the home folder. */
18export async function claudeFolder($: EngineInterface): Promise<string | undefined> {
19 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
20 if (configured !== undefined && configured !== '') return configured
21 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
22 return home === undefined ? undefined : `${home}/.claude`
23}
24
25/** Where the panel keeps its ledger: beside Claude Code's own files, outside the mod's install folder. */
26export function ledgerPath(folder: string): string {
27 return `${folder}/control-panel/stats.json`
28}
29
30/** The UTF-8 size of `text` in bytes: transcripts are read from a byte offset. */
31export function utf8Length(text: string): number {
32 let bytes = 0
33 for (let i = 0; i < text.length; i++) {
34 const c = text.charCodeAt(i)
35 if (c < 0x80) bytes += 1
36 else if (c < 0x800) bytes += 2
37 else if (c >= 0xd800 && c <= 0xdbff) {
38 bytes += 4
39 i++
40 } else bytes += 3
41 }
42 return bytes
43}
44
45/**
46 * Splits `buffer` into its complete lines, handing each with its size in bytes (newline
47 * included) to `onLine`, and returns the part after the last newline.
48 */
49export function takeLines(buffer: string, onLine: (line: string, bytes: number) => void): string {
50 let start = 0
51 for (let end = buffer.indexOf('\n'); end !== -1; end = buffer.indexOf('\n', start)) {
52 const line = buffer.slice(start, end)
53 onLine(line.endsWith('\r') ? line.slice(0, -1) : line, utf8Length(line) + 1)
54 start = end + 1
55 }
56 return buffer.slice(start)
57}
58
59type Transcript = { path: string; size: number }
60
61async function listTranscripts($: EngineInterface, folder: string): Promise<Transcript[]> {
62 const found: Transcript[] = []
63 const walk = async (dir: string, depth: number) => {
64 let entries
65 try {
66 entries = await $.fs.list(dir)
67 } catch {
68 return
69 }
70 for (const entry of entries) {
71 const path = `${dir}/${entry.name}`
72 if (entry.kind === 'dir' && depth < MAX_DEPTH) await walk(path, depth + 1)
73 else if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) found.push({ path, size: entry.size })
74 }
75 }
76 await walk(`${folder}/projects`, 0)
77 return found
78}
79
80/**
81 * Reads a transcript from byte `offset`, handing each complete line to `onLine`, and returns
82 * the offset after the last complete line: a line still being written is read next time.
83 */
84async function readFrom(
85 $: EngineInterface,
86 file: Transcript,
87 offset: number,
88 isWindows: boolean,
89 onLine: (line: string) => void,
90): Promise<number> {
91 let consumed = 0
92 if (offset === 0 && file.size <= READ_LIMIT) {
93 takeLines(await $.fs.read(file.path), (line, bytes) => {
94 onLine(line)
95 consumed += bytes
96 })
97 return consumed
98 }
99
100 // macOS and Linux start at the offset with tail; Windows has no tail, so its whole file is
101 // streamed and the part already read is skipped here.
102 let skip = isWindows ? offset : 0
103 const argv = isWindows ? ['cmd', '/c', 'type', file.path] : offset > 0 ? ['tail', '-c', `+${offset + 1}`, file.path] : ['cat', file.path]
104 let buffer = ''
105 for await (const piece of $.process.spawn({ argv })) {
106 if (piece.stream !== 'stdout') continue
107 buffer = takeLines(buffer + piece.text, (line, bytes) => {
108 if (skip > 0) {
109 skip -= bytes
110 return
111 }
112 onLine(line)
113 consumed += bytes
114 })
115 }
116 return offset + consumed
117}
118
119/** The ledger saved before, or an empty one. */
120export async function loadLedger($: EngineInterface, folder: string): Promise<Ledger> {
121 try {
122 return ledgerOf(await $.fs.read(ledgerPath(folder)))
123 } catch {
124 return ledgerOf(undefined)
125 }
126}
127
128async function saveLedger($: EngineInterface, folder: string, ledger: Ledger) {
129 try {
130 await $.fs.write(ledgerPath(folder), JSON.stringify(ledger))
131 } catch {
132 // The counts stay in memory; the next save tries again.
133 }
134}
135
136/**
137 * Counts every reply in the transcripts that grew since the ledger last read them, saving the
138 * ledger as it goes. `onProgress` hears how many transcripts are done of how many changed.
139 */
140export async function countTranscripts(
141 $: EngineInterface,
142 folder: string,
143 ledger: Ledger,
144 onProgress: (done: number, total: number) => void,
145): Promise<void> {
146 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
147 const changed = (await listTranscripts($, folder)).filter(file => ledger.files[file.path]?.size !== file.size)
148 onProgress(0, changed.length)
149 for (const [i, file] of changed.entries()) {
150 const mark = ledger.files[file.path]
151 // A transcript that shrank was rewritten: read it from the start, its replies counted once.
152 const offset = mark !== undefined && mark.offset <= file.size ? mark.offset : 0
153 try {
154 const next = await readFrom($, file, offset, isWindows, line => {
155 const reply = replyOf(line)
156 if (reply !== null) record(ledger, reply)
157 })
158 ledger.files[file.path] = { size: file.size, mtimeMs: 0, offset: next }
159 } catch {
160 // Unreadable now: it is tried again next time.
161 }
162 onProgress(i + 1, changed.length)
163 if ((i + 1) % SAVE_EVERY === 0) await saveLedger($, folder, ledger)
164 }
165 prune(ledger, dayOf(new Date(await $.clock.now())), KEEP_SEEN_DAYS)
166 await saveLedger($, folder, ledger)
167}
168hooks/stats.ts 242 lines1import type { Period, Scope } from '../types'
2
3// Token statistics from Claude Code's transcripts: each reply Claude writes is a line in a
4// session's transcript carrying its model and token counts. They are counted once into a ledger
5// of daily totals, which the panel keeps in a file of its own, so a transcript Claude Code later
6// deletes takes nothing away.
7
8/** A reply's numbers, in this order: replies (1), fresh input, cache written, cache read, output. */
9export type Counts = [number, number, number, number, number]
10
11/** One reply Claude wrote, as a transcript line records it. */
12export type Reply = {
13 /** The reply's identity across transcripts: a resumed conversation copies its replies. */
14 key: string
15 /** The local day it was written, `YYYY-MM-DD`. */
16 day: string
17 model: string
18 session: string
19 counts: Counts
20}
21
22/** How far the panel has read one transcript. */
23export type FileMark = { size: number; mtimeMs: number; offset: number }
24
25/** Everything counted so far: what the panel saves, and what its numbers are summed from. */
26export type Ledger = {
27 version: 1
28 /** day → model → counts. */
29 days: Record<string, Record<string, Counts>>
30 /** day → the conversations that had a reply that day. */
31 daySessions: Record<string, string[]>
32 /** conversation → model → counts. */
33 sessions: Record<string, Record<string, Counts>>
34 /** reply key's hash → its day: what has been counted, kept for recent days only. */
35 seen: Record<string, string>
36 /** transcript path → how far it has been read. */
37 files: Record<string, FileMark>
38}
39
40export function emptyLedger(): Ledger {
41 return { version: 1, days: {}, daySessions: {}, sessions: {}, seen: {}, files: {} }
42}
43
44/** A saved ledger read back, or an empty one when the text is no ledger. */
45export function ledgerOf(text: string | undefined): Ledger {
46 if (text === undefined) return emptyLedger()
47 try {
48 const parsed = JSON.parse(text) as Partial<Ledger> | null
49 if (parsed?.version !== 1) return emptyLedger()
50 return { ...emptyLedger(), ...parsed }
51 } catch {
52 return emptyLedger()
53 }
54}
55
56function pad(n: number): string {
57 return String(n).padStart(2, '0')
58}
59
60/** A date's local day, `YYYY-MM-DD`. */
61export function dayOf(date: Date): string {
62 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
63}
64
65function dayBefore(day: string, days: number): string {
66 const [y, m, d] = day.split('-').map(Number)
67 return dayOf(new Date(y ?? 1970, (m ?? 1) - 1, (d ?? 1) - days))
68}
69
70// Two 32-bit FNV-1a hashes with different seeds: a short, steady name for a reply's key.
71function hashOf(text: string): string {
72 let a = 0x811c9dc5
73 let b = 0x01000193 ^ 0x9e3779b9
74 for (let i = 0; i < text.length; i++) {
75 const c = text.charCodeAt(i)
76 a = Math.imul(a ^ c, 0x01000193) >>> 0
77 b = Math.imul(b ^ c, 0x01000193 ^ 0x5bd1e995) >>> 0
78 }
79 return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
80}
81
82const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
83
84/** The reply a transcript line records, or null for any other line. */
85export function replyOf(text: string): Reply | null {
86 // Most lines are not replies: skip them before parsing.
87 if (!text.includes('"assistant"') || !text.includes('"usage"')) return null
88 let entry: unknown
89 try {
90 entry = JSON.parse(text)
91 } catch {
92 return null
93 }
94 const e = entry as {
95 type?: unknown
96 sessionId?: unknown
97 requestId?: unknown
98 timestamp?: unknown
99 message?: { id?: unknown; model?: unknown; usage?: Record<string, unknown> }
100 }
101 const usage = e.message?.usage
102 const model = e.message?.model
103 if (e.type !== 'assistant' || usage === undefined || typeof model !== 'string' || model.startsWith('<')) return null
104 if (typeof e.timestamp !== 'string') return null
105 const at = new Date(e.timestamp)
106 if (Number.isNaN(at.getTime())) return null
107 return {
108 key: `${String(e.message?.id ?? '')}:${String(e.requestId ?? '')}`,
109 day: dayOf(at),
110 model,
111 session: typeof e.sessionId === 'string' ? e.sessionId : '',
112 counts: [
113 1,
114 num(usage['input_tokens']),
115 num(usage['cache_creation_input_tokens']),
116 num(usage['cache_read_input_tokens']),
117 num(usage['output_tokens']),
118 ],
119 }
120}
121
122function addTo(target: Record<string, Counts>, model: string, counts: Counts) {
123 const sum = target[model] ?? [0, 0, 0, 0, 0]
124 for (let i = 0; i < 5; i++) sum[i] = (sum[i] ?? 0) + (counts[i] ?? 0)
125 target[model] = sum
126}
127
128/** Counts a reply into the ledger: false when it was counted before, from another transcript. */
129export function record(ledger: Ledger, reply: Reply): boolean {
130 const hash = hashOf(reply.key)
131 if (ledger.seen[hash] !== undefined) return false
132 ledger.seen[hash] = reply.day
133 addTo((ledger.days[reply.day] ??= {}), reply.model, reply.counts)
134 const sessions = (ledger.daySessions[reply.day] ??= [])
135 if (!sessions.includes(reply.session)) sessions.push(reply.session)
136 addTo((ledger.sessions[reply.session] ??= {}), reply.model, reply.counts)
137 return true
138}
139
140/**
141 * Forgets which replies were counted before `keepDays` ago, keeping every total: a conversation
142 * resumed from long ago is rare, and the list would otherwise grow without end.
143 */
144export function prune(ledger: Ledger, today: string, keepDays: number) {
145 const oldest = dayBefore(today, keepDays)
146 for (const [hash, day] of Object.entries(ledger.seen)) {
147 if (day < oldest) delete ledger.seen[hash]
148 }
149}
150
151export type ModelShare = { model: string; label: string; output: number; share: number }
152
153export type Summary = {
154 conversations: number
155 replies: number
156 fresh: number
157 cacheWrite: number
158 cacheRead: number
159 /** Everything sent to the model: fresh, written to cache and read from cache. */
160 input: number
161 output: number
162 total: number
163 /** The top five models by output, `share` relative to the first. */
164 models: ModelShare[]
165 topModel: string | null
166 /** The first day counted, in the period's span. */
167 since: string | null
168}
169
170/** The numbers the Stats tab shows: one conversation, or every conversation over a period. */
171export function summarize(
172 ledger: Ledger,
173 opts: { scope: Scope; period: Period; session: string; today: string },
174): Summary {
175 const byModel: Record<string, Counts> = {}
176 const conversations = new Set<string>()
177 let since: string | null = null
178
179 if (opts.scope === 'session') {
180 for (const [model, counts] of Object.entries(ledger.sessions[opts.session] ?? {})) addTo(byModel, model, counts)
181 if (Object.keys(byModel).length > 0) conversations.add(opts.session)
182 } else {
183 const from =
184 opts.period === 'today' ? opts.today : opts.period === '7d' ? dayBefore(opts.today, 6) : opts.period === '30d' ? dayBefore(opts.today, 29) : ''
185 for (const [day, models] of Object.entries(ledger.days)) {
186 if (day < from || day > opts.today) continue
187 for (const [model, counts] of Object.entries(models)) addTo(byModel, model, counts)
188 for (const session of ledger.daySessions[day] ?? []) conversations.add(session)
189 if (since === null || day < since) since = day
190 }
191 }
192
193 const total: Counts = [0, 0, 0, 0, 0]
194 for (const counts of Object.values(byModel)) for (let i = 0; i < 5; i++) total[i] = (total[i] ?? 0) + (counts[i] ?? 0)
195 const [replies, fresh, cacheWrite, cacheRead, output] = total
196 const ranked = Object.entries(byModel)
197 .map(([model, counts]) => ({ model, label: modelLabel(model), output: counts[4] }))
198 .sort((a, b) => b.output - a.output)
199 .slice(0, 5)
200 const most = ranked[0]?.output ?? 0
201 return {
202 conversations: conversations.size,
203 replies,
204 fresh,
205 cacheWrite,
206 cacheRead,
207 input: fresh + cacheWrite + cacheRead,
208 output,
209 total: fresh + cacheWrite + cacheRead + output,
210 models: ranked.map(m => ({ ...m, share: most > 0 ? m.output / most : 0 })),
211 topModel: ranked[0]?.label ?? null,
212 since,
213 }
214}
215
216/** A model's name as people say it: `claude-opus-5-5` is "Opus 5.5". */
217export function modelLabel(model: string): string {
218 const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(model)
219 if (match === null) return model
220 const [, family = '', major, minor] = match
221 const name = family.charAt(0).toUpperCase() + family.slice(1)
222 return minor === undefined ? `${name} ${major}` : `${name} ${major}.${minor}`
223}
224
225/** A count in three significant figures: 773, 7.32K, 46K, 2.65B. */
226export function compact(n: number): string {
227 if (n < 1000) return String(Math.round(n))
228 const units: [number, string][] = [
229 [1e9, 'B'],
230 [1e6, 'M'],
231 [1e3, 'K'],
232 ]
233 for (const [size, unit] of units) {
234 if (n >= size) {
235 const value = n / size
236 const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2
237 return `${Number(value.toFixed(digits))}${unit}`
238 }
239 }
240 return String(n)
241}
242types/index.d.ts 13 lines1/** The panel's tabs. */
2export type Tab = 'mods' | 'stats'
3/** Which conversations the Stats tab counts: this one, or every one. */
4export type Scope = 'session' | 'all'
5/** Over which days the Stats tab counts every conversation. */
6export type Period = 'today' | '7d' | '30d' | 'all'
7
8declare module 'claude-code' {
9 interface PluginState {
10 'control-panel': { tab: Tab; scope: Scope; period: Period }
11 }
12}
13