SLOPSHOPPER

Token Hamster

Claude Code mod that shows token usage, 5-hour and weekly limits, reset countdown and cost per turn as an animated hamster above the prompt

newpanebandguardcommandstatus
★ 1v0.1.0MITupdated 2026-10-08valeryia-piatrova/token-hamster
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-hamster
│ ┃ Token Hamster ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▀▀▄ ▄▀▀▄ │ ┃ ▄██████████████▄ ⏺ Read(src/auth.ts) │ ┃ ▄███▀▀██████▀▀███▄ ⎿ Read 6 lines │ ┃ ▄▀█████▀█▀▀█▀█████▀▄ z Z z ⏺ Update(src/auth.ts) │ ┃ ▀▀███▀████████▀███▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀██▀████████▀██▀ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ 98.9k context tokens eaten this session │ ┃ 98.9k lifetime · full and napping ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Session limit: 69% left │ ┃ Context: 102.6k left ✻ Worked for 42s · done 4:20 PM │ ┃ $0.42 · cache hit 93% │ ┃ ● input: 2.1k › /hamster │ ┃ ● output: 1.5k ⎿ token-hamster: Token Hamster pane opened. │ ┃ ● cache read: 91.0k │ ┃ ● cache write: 4.3k │ ┃ │ ┃ Pace │ ┃ tokens / turn █ avg 98.9k │ ┃ 1 turns · biggest 98.9k │ ┃ │ ┃ Who spends │ ┃ main ██████████████ 100% │ ┃ │ ┃ Tools · tokens of the turns that used them ▄▀▀▄ ▄▀▀▄ 98.9k context tokens eaten ▄██████████████▄ 102.6k context left · session 69% ▄███▀▀██████▀▀███▄ [ Details ] ▄▀█████▀█▀▀█▀█████▀▄ z Z z ▀▀███▀████████▀███▀▀ ▀██▀████████▀██▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ token-hamster: 🐹 all 98.9k · session 69% · credits $0.42

Draws

Band
▄▀▀▄ ▄▀▀▄ 98.9k context tokens eaten ▄██████████████▄ 102.6k context left · session 69% ▄███▀▀██████▀▀███▄ [ Details ] ▄▀█████▀█▀▀█▀█████▀▄ z Z z ▀▀███▀████████▀███▀▀ ▀██▀████████▀██▀ ▀▀▀▀▀▀▀▀▀▀▀▀
Pane · Token Hamster
▄▀▀▄ ▄▀▀▄ ▄██████████████▄ ▄███▀▀██████▀▀███▄ ▄▀█████▀█▀▀█▀█████▀▄ z Z z ▀▀███▀████████▀███▀▀ ▀██▀████████▀██▀ ▀▀▀▀▀▀▀▀▀▀▀▀ 98.9k context tokens eaten this session 98.9k lifetime · full and napping Session limit: 69% left Context: 102.6k left $0.42 · cache hit 93% ● input: 2.1k ● output: 1.5k ● cache read: 91.0k ● cache write: 4.3k Pace tokens / turn █ avg 98.9k 1 turns · biggest 98.9k Who spends main ██████████████ 100% Tools · tokens of the turns that used them Read ██████████████ 1× · 98.9k Grep ██████████████ 1× · 98.9k Edit ██████████████ 1× · 98.9k Write ██████████████ 2× · 98.9k Bash ██████████████ 4× · 98.9k Models opus-5-5 ██████████████ 98.9k Cache and cost cache hit █ 93% cost / turn █ last $0.42
README

🐹 Token Hamster: Claude Code mod for token usage & limits

License: MIT Claude Code mod Claude Code plugin

Token Hamster demo: the hamster eats tokens, runs in its wheel with a quarter of the Claude Code limit left, and sleeps in its house when the limit runs out

Token Hamster is a Claude Code mod, a plugin whose behaviour lives in a hooks module, that shows your token usage, your 5-hour and weekly plan limits, when they reset, and what each turn costs, live, inside Claude Code. An animated hamster eats every token Claude eats, so you see your usage at a glance instead of running a separate command.

  • Token usage: input, output, cache read and cache write tokens for the session, plus a lifetime total.
  • Usage limits: the 5-hour session limit and the weekly limit as percent left, with a refill in … countdown to the reset.
  • Cost: cost per turn and for the session, and cache hit rate.
  • Forecast: your pace per turn and when the session limit will run out at that pace.
  • Statusline: 🐹 all 3.9M · session 62% · week 95% · credits $1.99 under the prompt.

No API key, no log scraping, nothing estimated: the numbers come from Claude Code's own usage data at the end of every turn.

How do I check my Claude Code usage?

Install the mod and keep coding. Token Hamster answers the usual questions without leaving the session:

QuestionWhere you see it
How many tokens has this session used?The hamster's cheeks, the pane's counts, the statusline
How close am I to the 5-hour limit?session 62% in the statusline; the hamster's mood
How much of the weekly limit is left?week 95% in the statusline; the pane's limit bars
When does my Claude limit reset?refill in … on the cage and in the pane
When will I hit the limit at this pace?The pane's pace forecast
What does a turn cost? Is the cache working?The pane's cost per turn and cache hit rate
Which subagent, tool or model burns the most tokens?The pane's breakdown

Features

The cage band sits above the prompt. While a turn runs the hamster eats; between turns it naps.

Mood follows your rate limits

The hamster's mood follows the emptiest limit, the 5-hour session or the week:

EatingNapping≤ 50% left≤ 25% left0% left
eatingnappingworriedin the wheelasleep in the house

At half the limit it frowns and sweats. With a quarter left it runs it off in its wheel. When the limit is used up it sleeps in its house and counts down to the reset.

Usage dashboard pane

/hamster (or Details) opens a pane with the counts and where the tokens went:

The Token Hamster pane: session token counts, 5-hour and weekly limits, context breakdown, pace forecast, tokens by subagent, tool and model, cache hit rate and cost per turn

  • Context: what fills the context window, as /context lists it.
  • Pace and forecast: tokens per turn and when the session limit runs out.
  • Who spends: the main loop against each subagent type, tool calls and models.
  • Cache and cost: cache hit rate and cost per turn.

Built to be glanced at

  • Smooth vector animation. On desktop, VS Code and mobile it's an animated SVG: seeds fly into its mouth, it chews, blinks, and breathes z z z when idle.
  • The food is your real token mix. 🟡 input, 🟠 output, 🟢 cache read, 🔵 cache write, in your session's actual proportions.
  • The cheeks are the session. They grow with the log of tokens eaten.
  • Terminal fallback. In the terminal it's a colour pixel-art hamster in half blocks, with the same chewing and moods.
  • Subagents count too. Subagent turns feed the hamster.

Token Hamster vs other Claude Code usage tools

Token HamsterccusageUsage monitor appsStatusline scripts
Runs inside Claude Code✅ mod / pluginCLI you runseparate terminal / menu bar app✅
Live 5-hour and weekly limits✅—✅some
Reset countdown and forecast✅—✅some
Cost per turn, cache hit rate✅✅ (from logs)✅some
Breakdown by subagent, tool, model✅by model——
Animated mascot🐹———

Use ccusage for reports over past days and months; use Token Hamster to watch the current session as it happens. They work fine together.

Requirements

  • Claude Code 2.1.289 or later (the mods / plugin hooks API).
  • Works in the terminal, the desktop app, VS Code and mobile.
  • The limit bars and moods appear when Claude Code reports plan limits (Pro and Max subscriptions). With an API key you still get tokens and cost.

Install

It installs like any Claude Code plugin. In Claude Code, run:

/plugin marketplace add valeryia-piatrova/token-hamster
/plugin install token-hamster@token-hamster

Or from your shell:

claude plugin marketplace add valeryia-piatrova/token-hamster
claude plugin install token-hamster@token-hamster

From source

git clone https://github.com/valeryia-piatrova/token-hamster.git ~/mods/token-hamster
claude --plugin-dir ~/mods/token-hamster

For the desktop app, or anywhere you can't pass a flag, add the folder's path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

The cage band sits above the prompt. To open the pane, press Details (d) or type /hamster. Collapse the band with its [-].

How it works

Like the mods that ship inside Claude Code (diff, agents-md), Token Hamster is a plugin with a .claude-plugin/plugin.json, a hooks/hooks.json naming the module, and TypeScript hooks under hooks/.

Data comes from the turn.complete hook's usage field, the API's own token counts, so the counters move once a turn, when it ends. Limits and reset times come from the same event. The lifetime total is kept across sessions in the plugin store.

What each hook does

HookWhat it does
session.startRegisters /hamster, loads the lifetime total and draws the cage.
command.run (hamster)Answers /hamster itself by opening the pane. It handles no other command.
tool.callCounts calls per tool for the pane's breakdown. It never changes, blocks or delays a tool: every call goes on to the tool unchanged.
turn.startStarts the eating animation.
turn.completeAdds the turn's tokens, refreshes the limits and stops the animation.
ui.render (AbovePrompt, Pane)Draws the cage band and the pane.

It reads only the session's own usage and the list of subagents (to name their types). It runs no processes, reads no files and makes no network requests.

Develop

claude plugin validate .
claude plugin test .
FileWhat it is
.claude-plugin/plugin.jsonmanifest
hooks/hooks.jsonpoints to the hooks module
hooks/register.tsxthe hooks: session.start, command.run, tool.call, turn.start, turn.complete, ui.render (band and pane)
types/index.d.tsthe $.state contract
tests/hamster.test.tsxtests on the terminal and desktop surfaces

Built on the official plugin-authoring API (function hooks). The API is early access and may change between releases.

License

MIT

Source 2 files
hooks/register.tsx 759 lines
1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ElementConstructor, EngineInterface, MarkdownProps, Register, TextProps, Timer } from 'claude-code'
3
4import type { Cage, FigureBlock, Figures, Left, Meal, Stats } from '../types'
5
6const PANE = 'token-hamster'
7const TITLE = 'Token Hamster'
8const STORE_KEY = 'lifetime'
9const EMPTY: Meal = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
10const NO_LEFT: Left = { limits: [] }
11
12const session = atom({ plugin: 'token-hamster', key: 'session' } as const, EMPTY)
13const lifetime = atom({ plugin: 'token-hamster', key: 'lifetime' } as const, 0)
14const isEating = atom({ plugin: 'token-hamster', key: 'isEating' } as const, false)
15const frame = atom({ plugin: 'token-hamster', key: 'frame' } as const, 0)
16const left = atom({ plugin: 'token-hamster', key: 'left' } as const, NO_LEFT)
17export const NO_STATS: Stats = { turns: 0, biggest: 0, total: 0, agents: {}, models: {}, tools: {}, recent: [], lastCost: 0, context: [] }
18const stats = atom({ plugin: 'token-hamster', key: 'stats' } as const, NO_STATS)
19const cage = atom({ plugin: 'token-hamster', key: 'cage' } as const, { m: EMPTY, l: NO_LEFT, eating: false, key: '' } as Cage)
20
21const KINDS = [
22  { key: 'input', label: 'input', color: '#F2C14E' },
23  { key: 'output', label: 'output', color: '#E76F51' },
24  { key: 'cacheRead', label: 'cache read', color: '#8AB17D' },
25  { key: 'cacheWrite', label: 'cache write', color: '#6C8EBF' },
26] as const
27
28export const sum = (m: Meal) => m.input + m.output + m.cacheRead + m.cacheWrite
29
30export const fmt = (n: number) =>
31  n >= 999_950_000 ? `${(n / 1e9).toFixed(2)}B`
32  : n >= 999_950 ? `${(n / 1e6).toFixed(1)}M`
33  : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k`
34  : String(n)
35
36const esc = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
37
38const WINDOWS: Record<string, string> = { five_hour: 'session', seven_day: 'week' }
39
40export const leftText = (l: Left) =>
41  [
42    ...(l.context === undefined ? [] : [`${fmt(l.context)} context left`]),
43    ...l.limits.map(w => `${WINDOWS[w.kind] ?? w.kind} ${w.percentLeft}%`),
44  ].join(' · ')
45
46export type Mood = 'fine' | 'worried' | 'running' | 'empty'
47export const mood = (l: Left): Mood => {
48  const p = Math.min(100, ...l.limits.map(w => w.percentLeft))
49  return p <= 0 ? 'empty' : p <= 25 ? 'running' : p <= 50 ? 'worried' : 'fine'
50}
51const MOOD_TEXT: Record<Mood, string> = {
52  fine: '',
53  worried: 'half the stash is gone',
54  running: 'a quarter left, running it off',
55  empty: 'out of food, asleep in the house',
56}
57
58export const until = (iso: string | undefined, now: number) => {
59  const ms = iso ? Date.parse(iso) - now : NaN
60  if (!(ms > 0)) return ''
61  const hours = Math.floor(ms / 3.6e6)
62  return hours >= 24 ? `${Math.floor(hours / 24)}d ${hours % 24}h` : `${hours}h ${Math.floor(ms / 6e4) % 60}m`
63}
64
65export const moodText = (l: Left, now: number) => {
66  const md = mood(l)
67  if (md === 'fine') return ''
68  const worst = [...l.limits].sort((a, b) => a.percentLeft - b.percentLeft)[0]
69  const t = until(worst?.resetsAt, now)
70  return t ? `${MOOD_TEXT[md]} · refill in ${t}` : MOOD_TEXT[md]
71}
72
73export const cacheHit = (m: Meal) => {
74  const inTotal = m.input + m.cacheRead + m.cacheWrite
75  return inTotal ? Math.round((m.cacheRead / inTotal) * 100) : 0
76}
77
78export const EATING = 'eating tokens…'
79export const eaten = (m: Meal, suffix = '') => (sum(m) ? `${fmt(sum(m))} context tokens eaten${suffix}` : EATING)
80
81export const statusText = (m: Meal, l: Left) =>
82  [
83    sum(m) ? `🐹 all ${fmt(sum(m))}` : `🐹 ${EATING}`,
84    ...l.limits.map(w => `${WINDOWS[w.kind] ?? w.kind} ${w.percentLeft}%`),
85    ...(l.cost === undefined ? [] : [`credits $${l.cost.toFixed(2)}`]),
86  ].join(' · ')
87
88export const cheek = (total: number) => 1 + Math.min(0.8, Math.log10(total + 1) / 11)
89
90export const seedColors = (m: Meal) => {
91  const total = sum(m)
92  if (total === 0) return Array(6).fill(KINDS[0].color) as string[]
93  const out: string[] = []
94  for (const k of KINDS) {
95    const n = Math.round((m[k.key] / total) * 6)
96    for (let i = 0; i < n && out.length < 6; i++) out.push(k.color)
97  }
98  while (out.length < 6) out.push(KINDS[0].color)
99  return out
100}
101
102const hamster = (m: Meal, eating: boolean, md: Mood = 'fine') => {
103  const c = Math.round(cheek(sum(m)) * 10) / 10
104  const crx = (18 * c).toFixed(1)
105  const cry = (15 * c).toFixed(1)
106  const wob = (18 * c + 2).toFixed(1)
107  const cheekAnim = eating
108    ? `<animate attributeName="rx" values="${crx};${wob};${crx}" dur="0.35s" repeatCount="indefinite"/>`
109    : ''
110  const eyes = eating
111    ? [102, 138].map(x => `
112      <ellipse cx="${x}" cy="92" rx="6" ry="6" fill="#2B2118">
113        <animate attributeName="ry" values="6;6;0.6;6" keyTimes="0;0.9;0.95;1" dur="3.5s" repeatCount="indefinite"/>
114      </ellipse>
115      <circle cx="${x + 2}" cy="90" r="2" fill="#fff"/>`).join('')
116    : [102, 138].map(x => `<path d="M${x - 6} 93 Q${x} 98 ${x + 6} 93" stroke="#2B2118" stroke-width="2.5" fill="none" stroke-linecap="round"/>`).join('')
117  const mouth = eating
118    ? `<ellipse cx="120" cy="116" rx="5" ry="2" fill="#7A3B3B">
119        <animate attributeName="ry" values="1;4;1" dur="0.35s" repeatCount="indefinite"/>
120      </ellipse>`
121    : md === 'fine'
122    ? `<path d="M113 112 Q120 118 127 112" stroke="#7A3B3B" stroke-width="2" fill="none" stroke-linecap="round"/>`
123    : `<path d="M113 116 Q120 110 127 116" stroke="#7A3B3B" stroke-width="2" fill="none" stroke-linecap="round"/>`
124  const brows = md === 'fine' || md === 'empty' ? '' : `
125    <path d="M94 80 L108 76" stroke="#7A4B3A" stroke-width="3" stroke-linecap="round"/>
126    <path d="M146 80 L132 76" stroke="#7A4B3A" stroke-width="3" stroke-linecap="round"/>`
127  const sweat = (md === 'worried' ? [120] : md === 'running' ? [112, 128] : []).map((x, i) => `
128    <path d="M${x} 58 q-5 8 0 11 q5 -3 0 -11z" fill="#8FC3F0" opacity="0">
129      <animate attributeName="opacity" values="0;1;0" dur="1.6s" begin="${i * 0.8}s" repeatCount="indefinite"/>
130      <animateTransform attributeName="transform" type="translate" values="0 0;0 14" dur="1.6s" begin="${i * 0.8}s" repeatCount="indefinite"/>
131    </path>`).join('')
132  const shake = md === 'running'
133    ? `<animateTransform attributeName="transform" type="translate" values="0 0;1.5 0;-1.5 0;0 0" dur="0.25s" repeatCount="indefinite"/>`
134    : ''
135  const seeds = eating
136    ? seedColors(m).map((color, i) => {
137        const x = 30 + i * 36
138        return `
139      <g opacity="0">
140        <ellipse rx="4" ry="7" fill="${color}" stroke="#00000033"/>
141        <animateMotion path="M${x} -10 Q${x} 60 120 116" dur="1.2s" begin="${(i * 0.2).toFixed(1)}s" repeatCount="indefinite" rotate="auto"/>
142        <animate attributeName="opacity" values="1;1;0" keyTimes="0;0.85;1" dur="1.2s" begin="${(i * 0.2).toFixed(1)}s" repeatCount="indefinite"/>
143      </g>`
144      }).join('')
145    : ''
146  const zzz = eating
147    ? ''
148    : [0, 1, 2].map(i => `
149      <text x="${172 + i * 12}" y="60" font-family="sans-serif" font-size="${12 + i * 4}" fill="#9A8C7E" opacity="0">z
150        <animate attributeName="y" values="64;30" dur="3s" begin="${i}s" repeatCount="indefinite"/>
151        <animate attributeName="opacity" values="0;1;0" dur="3s" begin="${i}s" repeatCount="indefinite"/>
152      </text>`).join('')
153  const breathe = eating ? '' : `<animateTransform attributeName="transform" type="scale" values="1 1;1.02 0.98;1 1" dur="3s" additive="sum" repeatCount="indefinite"/>`
154
155  return `
156  <g transform-origin="120 190">
157    ${breathe}
158    <g>${shake}
159    <ellipse cx="120" cy="190" rx="70" ry="6" fill="#00000018"/>
160    <ellipse cx="120" cy="145" rx="70" ry="48" fill="#E9A96B"/>
161    <ellipse cx="120" cy="155" rx="42" ry="32" fill="#FBE3C8"/>
162    <circle cx="80" cy="62" r="16" fill="#E9A96B"/><circle cx="80" cy="62" r="9" fill="#F5B8B0"/>
163    <circle cx="160" cy="62" r="16" fill="#E9A96B"/><circle cx="160" cy="62" r="9" fill="#F5B8B0"/>
164    <ellipse cx="120" cy="96" rx="52" ry="44" fill="#E9A96B"/>
165    <ellipse cx="82" cy="112" rx="${crx}" ry="${cry}" fill="#F4C59A">${cheekAnim}</ellipse>
166    <ellipse cx="158" cy="112" rx="${crx}" ry="${cry}" fill="#F4C59A">${cheekAnim}</ellipse>
167    <circle cx="80" cy="110" r="6" fill="#F29E9E" opacity="0.55"/>
168    <circle cx="160" cy="110" r="6" fill="#F29E9E" opacity="0.55"/>
169    <ellipse cx="120" cy="110" rx="26" ry="20" fill="#FBE3C8"/>
170    ${eyes}
171    <ellipse cx="120" cy="104" rx="4" ry="3" fill="#D9707A"/>
172    ${mouth}
173    <ellipse cx="106" cy="132" rx="7" ry="5" fill="#F4C59A"/>
174    <ellipse cx="134" cy="132" rx="7" ry="5" fill="#F4C59A"/>
175    <ellipse cx="96" cy="188" rx="12" ry="5" fill="#F4C59A"/>
176    <ellipse cx="144" cy="188" rx="12" ry="5" fill="#F4C59A"/>
177    ${brows}${sweat}
178    </g>
179  </g>
180  ${seeds}
181  ${zzz}`
182}
183
184export const hamsterSvg = (m: Meal, eating: boolean, md: Mood = 'fine') =>
185  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 200" width="240" height="200">${hamster(m, eating, md)}</svg>`
186
187export const sideHamster = (step: number) => {
188  const leg = (x: number, phase: number, far: boolean) => `
189    <g transform="translate(${x} -13)"><g>
190      <animateTransform attributeName="transform" type="rotate" values="-38;34;-38" dur="${step}s" begin="${(-phase * step).toFixed(3)}s" repeatCount="indefinite"/>
191      <rect x="-3.2" y="0" width="6.4" height="12" rx="3.2" fill="${far ? '#D4914F' : '#E9A96B'}"/>
192      <ellipse cx="2" cy="12" rx="5" ry="2.6" fill="${far ? '#E8B48A' : '#F4C59A'}"/>
193    </g></g>`
194  const bob = (step / 2).toFixed(3)
195  return `
196  <g>
197    <animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="${bob}s" repeatCount="indefinite"/>
198    ${leg(-17, 0.5, true)}${leg(25, 0, true)}
199    ${leg(-21, 0, false)}${leg(21, 0.5, false)}
200    <g transform-origin="0 -6">
201      <animateTransform attributeName="transform" type="scale" values="1 1;1.05 0.95;1 1" dur="${bob}s" repeatCount="indefinite"/>
202      <ellipse cx="-36" cy="-15" rx="4" ry="3" fill="#F4C59A"/>
203      <path d="M-36 -12 C-38 -34 -16 -46 6 -45 C26 -44 40 -36 46 -24 C50 -14 44 -6 30 -6 L-24 -6 C-33 -6 -36 -8 -36 -12 Z" fill="#E9A96B"/>
204      <path d="M-26 -7 C-20 -18 8 -20 28 -10 C30 -7 27 -6 24 -6 L-22 -6 Z" fill="#FBE3C8"/>
205      <ellipse cx="34" cy="-17" rx="11" ry="8" fill="#F4C59A"/>
206      <ellipse cx="45" cy="-24" rx="8" ry="6" fill="#FBE3C8"/>
207      <circle cx="52" cy="-26" r="2.6" fill="#D9707A"/>
208      <path d="M48 -23 L62 -27 M48 -22 L63 -22 M48 -21 L61 -17" stroke="#9A8C7E" stroke-width="0.8" stroke-linecap="round"/>
209      <ellipse cx="36" cy="-32" rx="3.4" ry="3.8" fill="#2B2118"/>
210      <circle cx="37.2" cy="-33.4" r="1.1" fill="#fff"/>
211      <g transform="rotate(-18 20 -44)">
212        <ellipse cx="20" cy="-44" rx="6" ry="7" fill="#E9A96B"/>
213        <ellipse cx="20" cy="-43" rx="3.4" ry="4.4" fill="#F5B8B0"/>
214      </g>
215      <path d="M42 -42 q-4 7 0 10 q4 -3 0 -10z" fill="#8FC3F0">
216        <animate attributeName="opacity" values="1;0" dur="0.8s" repeatCount="indefinite"/>
217        <animateTransform attributeName="transform" type="translate" values="0 0;-12 -6" dur="0.8s" repeatCount="indefinite"/>
218      </path>
219    </g>
220  </g>`
221}
222
223const wheel = (r: number, spin: string, md: Mood, runScale: number) => {
224  const spokes = [0, 45, 90, 135].map(a => `<line x1="0" y1="${4 - r}" x2="0" y2="${r - 4}" transform="rotate(${a})" stroke="#B9C3CF" stroke-width="3"/>`).join('')
225  const running = md === 'running'
226  return `
227    <path d="M${(-0.57 * r).toFixed(1)} ${r + 8} L0 0 L${(0.57 * r).toFixed(1)} ${r + 8}" stroke="#9AA6B4" stroke-width="5" fill="none" stroke-linecap="round"/>
228    <circle r="${r}" fill="none" stroke="#9AA6B4" stroke-width="5"/>
229    <g>${spokes}<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="${running ? '0.5s' : spin}" repeatCount="indefinite"/></g>
230    <circle r="5" fill="#9AA6B4"/>
231    ${running ? `<g transform="translate(0 ${r - 5}) scale(${runScale})">${sideHamster(0.2)}</g>` : ''}`
232}
233
234export const paneWheelSvg = () =>
235  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 200" width="240" height="200">
236  <rect x="0" y="190" width="240" height="10" rx="5" fill="#E8D3A8"/>
237  <g transform="translate(120 106)">${wheel(76, '14s', 'running', 0.92)}</g>
238</svg>`
239
240const house = (asleep: boolean) => `
241    <path d="M0 80 L0 26 Q45 -16 90 26 L90 80 Z" fill="#E58F6B"/>
242    <path d="M-6 30 Q45 -24 96 30" stroke="#C96F4E" stroke-width="8" fill="none" stroke-linecap="round"/>
243    <path d="M30 80 L30 52 Q45 38 60 52 L60 80 Z" fill="#7A4B3A"/>
244    ${asleep ? `
245    <g transform-origin="45 80">
246      <animateTransform attributeName="transform" type="scale" values="1 1;1.03 0.97;1 1" dur="3s" repeatCount="indefinite"/>
247      <circle cx="37" cy="61" r="4.5" fill="#E9A96B"/><circle cx="37" cy="61" r="2.2" fill="#F5B8B0"/>
248      <circle cx="53" cy="61" r="4.5" fill="#E9A96B"/><circle cx="53" cy="61" r="2.2" fill="#F5B8B0"/>
249      <ellipse cx="45" cy="72" rx="13" ry="10" fill="#E9A96B"/>
250      <ellipse cx="45" cy="76" rx="7" ry="4.5" fill="#FBE3C8"/>
251      <path d="M38 70 q2.5 2.5 5 0 M47 70 q2.5 2.5 5 0" stroke="#2B2118" stroke-width="1.5" fill="none" stroke-linecap="round"/>
252      <circle cx="45" cy="74.5" r="1.5" fill="#D9707A"/>
253    </g>
254    ${[0, 1, 2].map(i => `
255    <text x="${56 + i * 8}" y="20" font-family="sans-serif" font-size="${10 + i * 4}" fill="#9A8C7E" opacity="0">z
256      <animate attributeName="y" values="24;-14" dur="3s" begin="${i}s" repeatCount="indefinite"/>
257      <animate attributeName="opacity" values="0;1;0" dur="3s" begin="${i}s" repeatCount="indefinite"/>
258    </text>`).join('')}` : ''}`
259
260export const paneHouseSvg = () =>
261  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 200" width="240" height="200">
262  <rect x="0" y="190" width="240" height="10" rx="5" fill="#E8D3A8"/>
263  <g transform="translate(30 30) scale(2)">${house(true)}</g>
264</svg>`
265
266export const cageKey = (m: Meal, eating: boolean, l: Left) =>
267  JSON.stringify([eating, mood(l), Math.round(cheek(sum(m)) * 10) / 10, seedColors(m)])
268
269const bandText = (m: Meal, l: Left) => {
270  const md = mood(l)
271  return `
272  <text x="285" y="46" font-family="ui-sans-serif, system-ui, sans-serif" font-size="36" font-weight="700" fill="#5B4636">${sum(m) ? `${fmt(sum(m))}<tspan font-size="26" font-weight="600" fill="#7A6656"> context tokens eaten</tspan>` : EATING}</text>
273  <text x="285" y="80" font-family="ui-sans-serif, system-ui, sans-serif" font-size="20" font-weight="600" fill="#5B4636">${esc(leftText(l))}</text>
274  <text x="285" y="110" font-family="ui-sans-serif, system-ui, sans-serif" font-size="19" font-weight="700" fill="${md === 'worried' ? '#C98A2B' : '#C0453A'}">${esc(moodText(l, l.at ?? 0))}</text>`
275}
276
277export const sceneSvg = (m: Meal, eating: boolean, l = NO_LEFT, withText: unknown = true) => {
278  const md = mood(l)
279  const dust = [-300, -120, 60, 150, 330, 420, 700, 790, 960, 1130, 1300, 1480].map((x, i) =>
280    `<path d="M${x} ${143 + (i % 3) * 2} q6 -3 12 0" stroke="#CDB98F" stroke-width="2" fill="none"/>`).join('')
281  const pebbles = [-200, 90, 380, 760, 1050, 1400].map((x, i) =>
282    `<ellipse cx="${x}" cy="${145 + (i % 2) * 2}" rx="${6 + (i % 3) * 2}" ry="2.5" fill="#C9B48A"/>`).join('')
283  const motes = [120, 300, 470, 720, 900, 1110].map((x, i) => `
284    <circle cx="${x}" cy="100" r="${1.5 + (i % 3) * 0.7}" fill="#F2C14E" opacity="0">
285      <animate attributeName="cy" values="120;20" dur="${7 + i}s" begin="${i * 1.1}s" repeatCount="indefinite"/>
286      <animate attributeName="opacity" values="0;0.7;0" dur="${7 + i}s" begin="${i * 1.1}s" repeatCount="indefinite"/>
287    </circle>`).join('')
288  const sprout = (x: number, height: number) =>
289    `<path d="M${x} 140 q-6 -${height / 2} 0 -${height}" stroke="#7FA65A" stroke-width="4" fill="none" stroke-linecap="round">
290      <animateTransform attributeName="transform" type="rotate" values="-3 ${x} 140;3 ${x} 140;-3 ${x} 140" dur="5s" repeatCount="indefinite"/></path>`
291  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 150" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
292  <rect x="-4000" y="-500" width="9200" height="640" fill="#F7EEDF"/>
293  <rect x="-4000" y="140" width="9200" height="500" fill="#E8D3A8"/>
294  <path d="M-4000 140 H5200" stroke="#D9C091" stroke-width="2"/>
295  ${dust}${pebbles}${motes}
296  ${sprout(40, 34)}${sprout(52, 24)}${sprout(1160, 30)}${sprout(1172, 40)}
297  <g transform="translate(180 67)">${wheel(65, eating ? '1.2s' : '14s', md, 0.87)}</g>
298  <g transform="translate(963 2) scale(1.72)">${house(md === 'empty')}</g>
299  ${md === 'running' || md === 'empty' ? '' : `<g transform="translate(745 -31) scale(0.9)">${hamster(m, eating, md)}</g>`}
300  ${withText ? bandText(m, l) : ''}
301</svg>`
302}
303
304const PX: Record<string, string> = {
305  O: '#E9A96B',
306  C: '#F4C59A',
307  B: '#FBE3C8',
308  P: '#F5B8B0',
309  E: '#2B2118',
310  W: '#FFFFFF',
311  N: '#D9707A',
312  M: '#7A3B3B',
313  D: '#8FC3F0',
314}
315
316export const hamsterPixels = (m: Meal, eating: boolean, f: number, md: Mood = 'fine') => {
317  const g = [
318    '...OO........OO...',
319    '..OPPO......OPPO..',
320    '..OOOOOOOOOOOOOO..',
321    '.OOOOOOOOOOOOOOOO.',
322    '.OOOOOOOOOOOOOOOO.',
323    'OOOOOOOOOOOOOOOOOO',
324    'OCCCOOOBBBBOOOCCCO',
325    'CCCCOOBBNNBBOOCCCC',
326    'CCCCCBBBBBBBBCCCCC',
327    '.CCCBBBBBBBBBBCCC.',
328    '.OOOBBBBBBBBBBOOO.',
329    '..OOOBBBBBBBBOOO..',
330    '...OOOOOOOOOOOO...',
331    '...CCC......CCC...',
332  ].map(r => r.split(''))
333  const put = (y: number, x: number, c: string) => { g[y]![x] = c }
334  const awake = eating || md === 'worried' || md === 'running'
335  for (const x of [4, 12]) {
336    if (md === 'running') { put(4, x, 'W'); put(4, x + 1, 'W'); put(5, x, 'E'); put(5, x + 1, 'E') }
337    else if (awake) { put(4, x, 'E'); put(4, x + 1, 'W'); put(5, x, 'E'); put(5, x + 1, 'E') }
338    else { put(5, x, 'E'); put(5, x + 1, 'E') }
339  }
340  if (md === 'worried' || md === 'running') { put(3, 4, 'E'); put(3, 5, 'E'); put(3, 12, 'E'); put(3, 13, 'E') }
341  if (eating && f % 2) { put(8, 8, 'M'); put(8, 9, 'M') }
342  if (md === 'worried') { put(2, 8, 'D'); put(3, 8, 'D') }
343  if (md === 'running') { put(2, 6, 'D'); put(3, 6, 'D'); put(2, 11, 'D'); put(3, 11, 'D') }
344  const puff = cheek(sum(m)) > 1.35
345  for (const [y, r] of g.entries()) {
346    const c = puff && (y === 7 || y === 8) ? 'C' : '.'
347    r.unshift(c); r.push(c)
348  }
349  return g.map(r => r.join(''))
350}
351
352export const halfBlocks = (rows: string[]) => {
353  const out: { ch: string; fg?: string; bg?: string }[][] = []
354  for (let y = 0; y < rows.length; y += 2) {
355    out.push([...rows[y]!].map((t, x) => {
356      const b = rows[y + 1]?.[x] ?? '.'
357      const top = PX[t], bot = PX[b]
358      return !top && !bot ? { ch: ' ' }
359        : top === bot ? { ch: '█', fg: top }
360        : !bot ? { ch: '▀', fg: top }
361        : !top ? { ch: '▄', fg: bot }
362        : { ch: '▀', fg: top, bg: bot }
363    }))
364  }
365  return out
366}
367
368const refreshLeft = async ($: EngineInterface) => {
369  const u = await $.session.usage().catch(() => undefined)
370  if (!u) return
371  const { tokens, window } = u.context
372  const now = await $.clock.now().catch(() => 0)
373  const next: Left = {
374    context: tokens === undefined ? undefined : Math.max(0, window - tokens),
375    window,
376    cost: u.cost?.usd,
377    at: Math.floor(now / 60_000) * 60_000,
378    limits: u.rateLimits.map(w => ({
379      kind: w.kind,
380      percentLeft: Math.max(0, Math.round(100 - w.percentUsed)),
381      resetsAt: w.resetsAt,
382    })),
383  }
384  if (JSON.stringify(await read($, left)) === JSON.stringify(next)) return
385  await update($, left, () => next)
386}
387
388const feed = async ($: EngineInterface, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) => {
389  const meal: Meal = {
390    input: u.input_tokens,
391    output: u.output_tokens,
392    cacheRead: u.cache_read_input_tokens,
393    cacheWrite: u.cache_creation_input_tokens,
394  }
395  await update($, session, s => ({
396    input: s.input + meal.input,
397    output: s.output + meal.output,
398    cacheRead: s.cacheRead + meal.cacheRead,
399    cacheWrite: s.cacheWrite + meal.cacheWrite,
400  }))
401  const stored = await $.store.get(STORE_KEY)
402  const life = (typeof stored === 'number' && Number.isFinite(stored) ? stored : 0) + sum(meal)
403  await $.store.set(STORE_KEY, life)
404  await update($, lifetime, () => life)
405  await refreshLeft($)
406  await syncCage($)
407  await showStatus($)
408}
409
410const showStatus = async ($: EngineInterface) => {
411  $.ui.status(statusText(await read($, session), await read($, left)))
412}
413
414const syncCage = async ($: EngineInterface) => {
415  const m = await read($, session)
416  const eating = await read($, isEating)
417  const l = await read($, left)
418  const key = cageKey(m, eating, l)
419  if ((await read($, cage)).key !== key) await update($, cage, () => ({ m, l, eating, key }))
420}
421
422const recordTurn = async (
423  $: EngineInterface,
424  agentId: string | undefined,
425  u: { model?: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined,
426  tools: Record<string, number>,
427) => {
428  const meal: Meal = u
429    ? { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens }
430    : EMPTY
431  const who = agentId
432    ? ((await $.agent.list().catch(() => [])).find(a => a.id === agentId)?.type ?? 'subagent')
433    : 'main'
434  const breakdown = agentId ? undefined : (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown
435  const l = await read($, left)
436  await update($, stats, st => ({
437    ...addTurn(st, { tokens: sum(meal), who, model: u?.model, tools, hit: cacheHit(meal), l }),
438    context: breakdown
439      ? breakdown.categories.filter(c => c.kind === 'used' && c.tokens > 0).sort((a, b) => b.tokens - a.tokens).slice(0, 6)
440        .map((c, i) => ({ name: c.name, tokens: c.tokens, color: PALETTE[i % PALETTE.length]! }))
441      : st.context,
442  }))
443}
444
445const warnColor = (md: Mood) => (md === 'worried' ? '#C98A2B' : '#C0453A')
446
447export const bandFigures = (m: Meal, l: Left): Figures => {
448  const md = mood(l)
449  return {
450    blocks: [
451      { md: leftText(l) ? `## ${eaten(m)}\n\n**${leftText(l)}**` : `## ${eaten(m)}` },
452      ...(md === 'fine' ? [] : [{ text: moodText(l, l.at ?? 0), bold: true, color: warnColor(md) }]),
453    ],
454  }
455}
456
457export const paneStatus = (m: Meal, l: Left, eating: boolean) => {
458  const md = mood(l)
459  return md !== 'fine' ? moodText(l, l.at ?? 0) : eating ? 'nom nom nom…' : sum(m) ? 'full and napping' : 'hungry, waiting for a prompt'
460}
461
462const RECENT = 12
463
464export const addTurn = (
465  st: Stats,
466  t: { tokens: number; who: string; model?: string; tools: Record<string, number>; hit: number; l: Left },
467): Stats => {
468  const add = (o: Record<string, number>, k: string | undefined, n: number) => (k ? { ...o, [k]: (o[k] ?? 0) + n } : o)
469  const tools = { ...st.tools }
470  for (const [name, calls] of Object.entries(t.tools)) {
471    const was = tools[name] ?? { calls: 0, tokens: 0 }
472    tools[name] = { calls: was.calls + calls, tokens: was.tokens + t.tokens }
473  }
474  const cost = t.l.cost ?? st.lastCost
475  const main = t.who === 'main'
476  const five = t.l.limits.find(w => w.kind === 'five_hour')
477  const pace = !five ? st.pace : !st.pace || five.percentLeft > st.pace.pct ? { at: t.l.at ?? 0, pct: five.percentLeft } : st.pace
478  return {
479    ...st,
480    turns: st.turns + (main ? 1 : 0),
481    biggest: main ? Math.max(st.biggest, t.tokens) : st.biggest,
482    total: st.total + t.tokens,
483    agents: add(st.agents, t.who, t.tokens),
484    models: add(st.models, t.model?.replace(/^claude-/, ''), t.tokens),
485    tools,
486    recent: main ? [...st.recent, { tokens: t.tokens, hit: t.hit, cost: Math.max(0, cost - st.lastCost) }].slice(-RECENT) : st.recent,
487    lastCost: cost,
488    pace,
489  }
490}
491
492export const forecast = (st: Stats, l: Left) => {
493  const five = l.limits.find(w => w.kind === 'five_hour')
494  if (!five || !st.pace || l.at === undefined) return ''
495  const spent = st.pace.pct - five.percentLeft
496  const ms = l.at - st.pace.at
497  if (spent <= 0 || ms <= 0) return ''
498  const left = (five.percentLeft / spent) * ms
499  const reset = five.resetsAt ? Date.parse(five.resetsAt) - l.at : Infinity
500  if (left >= reset) return 'at this pace the session limit lasts until it resets'
501  return `at this pace the session limit runs out in ~${until(new Date(l.at + left).toISOString(), l.at) || '1m'}`
502}
503
504const PALETTE = ['#E9A96B', '#6C8EBF', '#8AB17D', '#E76F51', '#F2C14E', '#B48EAD', '#9AA6B4']
505const top = (o: Record<string, number>, n = 6) => Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, n)
506
507export const statsFigures = (st: Stats, l: Left): FigureBlock[] => {
508  const out: FigureBlock[] = []
509  const used = st.context.reduce((n, c) => n + c.tokens, 0)
510  if (used) {
511    out.push({ head: 'What fills the context' })
512    for (const c of st.context) out.push({ bar: { label: c.name, share: c.tokens / used, value: fmt(c.tokens), color: c.color } })
513  }
514  if (st.recent.length) {
515    out.push({ head: 'Pace' })
516    out.push({ spark: { label: 'tokens / turn', values: st.recent.map(t => t.tokens), value: `avg ${fmt(Math.round(st.recent.reduce((n, t) => n + t.tokens, 0) / st.recent.length))}` } })
517    out.push({ text: `${st.turns} turns · biggest ${fmt(st.biggest)}`, dim: true })
518    const f = forecast(st, l)
519    if (f) out.push({ text: f, bold: true })
520  }
521  if (st.total) {
522    out.push({ head: 'Who spends' })
523    top(st.agents).forEach(([who, n], i) => out.push({ bar: { label: who, share: n / st.total, value: `${Math.round((n / st.total) * 100)}%`, color: PALETTE[i % PALETTE.length] } }))
524  }
525  const tools = Object.entries(st.tools).sort((a, b) => b[1].tokens - a[1].tokens).slice(0, 6)
526  if (tools.length) {
527    out.push({ head: 'Tools · tokens of the turns that used them' })
528    for (const [name, t] of tools) out.push({ bar: { label: name, share: st.total ? t.tokens / st.total : 0, value: `${t.calls}× · ${fmt(t.tokens)}`, color: '#6C8EBF' } })
529  }
530  if (Object.keys(st.models).length) {
531    out.push({ head: 'Models' })
532    top(st.models).forEach(([name, n], i) => out.push({ bar: { label: name, share: st.total ? n / st.total : 0, value: fmt(n), color: PALETTE[(i + 3) % PALETTE.length] } }))
533  }
534  if (st.recent.length) {
535    out.push({ head: 'Cache and cost' })
536    const last = st.recent[st.recent.length - 1]!
537    out.push({ spark: { label: 'cache hit', values: st.recent.map(t => t.hit), value: `${last.hit}%`, color: '#8AB17D' } })
538    if (st.recent.some(t => t.cost > 0)) {
539      out.push({ spark: { label: 'cost / turn', values: st.recent.map(t => t.cost), value: `last $${last.cost.toFixed(2)}`, color: '#E76F51' } })
540    }
541  }
542  return out
543}
544
545export const paneFigures = (m: Meal, life: number, l: Left, eating: boolean, st: Stats = NO_STATS): Figures => ({
546  blocks: [
547    { text: eaten(m, ' this session'), bold: true },
548    { text: `${fmt(life)} lifetime · ${paneStatus(m, l, eating)}`, dim: true },
549    ...l.limits.map(w => {
550      const t = until(w.resetsAt, l.at ?? 0)
551      const name = w.kind === 'five_hour' ? 'Session limit' : w.kind === 'seven_day' ? 'Weekly' : w.kind
552      return { text: `${name}: ${w.percentLeft}% left${t ? ` · resets in ${t}` : ''}` }
553    }),
554    ...(l.context === undefined ? [] : [{ text: `Context: ${fmt(l.context)} left` }]),
555    { text: `${l.cost === undefined ? '' : `$${l.cost.toFixed(2)} · `}cache hit ${cacheHit(m)}%`, dim: true },
556    ...KINDS.map(k => ({ text: `${k.label}: ${fmt(m[k.key])}`, dot: k.color })),
557    ...statsFigures(st, l),
558  ],
559})
560
561const LABEL = 16
562const BAR = 14
563export const spark = (values: number[]) => {
564  const max = Math.max(...values, 0)
565  return values.map(v => (max ? '▁▂▃▄▅▆▇█'[Math.min(7, Math.round((v / max) * 7))] : '▁')).join('')
566}
567
568const TRACK = '#D9D2C7'
569const drawFigures = (
570  Box: ElementConstructor<BoxProps>,
571  Text: ElementConstructor<TextProps>,
572  Markdown: ElementConstructor<MarkdownProps>,
573  f: Figures,
574  cells = false,
575) => (
576  <Box flexDirection="column">
577    {f.blocks.map(b =>
578      'md' in b ? <Markdown text={b.md} />
579      : 'head' in b ? <Box marginTop={1}><Text bold>{b.head}</Text></Box>
580      : 'bar' in b ? (
581        <Box flexDirection="row" gap={1}>
582          <Box width={LABEL}><Text wrap="truncate-end">{b.bar.label}</Text></Box>
583          {cells ? (
584            <Text>
585              <Text color={b.bar.color}>{'█'.repeat(Math.round(Math.min(1, b.bar.share) * BAR))}</Text>
586              <Text dimColor>{'░'.repeat(BAR - Math.round(Math.min(1, b.bar.share) * BAR))}</Text>
587            </Text>
588          ) : (
589            <Box width={BAR} height={1} backgroundColor={TRACK}>
590              <Box width={`${Math.round(Math.min(1, b.bar.share) * 100)}%`} height={1} backgroundColor={b.bar.color} />
591            </Box>
592          )}
593          <Text dimColor>{b.bar.value}</Text>
594        </Box>
595      )
596      : 'spark' in b ? (
597        <Box flexDirection="row" gap={1}>
598          <Box width={LABEL}><Text wrap="truncate-end">{b.spark.label}</Text></Box>
599          <Box width={BAR}><Text color={b.spark.color ?? '#E9A96B'}>{spark(b.spark.values)}</Text></Box>
600          <Text dimColor>{b.spark.value}</Text>
601        </Box>
602      ) : (
603        <Text bold={b.bold} dimColor={b.dim} color={b.color}>
604          {b.dot && <Text color={b.dot}>● </Text>}
605          {b.text}
606        </Text>
607      ))}
608  </Box>
609)
610
611const hamsterCells = (Text: ElementConstructor<TextProps>, m: Meal, eating: boolean, f: number, md: Mood) =>
612  halfBlocks(hamsterPixels(m, eating, f, md)).map((row, y) => (
613    <Text>
614      {row.map(c => <Text color={c.fg} backgroundColor={c.bg}>{c.ch}</Text>)}
615      {y === 3 && <Text color="#F2C14E">{md === 'running' ? '' : eating && md !== 'empty' ? '  ∘ • · ∘ •'.slice(f % 4) : '  z Z z'}</Text>}
616    </Text>
617  ))
618
619const countTool = (calling: Map<string, Record<string, number>>, loop: string, tool: string) => {
620  const counts = calling.get(loop) ?? {}
621  calling.set(loop, { ...counts, [tool]: (counts[tool] ?? 0) + 1 })
622}
623
624export const register: Register = on => {
625  let chew: Timer | undefined
626  const calling = new Map<string, Record<string, number>>()
627
628  on('tool.call', ($, e, next) => {
629    countTool(calling, e.agentId ?? 'main', e.tool)
630    return next(e)
631  })
632
633  on('session.start', async ($, e, next) => {
634    await $.command.register({ name: 'hamster', description: 'Open the Token Hamster pane' })
635    const stored = await $.store.get(STORE_KEY)
636    await update($, lifetime, () => (typeof stored === 'number' && Number.isFinite(stored) ? stored : 0))
637    await refreshLeft($)
638    await syncCage($)
639    return next(e)
640  })
641
642  on('command.run', { command: 'hamster' }, async $ => {
643    await $.ui.open({ id: PANE, title: TITLE })
644    return { text: 'Token Hamster pane opened.' }
645  })
646
647  on('turn.start', async ($, e, next) => {
648    await update($, isEating, () => true)
649    await syncCage($)
650    chew?.cancel()
651    chew = $.clock.every(300, () => void update($, frame, n => n + 1))
652    return next(e)
653  })
654
655  on('turn.complete', async ($, e, next) => {
656    if (e.usage) await feed($, e.usage)
657    if (!e.agentId) {
658      chew?.cancel()
659      chew = undefined
660      await update($, isEating, () => false)
661      await refreshLeft($)
662      await syncCage($)
663    }
664    await showStatus($)
665    const loop = e.agentId ?? 'main'
666    const tools = calling.get(loop) ?? {}
667    calling.delete(loop)
668    await recordTurn($, e.agentId, e.usage, tools)
669    return next(e)
670  })
671
672  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
673    if (e.props.hasSurvey) return next(e)
674    const details = () => void $.ui.open({ id: PANE, title: TITLE })
675
676    if (e.surface === 'terminal') {
677      const m = await read($, session)
678      const eating = await read($, isEating)
679      const l = await read($, left)
680      const md = mood(l)
681      const { Box, Text, Button } = $.ui.resolve(e)
682      const f = eating ? await read($, frame) : 0
683      return (
684        <Box flexDirection="row" gap={2}>
685          <Box flexDirection="column">
686            {hamsterCells(Text, m, eating, f, md)}
687          </Box>
688          <Box flexDirection="column">
689            <Text bold>{eaten(m)}</Text>
690            <Text dimColor>{leftText(l)}</Text>
691            {md !== 'fine' && <Text bold color={md === 'worried' ? 'yellow' : 'red'}>{moodText(l, l.at ?? 0)}</Text>}
692            <Button key="details" label="Details" hotkey="d" variant="secondary" onPress={details} />
693          </Box>
694        </Box>
695      )
696    }
697
698    const c = await read($, cage)
699    const { Box, Svg, Button, Markdown, Text } = $.ui.resolve(e)
700    const figures = drawFigures(Box, Text, Markdown, bandFigures(await read($, session), await read($, left)))
701    const w = e.props.bodyColumns * 8
702    const scale = Math.min(w / 1200, 140 / 150)
703    const figuresLeft = Math.round(((w - 1200 * scale) / 2 + 285 * scale) / 8)
704    return (
705      <Box position="relative" width="100%" flexGrow={1} borderStyle="round" borderColor="#E9A96B" paddingX={1}>
706        <Box width="100%" position="relative">
707          <Svg
708            source={sceneSvg(c.m, c.eating, c.l, false)}
709            alt={`A hamster in its cage, ${c.eating ? 'eating' : 'napping'}`}
710            width={w}
711            height={140}
712            isInteractive
713          />
714          <Box key="figures" position="absolute" top={0} left={figuresLeft}>
715            {figures}
716          </Box>
717        </Box>
718        <Box position="absolute" top={0} right={2} flexDirection="row" gap={1}>
719          <Button key="details" label=" Details " hotkey="d" variant="secondary" onPress={details} />
720        </Box>
721      </Box>
722    )
723  })
724
725  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
726    if (e.surface === 'terminal') {
727      const m = await read($, session)
728      const life = await read($, lifetime)
729      const l = await read($, left)
730      const eating = await read($, isEating)
731      const { Box, Text } = $.ui.resolve(e)
732      const f = eating ? await read($, frame) : 0
733      return (
734        <Box flexDirection="column">
735          {hamsterCells(Text, m, eating, f, mood(l))}
736          {drawFigures(Box, Text, $.ui.resolve(e).Markdown, paneFigures(m, life, l, eating, await read($, stats)), true)}
737        </Box>
738      )
739    }
740
741    const c = await read($, cage)
742    const md = mood(c.l)
743    const { Box, Svg, Markdown, Text } = $.ui.resolve(e)
744    const figures = drawFigures(Box, Text, Markdown, paneFigures(await read($, session), await read($, lifetime), await read($, left), await read($, isEating), await read($, stats)))
745    return (
746      <Box flexDirection="column" alignItems="center">
747        <Svg
748          source={md === 'running' ? paneWheelSvg() : md === 'empty' ? paneHouseSvg() : hamsterSvg(c.m, c.eating, md)}
749          alt={`A hamster, ${md === 'running' ? 'running in its wheel' : md === 'empty' ? 'asleep in its house' : c.eating ? 'eating' : 'napping'}`}
750          width={320}
751          height={267}
752          isInteractive
753        />
754        {figures}
755      </Box>
756    )
757  })
758}
759
types/index.d.ts 52 lines
1export type Meal = {
2  input: number
3  output: number
4  cacheRead: number
5  cacheWrite: number
6}
7
8export type Left = {
9  context?: number
10  window?: number
11  cost?: number
12  at?: number
13  limits: { kind: string; percentLeft: number; resetsAt?: string }[]
14}
15
16export type FigureBlock =
17  | { md: string }
18  | { text: string; color?: string; bold?: boolean; dim?: boolean; dot?: string }
19  | { head: string }
20  | { bar: { label: string; share: number; value: string; color?: string } }
21  | { spark: { label: string; values: number[]; value: string; color?: string } }
22
23export type Stats = {
24  turns: number
25  biggest: number
26  total: number
27  agents: Record<string, number>
28  models: Record<string, number>
29  tools: Record<string, { calls: number; tokens: number }>
30  recent: { tokens: number; hit: number; cost: number }[]
31  lastCost: number
32  pace?: { at: number; pct: number }
33  context: { name: string; tokens: number; color: string }[]
34}
35export type Figures = { blocks: FigureBlock[] }
36
37export type Cage = { m: Meal; l: Left; eating: boolean; key: string }
38
39declare module 'claude-code' {
40  interface PluginState {
41    'token-hamster': {
42      session: Meal
43      lifetime: number
44      isEating: boolean
45      frame: number
46      left: Left
47      cage: Cage
48      stats: Stats
49    }
50  }
51}
52