SLOPSHOPPER

control-panel

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

panebandcommandtoastprocesstimer
v0.2.0no licenseupdated 2026-10-18moa-os/claude-mods/control-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · control-panel
│ ┃ Dev's CP ✕ › fix the failing auth test and add an audit log call │ ┃ ☀ Good morning, Dev · Thu 9 Oct │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Mods ] [ Stats ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No mods with a command to launch yet. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cp │ │ [ ✦ Dev's CP ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ✦ Dev's CP ]
Pane · Dev's CP
☀ Good morning, Dev · Thu 9 Oct [ Mods ] [ Stats ] No mods with a command to launch yet.
README

claude-mods

Mods for Claude Code: small plugins that change its interface.

Mods

usage-rings

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

  • In the desktop app each ring is a small arc with its percent beside it, then the window's name and time left; hover over a ring for its reset time. In a terminal they're a text row with circle symbols.
  • The rings take the left of the band and leave the rest of the row to other mods, such as control-panel's pill.
  • The 5-hour and weekly figures come from Claude Code itself. The Fable figure comes from the desktop app's usage card, so in a terminal-only session it may show "–".
  • Only plans with usage limits have these figures (not API keys, Bedrock, Vertex or gateways).

control-panel

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.

  • Your name is your Claude account's display name, which Claude Code keeps in its config file on each machine; without one, it's your login name (USER, or USERNAME on Windows).
  • The panel greets you by the time of day ("Good evening, Moa") on a strip whose colours change from morning to night, as the pill's do.
  • Its Mods tab has a card for every command your installed mods add, plus Claude Code's own /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.
  • Its Stats tab counts your token usage: this conversation, or all conversations today, over the last 7 or 30 days, or all time. It shows conversations, replies, input tokens (split into read from cache, written to cache and new), output tokens, total tokens, your most used model, and output by model for your top five.
  • The counts come from the transcripts Claude Code keeps on each machine. The panel counts each reply once and keeps the totals in ~/.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.

Install

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.

Update

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.

Developing

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-mods
Source 5 files
hooks/register.tsx 475 lines
1import { 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}
475
hooks/panel.ts 329 lines
1import 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}
329
hooks/scan.ts 168 lines
1import 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}
168
hooks/stats.ts 242 lines
1import 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}
242
types/index.d.ts 13 lines
1/** 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