SLOPSHOPPER

token-meter

This session's token use, Claude vs local LLMs: status line, live spinner count, and a /tokens pane with a Pac-Man context-window maze. Plus a Haiku-first…

newpanespinnercommandstatustimer
v1.0.0MITupdated 2026-10-05an80sPWNstar/claude-mods/token-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-meter
│ ┃ Tokens · this session ✕ › fix the failing auth test and add an audit log call │ ┃ SCORE 0 │ ┃ C • • • • • • • • • • • • • • • • • • • ⏺ Read(src/auth.ts) │ ┃ • • • • • • • • • • • • • • • • • • • • ⎿ Read 6 lines │ ┃ • • • • • • • • • • • • • • • • • • • • ⏺ Update(src/auth.ts) │ ┃ • • • • • • • • • • • • • • • • • • • • ⎿ 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 │ ┃ 1 dot = 5k context · 0 of 1M │ ┃ › /tokens │ ┃ Claude req in write ⎿ token-meter: Opened the tokens pane. │ ┃ read out │ ┃ (none yet) │ ┃ │ ┃ Local jobs total │ ┃ (none yet) │ ┃ │ ┃ Total Claude 0 · Local 0 │ ✻ Thinking… · Claude +0 this turn… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ token-meter: Claude 0 (0 cached) · Local 0

Draws

Pane · Tokens · this session
SCORE 0 C • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • • 1 dot = 5k context · 0 of 1M Claude req in write read out (none yet) Local jobs total (none yet) Total Claude 0 · Local 0
README

claude-mods

A Claude Code plugin marketplace for Claude Mods. Mods are built from function hooks and are GA in Claude Code v2.1.287 (on by default). Each mod is a folder: token-meter and matrix-rain. More mods will be added as folders.

Install

Run these commands in Claude Code:

/plugin marketplace add an80sPWNstar/claude-mods
/plugin install token-meter@claude-mods
/plugin install matrix-rain@claude-mods

Install either one or both.

Requires Claude Code 2.1.287 or newer. Built and tested in the terminal CLI (fullscreen layout, pane docked beside the transcript).

token-meter

Shows how many tokens THIS session has used, Claude and local LLMs side by side, as plain numbers (no percentages, no account-wide usage).

The /tokens pane: SCORE, the Pac-Man context maze, and the per-model and per-box tables

  1. Status line: Claude 22.51M (22.19M cached) · Local 261.7k. Claude = input + output + cache read + cache write for every model step in the session. The number in parentheses is the cache-read part.
  2. Spinner: while Claude works, the spinner line ends with … · Claude +18.4k this turn (tokens since your last message).
  3. /tokens pane:
  4. SCORE 315,335: fresh tokens this session (input + output + cache write; cache reads not counted).
  5. A Pac-Man maze: 200 peach dots in 10 rows of 20, one dot per 5k tokens of a 1M context window. Pac-Man's position is how full the context is right now (size of the latest main-thread request). He snakes left to right, then right to left, eats his way forward as the context grows, chomps while fresh tokens are flowing, and the board refills when the conversation is compacted. Below it: 1 dot = 5k context · 174.8k of 1M.
  6. A table per Claude model (requests, input, cache write, cache read, output) and per local box (jobs, total), then a Total line Claude 22.51M · Local 261.7k. Below 54 columns of pane width the table switches to narrower columns.

Local models

token-meter counts local LLM usage by reading the job records of lanllm (https://github.com/an80sPWNstar/lanllm), a Claude Code plugin by the same author that lets Claude delegate work to local LLM servers (llama.cpp boxes on your LAN). token-meter does not talk to local models itself. It reads ~/.lanllm/jobs/<session-id>/*.json every 20 seconds and adds up prompt + completion tokens per box. lanllm files each job under the Claude Code session id (it reads the CLAUDE_CODE_SESSION_ID variable), so only this session's local work is counted. Without lanllm, the Local numbers stay at 0 and everything else works.

Subagent guard

Opinionated: when Claude launches a subagent:

  • no model given: it is rewritten to Haiku;
  • a model other than Haiku, or a fork: the launch is refused once with the reason "routing rule is Haiku first unless Haiku already failed at this task"; issuing the identical call again lets it through;
  • Haiku and agent-team teammates pass untouched.

If you do not want this, the guard is the agent.spawn hook in token-meter/hooks/register.tsx.

Tuning

Constants at the top of token-meter/hooks/tally.ts: CTX_WINDOW (1000000), CTX_DOT (5000 tokens per dot), CTX_DOTS (200), ROW (20 dots per row), ROWS (10). They are separate literals, so change them together (CTX_DOTS = CTX_WINDOW / CTX_DOT, ROWS = CTX_DOTS / ROW), update the 5k in ctxLabel, and update tests/ctx.test.ts. Colors are in toneProps in token-meter/hooks/register.tsx: amber #e8a33d (Claude), green #6bcb77 (local), peach #ffb8ae (dots), bold yellow #ffff00 (Pac-Man).

What it touches

Reads: the env vars USERPROFILE / HOME (to find ~/.lanllm), files under ~/.lanllm/jobs/<session-id>/. Writes: only its own plugin store (session totals and the last context size, so a reload keeps the counts). No network, no processes.

matrix-rain

Matrix digital rain in green katakana-style glyphs. Pure eye candy, no effect on Claude.

  • /matrix opens a docked rain pane (asks for 48 columns); /matrix again closes it.
  • Band: while Claude is working, a few rows of rain fall above the prompt and stop when the turn ends. On by default; /matrix band toggles it, and the choice is remembered across sessions.

What it touches: only its own plugin store (the band on/off setting). No files, no network, no processes.

Development

The tests cover the pure functions (token-meter/hooks/tally.ts, matrix-rain/hooks/rain.ts). From the repo root:

claude plugin validate token-meter
claude plugin test token-meter
claude plugin validate matrix-rain
claude plugin test matrix-rain

Type-check with tsc -p token-meter (or matrix-rain) after Claude Code has loaded the mod once (it writes .claude-plugin/types/, which is git-ignored).

License

MIT

Source 2 files
hooks/register.tsx 188 lines
1import type { Register, EngineInterface } from 'claude-code'
2import {
3  emptyTotals,
4  addUsage,
5  claudeTotal,
6  parseJob,
7  localTotal,
8  statusLine,
9  spawnDecision,
10  paneLines,
11  spinnerSuffix,
12  freshTotal,
13  dotsOwed,
14  stepEaten,
15  contextSize,
16  ctxTarget,
17  stepToward,
18  ctxMaze,
19  ctxLabel,
20  scoreLine,
21} from './tally'
22import type { Totals, LocalJob, Run, Tone } from './tally'
23
24let totals: Totals = emptyTotals()
25let jobs: LocalJob[] = []
26let confirmed = new Set<string>()
27let sid = ''
28let jobsDir = ''
29const PANE = 'token-meter'
30let mainTurnId = ''
31let turnBase = 0
32let eaten = 0
33let ctx = 0
34let shown = 0
35
36async function save($: EngineInterface) {
37  await $.store.set('totals:' + sid, totals)
38}
39
40async function scanLocal($: EngineInterface) {
41  if (jobsDir === '' || !(await $.fs.exists(jobsDir))) return
42  try {
43    const entries = await $.fs.list(jobsDir)
44    const next: LocalJob[] = []
45    for (const e of entries) {
46      if (e.kind === 'file' && e.name.endsWith('.json')) {
47        const text = await $.fs.read(jobsDir + '/' + e.name)
48        const j = parseJob(text)
49        if (j !== null) next.push(j)
50      }
51    }
52    jobs = next
53  } catch {
54    // leave jobs unchanged
55  }
56}
57
58function show($: EngineInterface) {
59  $.ui.status(statusLine(claudeTotal(totals), localTotal(jobs)))
60  $.ui.invalidate('ui.render')
61}
62
63function toneProps(tone: Tone) {
64  if (tone === 'head') return { bold: true }
65  if (tone === 'dim') return { dimColor: true }
66  if (tone === 'amber') return { color: '#e8a33d' }
67  if (tone === 'green') return { color: '#6bcb77' }
68  if (tone === 'pellet') return { color: '#ffb8ae' }
69  if (tone === 'pac') return { color: '#ffff00', bold: true }
70  return {}
71}
72
73export const register: Register = on => {
74  on('session.start', async ($, e, next) => {
75    sid = await $.session.id()
76    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
77    jobsDir = home ? home.replace(/\\/g, '/') + '/.lanllm/jobs/' + sid : ''
78    const saved = await $.store.get('totals:' + sid)
79    if (
80      typeof saved === 'object' &&
81      saved !== null &&
82      typeof (saved as { byModel?: unknown }).byModel === 'object' &&
83      (saved as { byModel?: unknown }).byModel !== null
84    ) {
85      totals = saved as Totals
86    }
87    eaten = dotsOwed(freshTotal(totals))
88    const savedCtx = await $.store.get('ctx:' + sid)
89    if (typeof savedCtx === 'number') ctx = savedCtx
90    shown = ctxTarget(ctx)
91    await $.command.register({ name: 'tokens', description: 'Token use this session: Claude per model, local per box' })
92    await scanLocal($)
93    show($)
94    $.clock.every(20000, async () => {
95      await scanLocal($)
96      show($)
97    })
98    $.clock.every(120, () => {
99      const owed = dotsOwed(freshTotal(totals))
100      const target = ctxTarget(ctx)
101      let changed = false
102      if (eaten < owed) {
103        eaten = stepEaten(eaten, owed)
104        changed = true
105      }
106      if (shown !== target) {
107        shown = stepToward(shown, target)
108        changed = true
109      }
110      if (changed) $.ui.invalidate('ui.render')
111    })
112    return next(e)
113  })
114
115  on('turn.step', async function* ($, e, next) {
116    if (e.agentId === undefined && e.turnId !== mainTurnId) {
117      mainTurnId = e.turnId
118      turnBase = claudeTotal(totals).all
119    }
120    const r = yield* next(e)
121    if (r && r.usage) {
122      totals = addUsage(totals, r.usage)
123      if (e.agentId === undefined) {
124        ctx = contextSize(r.usage)
125        await $.store.set('ctx:' + sid, ctx)
126      }
127      show($)
128      await save($)
129    }
130    return r
131  })
132
133  on('agent.spawn', async ($, e, next) => {
134    const info = {
135      fork: e.fork,
136      model: e.model,
137      subagentType: e.subagentType,
138      description: e.description,
139      isTeammate: e.isTeammate,
140    }
141    const d = spawnDecision(info, confirmed)
142    if (d.action === 'pass') return next(e)
143    if (d.action === 'rewrite') return next({ ...e, model: 'haiku' })
144    confirmed.add(d.key)
145    return { deny: d.reason }
146  })
147
148  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
149    const n = claudeTotal(totals).all - turnBase
150    return next({ ...e, props: { ...e.props, suffix: spinnerSuffix(n) } })
151  })
152
153  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
154    const { Box, Text } = $.ui.resolve(e)
155    const fresh = freshTotal(totals)
156    const cols = e.props.bodyColumns
157    const lines: Run[][] = [
158      scoreLine(fresh),
159      ...ctxMaze(shown, eaten, dotsOwed(fresh)),
160      [{ text: ctxLabel(ctx), tone: 'dim' }],
161      [],
162      ...paneLines(totals, jobs, cols < 54),
163    ]
164    return (
165      <Box flexDirection="column">
166        {lines.map(line =>
167          line.length === 0 ? (
168            <Text> </Text>
169          ) : (
170            <Text wrap="wrap">
171              {line.map((r: Run) => (
172                <Text {...toneProps(r.tone)}>{r.text}</Text>
173              ))}
174            </Text>
175          ),
176        )}
177      </Box>
178    )
179  })
180
181  on('command.run', { command: 'tokens' }, async $ => {
182    await scanLocal($)
183    show($)
184    await $.ui.open({ id: PANE, title: 'Tokens · this session', columns: 56 })
185    return { text: 'Opened the tokens pane.' }
186  })
187}
188
hooks/tally.ts 303 lines
1export type Usage = {
2  model: string
3  input_tokens: number
4  output_tokens: number
5  cache_read_input_tokens: number
6  cache_creation_input_tokens: number
7}
8export type ModelRow = { requests: number; input: number; output: number; cacheRead: number; cacheWrite: number }
9export type Totals = { byModel: Record<string, ModelRow> }
10export type LocalJob = { id: string; box: string; model: string; prompt: number; completion: number; done: boolean }
11export type SpawnInfo = { fork: boolean; model?: string; subagentType: string; description: string; isTeammate?: true }
12export type SpawnDecision =
13  | { action: 'pass' }
14  | { action: 'rewrite'; model: 'haiku' }
15  | { action: 'deny'; key: string; reason: string }
16
17function num(v: unknown): number {
18  return typeof v === 'number' && Number.isFinite(v) ? v : 0
19}
20
21export const CTX_WINDOW = 1000000 // context window in tokens
22export const CTX_DOT = 5000       // context tokens per dot
23export const CTX_DOTS = 200       // CTX_WINDOW / CTX_DOT
24export const ROW = 20   // dots per maze row
25export const ROWS = 10  // CTX_DOTS / ROW
26
27// The context one step's request carried: input + cache read + cache write.
28export function contextSize(u: Usage): number {
29  return num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens)
30}
31
32// Dots eaten for a context size: Math.min(CTX_DOTS, Math.floor(ctx / CTX_DOT)).
33export function ctxTarget(ctx: number): number {
34  return Math.min(CTX_DOTS, Math.floor(ctx / CTX_DOT))
35}
36
37// One animation step of the eater's position: shown < target ? shown + 1 : target
38// (moving up walks one dot at a time; a drop, e.g. after compaction, jumps straight there).
39export function stepToward(shown: number, target: number): number {
40  return shown < target ? shown + 1 : target
41}
42
43// The maze: ROWS lines of ROW cells, two characters per cell, walked as a snake
44// (even rows left to right, odd rows right to left).
45// s = Math.max(0, Math.min(CTX_DOTS, shown)); p = Math.min(s, CTX_DOTS - 1)  (the eater's cell)
46// moving = owed > eaten
47// For row r (0..ROWS-1) and column j (0..ROW-1): idx = r * ROW + (r % 2 === 0 ? j : ROW - 1 - j)
48//   idx === p -> { text: ' ' + mouth, tone: 'pac' }
49//                mouth = moving && eaten % 2 === 0 ? 'O' : (r % 2 === 0 ? 'C' : 'Ɔ')   ('Ɔ' is U+0186)
50//   idx < s   -> { text: '  ', tone: 'plain' }
51//   otherwise -> { text: ' •', tone: 'pellet' }   ('•' is U+2022)
52// Each line is its cells left to right, with ADJACENT RUNS OF THE SAME TONE MERGED into one run (texts concatenated).
53export function ctxMaze(shown: number, eaten: number, owed: number): Run[][] {
54  const s = Math.max(0, Math.min(CTX_DOTS, shown))
55  const p = Math.min(s, CTX_DOTS - 1)
56  const moving = owed > eaten
57  const rows: Run[][] = []
58  for (let r = 0; r < ROWS; r++) {
59    const cells: Run[] = []
60    for (let j = 0; j < ROW; j++) {
61      const idx = r * ROW + (r % 2 === 0 ? j : ROW - 1 - j)
62      let run: Run
63      if (idx === p) {
64        const mouth = moving && eaten % 2 === 0 ? 'O' : (r % 2 === 0 ? 'C' : '\u0186')
65        run = { text: ' ' + mouth, tone: 'pac' }
66      } else if (idx < s) {
67        run = { text: '  ', tone: 'plain' }
68      } else {
69        run = { text: ' \u2022', tone: 'pellet' }
70      }
71      const last = cells[cells.length - 1]
72      if (last !== undefined && last.tone === run.tone) last.text += run.text
73      else cells.push(run)
74    }
75    rows.push(cells)
76  }
77  return rows
78}
79
80// Label under the maze: '1 dot = 5k context · ' + formatCount(ctx) + ' of 1M'  (middle dot U+00B7)
81export function ctxLabel(ctx: number): string {
82  return '1 dot = 5k context \u00B7 ' + formatCount(ctx) + ' of 1M'
83}
84
85export function emptyTotals(): Totals {
86  return { byModel: {} }
87}
88
89export function addUsage(t: Totals, u: Usage): Totals {
90  const byModel: Record<string, ModelRow> = {}
91  for (const [m, r] of Object.entries(t.byModel)) byModel[m] = { ...r }
92  const model = typeof u.model === 'string' ? u.model : '?'
93  const row = byModel[model] ?? { requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
94  row.requests += 1
95  row.input += num(u.input_tokens)
96  row.output += num(u.output_tokens)
97  row.cacheRead += num(u.cache_read_input_tokens)
98  row.cacheWrite += num(u.cache_creation_input_tokens)
99  byModel[model] = row
100  return { byModel }
101}
102
103export function claudeTotal(t: Totals): { all: number; cached: number } {
104  let all = 0
105  let cached = 0
106  for (const r of Object.values(t.byModel)) {
107    all += r.input + r.output + r.cacheRead + r.cacheWrite
108    cached += r.cacheRead
109  }
110  return { all, cached }
111}
112
113export function parseJob(text: string): LocalJob | null {
114  let obj: unknown
115  try {
116    obj = JSON.parse(text)
117  } catch {
118    return null
119  }
120  if (typeof obj !== 'object' || obj === null) return null
121  const o = obj as Record<string, any>
122  const result = o.result
123  if (typeof result !== 'object' || result === null || typeof result.usage !== 'object' || result.usage === null) return null
124  const usage = result.usage as Record<string, any>
125  const server = (typeof o.server === 'object' && o.server !== null ? o.server : {}) as Record<string, any>
126  let model = '?'
127  if (typeof server.model === 'string' && server.model.length > 0) {
128    const base = server.model.split(/[\\/]/).pop() ?? ''
129    model = base.endsWith('.gguf') ? base.slice(0, -'.gguf'.length) : base
130    if (model.length === 0) model = '?'
131  }
132  return {
133    id: typeof o.id === 'string' ? o.id : String(o.id ?? '?'),
134    box: typeof server.name === 'string' && server.name.length > 0 ? server.name : '?',
135    model,
136    prompt: num(usage.promptTokens),
137    completion: num(usage.completionTokens),
138    done: o.state === 'done',
139  }
140}
141
142export function localTotal(jobs: LocalJob[]): { all: number; byBox: Record<string, number> } {
143  let all = 0
144  const byBox: Record<string, number> = {}
145  for (const j of jobs) {
146    const s = j.prompt + j.completion
147    all += s
148    byBox[j.box] = (byBox[j.box] ?? 0) + s
149  }
150  return { all, byBox }
151}
152
153export function formatCount(n: number): string {
154  if (n < 1000) return String(n)
155  if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k'
156  return (n / 1_000_000).toFixed(2) + 'M'
157}
158
159export function statusLine(claude: { all: number; cached: number }, local: { all: number }): string {
160  return `Claude ${formatCount(claude.all)} (${formatCount(claude.cached)} cached) · Local ${formatCount(local.all)}`
161}
162
163export function spawnDecision(s: SpawnInfo, confirmed: ReadonlySet<string>): SpawnDecision {
164  const key = `${s.subagentType}|${s.model ?? 'inherit'}|${s.fork ? 'fork' : 'agent'}|${s.description}`
165  if (s.isTeammate === true) return { action: 'pass' }
166  if (confirmed.has(key)) return { action: 'pass' }
167  if (s.fork === true) {
168    return {
169      action: 'deny',
170      key,
171      reason:
172        'token-meter: routing rule is Haiku first unless Haiku already failed at this task; forks bypass that rule. re-issue the identical call to confirm this fork.',
173    }
174  }
175  if (s.model === undefined) return { action: 'rewrite', model: 'haiku' }
176  if (s.model === 'haiku' || s.model.includes('haiku')) return { action: 'pass' }
177  return {
178    action: 'deny',
179    key,
180    reason:
181      `token-meter: routing rule is Haiku first unless Haiku already failed at this task; ` +
182      `${s.model} is not Haiku. re-issue the identical call to confirm it.`,
183  }
184}
185
186export type Tone = 'head' | 'name' | 'dim' | 'amber' | 'green' | 'plain' | 'pellet' | 'pac'
187export type Run = { text: string; tone: Tone }
188
189// Drop a leading "claude-" and a trailing "-YYYYMMDD" (8 digits).
190// "claude-opus-5-5" -> "opus-5-5"; "claude-haiku-4-5-20251001" -> "haiku-4-5"; "qwen" -> "qwen".
191export function shortModel(id: string): string {
192  let s = id
193  if (s.startsWith('claude-')) s = s.slice('claude-'.length)
194  s = s.replace(/-\d{8}$/, '')
195  return s
196}
197
198// Lines for the pane. Each line is a list of runs; an empty array is a blank line.
199// compact narrows the table: NAME 10 / COL 7 / names sliced to 10, instead of 14 / 8 / 13.
200export function paneLines(t: Totals, jobs: LocalJob[], compact = false): Run[][] {
201  const NAME = compact ? 10 : 14
202  const COL = compact ? 7 : 8
203  const SLICE = compact ? 10 : 13
204  const lines: Run[][] = []
205  lines.push([
206    { text: 'Claude'.padEnd(NAME) + ['req', 'in', 'write', 'read', 'out'].map(s => s.padStart(COL)).join(''), tone: 'head' },
207  ])
208  const models = Object.entries(t.byModel)
209  if (models.length === 0) {
210    lines.push([{ text: '  (none yet)', tone: 'dim' }])
211  } else {
212    const sorted = models
213      .map(([m, r]) => ({ m, r, total: r.input + r.output + r.cacheRead + r.cacheWrite }))
214      .sort((a, b) => b.total - a.total)
215    for (const { m, r } of sorted) {
216      lines.push([
217        { text: shortModel(m).slice(0, SLICE).padEnd(NAME), tone: 'name' },
218        {
219          text:
220            String(r.requests).padStart(COL) +
221            formatCount(r.input).padStart(COL) +
222            formatCount(r.cacheWrite).padStart(COL),
223          tone: 'plain',
224        },
225        { text: formatCount(r.cacheRead).padStart(COL), tone: 'dim' },
226        { text: formatCount(r.output).padStart(COL), tone: 'plain' },
227      ])
228    }
229  }
230  lines.push([])
231  lines.push([{ text: 'Local'.padEnd(NAME) + 'jobs'.padStart(COL) + 'total'.padStart(COL), tone: 'head' }])
232  const boxes: Record<string, { count: number; sum: number }> = {}
233  for (const j of jobs) {
234    const b = boxes[j.box] ?? { count: 0, sum: 0 }
235    b.count += 1
236    b.sum += j.prompt + j.completion
237    boxes[j.box] = b
238  }
239  const boxEntries = Object.entries(boxes)
240  if (boxEntries.length === 0) {
241    lines.push([{ text: '  (none yet)', tone: 'dim' }])
242  } else {
243    const sorted = boxEntries.sort((a, b) => b[1].sum - a[1].sum)
244    for (const [box, b] of sorted) {
245      lines.push([
246        { text: box.slice(0, SLICE).padEnd(NAME), tone: 'name' },
247        { text: String(b.count).padStart(COL) + formatCount(b.sum).padStart(COL), tone: 'plain' },
248      ])
249    }
250  }
251  lines.push([])
252  const claude = claudeTotal(t)
253  const local = localTotal(jobs)
254  lines.push([
255    { text: 'Total   ', tone: 'plain' },
256    { text: 'Claude ' + formatCount(claude.all), tone: 'amber' },
257    { text: ' \u00B7 ', tone: 'plain' },
258    { text: 'Local ' + formatCount(local.all), tone: 'green' },
259  ])
260  return lines
261}
262
263// Spinner suffix: '… · Claude +' + formatCount(n) + ' this turn'
264export function spinnerSuffix(n: number): string {
265  return '\u2026 \u00B7 Claude +' + formatCount(n) + ' this turn'
266}
267
268export const TRACK = 40 // track width in cells
269export const DOT = 1000 // fresh tokens per dot
270
271// Fresh tokens: input + output + cacheWrite over all models (cache reads NOT counted).
272// Equals claudeTotal(t).all - claudeTotal(t).cached.
273export function freshTotal(t: Totals): number {
274  const c = claudeTotal(t)
275  return c.all - c.cached
276}
277
278// Dots earned so far: Math.floor(fresh / DOT).
279export function dotsOwed(fresh: number): number {
280  return Math.floor(fresh / DOT)
281}
282
283// One tick of the eater. If eaten >= owed, return eaten unchanged.
284// Otherwise skip any backlog beyond TRACK, then eat one:
285//   return Math.max(eaten, owed - TRACK) + 1
286export function stepEaten(eaten: number, owed: number): number {
287  if (eaten >= owed) return eaten
288  return Math.max(eaten, owed - TRACK) + 1
289}
290
291// Arcade score line. The number is fresh with commas every three digits,
292// built WITHOUT toLocaleString/Intl (the runtime may lack them):
293//   String(fresh).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
294// Returns exactly two runs:
295// [{ text: 'SCORE ', tone: 'head' }, { text: <grouped number>, tone: 'amber' }]
296export function scoreLine(fresh: number): Run[] {
297  const grouped = String(fresh).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
298  return [
299    { text: 'SCORE ', tone: 'head' },
300    { text: grouped, tone: 'amber' },
301  ]
302}
303