SLOPSHOPPER

token-printer

Token printer go brrr: Clawd prints tokens while Claude works, and /brrr posts the meme

newpanebandspinnerrowscommand
v0.1.0MITupdated 2026-10-03BorjaGM1/token-printer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-printer
│ ┃ Token Reserve ✕ › fix the failing auth test and add an audit log call │ ┃ ▐▛███▜▌ ┌──────┐ ✻ ✻ │ ┃ ▝▜█████▛▘ │ ▒▒ ✻ ├═ ✻ ✻ ✻ ⏺ Read(src/auth.ts) │ ┃ ▘▘ ▝▝ └──────┘ ✻ ✻ ✻ ⎿ Read 6 lines │ ┃ 1,480 tokens printed this session · $0.42 ⏺ Update(src/auth.ts) │ ┃ 1 reply · biggest run 1,480 ⎿ Added 2 lines, removed 1 line │ ┃ 1,480 tokens printed all-time ⏺ Bash(bun test) │ ┃ █ ⎿ 3 pass, 1 fail │ ┃ [ Hide band ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ 🖨️ +1,480 tokens printed · brrr │ │ › /brrr │ STOP! The context window is 49% full! │ ▐▛███▜▌ ┌──────┐ ✻ ✻ │ ▝▜█████▛▘ │ ▒▒ ✻ ├═ ✻ ✻ ✻ │ ▘▘ ▝▝ └──────┘ ✻ ✻ ✻ │ haha token printer go brrrr │ 1,480 tokens printed · $0.42 this session │ │ ✻ Token printer go brrrr… ✻ Printer idle · last run +1,480 1,480 printed · $0.42 hide ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ Printer idle · last run +1,480 1,480 printed · $0.42 hide
Pane · Token Reserve
▐▛███▜▌ ┌──────┐ ✻ ✻ ▝▜█████▛▘ │ ▒▒ ✻ ├═ ✻ ✻ ✻ ▘▘ ▝▝ └──────┘ ✻ ✻ ✻ 1,480 tokens printed this session · $0.42 1 reply · biggest run 1,480 1,480 tokens printed all-time █ [ Hide band ]
Command output
STOP! The context window is 49% full! ▐▛███▜▌ ┌──────┐ ✻ ✻ ▝▜█████▛▘ │ ▒▒ ✻ ├═ ✻ ✻ ✻ ▘▘ ▝▝ └──────┘ ✻ ✻ ✻ haha token printer go brrrr 1,480 tokens printed · $0.42 this session
README

token-printer

Money printer go brrr, but it's tokens. A Claude Code mod where Clawd runs a token printer above your prompt box while Claude works.

<img src="media/demo.gif" width="600" alt="Clawd startles awake when a prompt is typed, follows the cursor with his eyes, then prints tokens in shades at Max effort"> <sub>Clawd is drawn by the mod's own code; the app around him is re-created for the demo.</sub>

What it does

  • The printer band. Clawd and his printer live above the prompt box. The printer runs harder with what Claude is doing: warming up, using tools, thinking, writing ("Token printer go BRRRRR"). Tokens fly out of the slot the whole time. After a run the printer steams and Clawd dozes off.
  • He watches you type. Start a prompt and a dozing Clawd jolts awake ("Ooh, a prompt!"). Then his eyes follow your cursor across the box, line by line.
  • Ultra mode. At Max effort he wears shades: over his eyes while he prints, pushed up on his head while he watches you.
  • Receipts. Every reply gets a line underneath: 🖨️ +12,345 tokens printed (+$0.42) · brrr
  • Milestones. Toasts as the session's total grows, from "The printer is warmed up." at 10k up to "Hyperinflation achieved." at a million.
  • /brrr posts the two-panel meme with your session's numbers. /brrr <plea> writes your own top panel.
  • /token-reserve opens a panel with the session's stats, a tokens-per-reply chart, and a switch to hide the band.

On the desktop app Clawd is drawn natively, from a few dozen boxes with no images. On the terminal the band is a line of text.

Install

You need Claude Code with function hooks. It was built on 2.1.286.

git clone https://github.com/BorjaGM1/token-printer ~/token-printer

Then load it:

  • For one terminal session: claude --plugin-dir ~/token-printer
  • For every session, including the desktop app: add the folder to the env of ~/.claude/settings.json:
  { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/token-printer" } }

To load several folders, separate them with : (; on Windows).

If the band doesn't show up, function hooks may be switched off for you. Add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" to the same env.

How it's made

  • hooks/register.tsx has the hooks: the band, the printer's levels, receipts, toasts, both commands, and following what you type.
  • hooks/art.ts holds Clawd, the printer and the tokens as pixel frames, plus the animated SVGs for the panel and the meme.
  • hooks/caret.ts works out where your cursor is by laying out the text the way the prompt box does.
  • tests/printer.test.ts holds the tests. Run them with claude plugin test ..

Cursor tracking was measured on the macOS desktop app. On other systems his eyes may land a little off.

Everything this took to figure out is packed into claude-code-mod-kit, a skill that helps Claude build mods like this one.


Clawd is Claude Code's mascot. This is a fan-made mod, not affiliated with Anthropic.

Source 4 files
hooks/register.tsx 449 lines
1import { atom, read, update } from 'claude-code'
2import type { BoxProps, ElementConstructor, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { PrintLevel } from '../types'
5import { ACROSS, BLOCK_ART, ORANGE, PALETTE, SETTLED_TICKS, TICK_MS, cadence, chart, compact, frame, grouped, isAsleep, isIntro, meme, sparkline, strip } from './art'
6import type { Gaze, Scene } from './art'
7import { caretAt } from './caret'
8
9const printed = atom({ plugin: 'token-printer', key: 'printed' } as const, 0)
10const runs = atom({ plugin: 'token-printer', key: 'runs' } as const, [])
11const isUltra = atom({ plugin: 'token-printer', key: 'isUltra' } as const, false)
12const isPrinting = atom({ plugin: 'token-printer', key: 'isPrinting' } as const, false)
13const isBandHidden = atom({ plugin: 'token-printer', key: 'isBandHidden' } as const, false)
14// The band alone reads it: a write redraws the band and nothing else.
15const redraw = atom({ plugin: 'token-printer', key: 'redraw' } as const, 0)
16const redrawRef = { plugin: 'token-printer', key: 'redraw' } as const
17
18const RESERVE = 'token-reserve'
19
20/** How hard the printer runs for what the turn is doing. */
21const LEVEL = { requesting: 0, 'tool-input': 1, 'tool-use': 1, thinking: 2, responding: 3 } as const
22
23const WORD = [
24  'Token printer warming up',
25  'Token printer go brr',
26  'Token printer go brrrr',
27  'Token printer go BRRRRR',
28] as const
29
30const MILESTONES = [
31  [10_000, 'The printer is warmed up.'],
32  [50_000, 'brrrrrrrrrrrrrrrrrrrrrr'],
33  [100_000, 'The Fed has entered the chat.'],
34  [250_000, 'Number go up.'],
35  [500_000, 'Quantitative easing: ∞'],
36  [1_000_000, 'Hyperinflation achieved.'],
37] as const
38
39const pick = (options: readonly [string, ...string[]]) =>
40  options[Math.floor(Math.random() * options.length)] ?? options[0]
41
42/** What the session has cost so far, in dollars; 0 where the host keeps no ledger. */
43const spent = async ($: EngineInterface) => (await $.session.usage()).cost?.usd ?? 0
44
45const dollars = (usd: number) => (usd >= 0.01 ? `$${usd.toFixed(2)}` : '')
46
47/**
48 * The prompt box's text width, from the band's columns. Measured on the
49 * desktop: a band of 95 columns is 768px wide and one of 69 is 565px; the box
50 * under it is as wide, and its padding and send button take 46px of that.
51 */
52const PX_PER_COLUMN = (768 - 565) / (95 - 69)
53const BAND_PX_AT_ZERO = 768 - 95 * PX_PER_COLUMN
54const TEXT_INSET_PX = 46
55
56
57/** A row of a frame as runs of one letter: [letter, first column, length]. */
58function runsOf(row: string): [string, number, number][] {
59  const out: [string, number, number][] = []
60  for (let x = 0; x < row.length; x += 1) {
61    const letter = row.charAt(x)
62    const last = out[out.length - 1]
63    if (last !== undefined && last[0] === letter) last[2] += 1
64    else out.push([letter, x, 1])
65  }
66  return out
67}
68
69/** A frame as boxes: each line a row of runs, each pixel 1% wide and 5% tall. */
70function pixels(Box: ElementConstructor<BoxProps>, rows: readonly string[]) {
71  return (
72    <Box flexDirection="column" width="100%" height="100%">
73      {rows.map((row, y) => (
74        <Box key={`r${y}`} flexDirection="row" width="100%" height="5%">
75          {runsOf(row).filter(([letter], i, all) => letter !== '.' || i < all.length - 1).map(([letter, from, length]) => {
76            const color = PALETTE[letter]
77            return color === undefined
78              ? <Box key={`c${from}`} width={`${length}%`} height="100%" />
79              : <Box key={`c${from}`} width={`${length}%`} height="100%" backgroundColor={color} />
80          })}
81        </Box>
82      ))}
83    </Box>
84  )
85}
86
87export const register: Register = on => {
88  // What the spinner last said the turn is doing: the band and the pane print at it.
89  let level: PrintLevel = 0
90  // Where the running reply started from, for its receipt.
91  let start = { printed: 0, usd: 0 }
92  // Whether the main loop's last request ran at Max effort, once one has.
93  let isMaxEffort: boolean | undefined
94  // The prompt box holds a draft (Clawd is up and watching it), and whether a
95  // print run ended since the last draft (the printer still steams).
96  let hasDraft = false
97  let hasPrinted = false
98  // Where the caret is, as Clawd's gaze, and the band's width in columns.
99  let gaze: Gaze = { across: 0, isLower: false }
100  let columns = 95
101  // Our own copy of the draft and caret, to measure again when the box resizes.
102  let draft = ''
103  let caret = 0
104  // The band's clock: ticks so far, the scene drawn last and the tick it began
105  // at, whether the band shows, and a redraw asked for by a render hook (which
106  // cannot write state itself). `redraws` is the value written to `redraw`.
107  let clock: Timer | undefined
108  let tick = 0
109  let scene: Scene = 'drowsy'
110  let sceneStart = 0
111  let isBandShown = false
112  let isRedrawDue = false
113  let redraws = 0
114
115  /** Notes whether the box holds a draft; true when that flipped, so the band redraws. */
116  const noteDraft = (isDraft: boolean) => {
117    if (isDraft === hasDraft) {
118      return false
119    }
120
121    hasDraft = isDraft
122    hasPrinted &&= !hasDraft
123    return true
124  }
125
126  /** Notes where the caret is; true when Clawd should look somewhere else. */
127  const noteCaret = (text: string, cursor: number) => {
128    const width = BAND_PX_AT_ZERO + columns * PX_PER_COLUMN - TEXT_INSET_PX
129    const caret = caretAt(text, cursor, width)
130    const next = { across: Math.min(ACROSS - 1, Math.floor((caret.x / width) * ACROSS)), isLower: caret.line > 0 }
131
132    if (next.across === gaze.across && next.isLower === gaze.isLower) {
133      return false
134    }
135
136    gaze = next
137    return true
138  }
139
140  on('session.start', async ($, e, next) => {
141    // The band's clock: redraw it whenever its scene has moved on a frame.
142    clock ??= $.clock.every(TICK_MS, () => {
143      tick += 1
144      const t = tick - sceneStart
145
146      if (isBandShown && (isRedrawDue || t % cadence(scene, t) === 0)) {
147        isRedrawDue = false
148        redraws += 1
149        void $.state.set(redrawRef, redraws)
150      }
151    })
152
153    await $.command.register({
154      name: 'brrr',
155      description: 'Token printer go brrr: post the meme with this session’s numbers',
156      argumentHint: '[what to beg the printer]',
157    })
158    await $.command.register({
159      name: RESERVE,
160      description: 'Open the Token Reserve: what the printer printed, reply by reply',
161    })
162
163    return next(e)
164  })
165
166  on('turn.start', async ($, e, next) => {
167    // The draft was just sent: the box is empty again.
168    hasDraft = false
169    draft = ''
170    caret = 0
171    start = { printed: await read($, printed), usd: await spent($) }
172    await update($, isPrinting, () => true)
173
174    return next(e)
175  })
176
177  // Each request says the effort it runs at: Max is Clawd's ultra mode (shades).
178  // The picker raises no event of its own, so a change shows from the next turn.
179  on('turn.step', async function* ($, e, next) {
180    const isMax = e.effort === 'max'
181
182    if (e.agentId === undefined && isMax !== isMaxEffort) {
183      isMaxEffort = isMax
184      void update($, isUltra, () => isMax)
185    }
186
187    return yield* next(e)
188  })
189
190  on('turn.complete', async ($, e, next) => {
191    const fresh = e.usage?.output_tokens ?? 0
192    const total = fresh > 0 ? await update($, printed, n => n + fresh) : await read($, printed)
193
194    if (fresh > 0) {
195      const hit = MILESTONES.filter(([at]) => total - fresh < at && at <= total).pop()
196
197      if (hit !== undefined) {
198        $.ui.toast(`🖨️ ${compact(hit[0])} tokens printed. ${hit[1]}`)
199      }
200
201      await $.store.set('lifetime', Number((await $.store.get('lifetime')) ?? 0) + fresh)
202    }
203
204    // A subagent's answer is its parent's to read: no receipt on it.
205    if (e.agentId !== undefined) {
206      return next(e)
207    }
208
209    await update($, isPrinting, () => false)
210    const done = await next(e)
211    const run = total - start.printed
212
213    if (run <= 0) {
214      return done
215    }
216
217    await update($, runs, list => [...list, run].slice(-30))
218    const cost = dollars((await spent($)) - start.usd)
219    const shades = (await read($, isUltra)) ? ' 😎' : ''
220
221    return { ...done, text: `🖨️ +${grouped(run)} tokens printed${cost ? ` (+${cost})` : ''} · brrr${shades}` }
222  })
223
224  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
225    const now: PrintLevel = LEVEL[e.props.mode]
226
227    if (now !== level) {
228      level = now
229      isRedrawDue = true
230    }
231
232    if (e.surface === 'terminal') {
233      const shades = (await read($, isUltra)) ? ' 😎' : ''
234
235      return next({ ...e, props: { ...e.props, word: `${WORD[now]}${shades}` } })
236    }
237
238    // The desktop's row stays the engine's: the band above the prompt draws the printer.
239    return next(e)
240  })
241
242  // Two places say the prompt box holds a draft: each edit of it, and the hint
243  // line under it. Either one wakes Clawd.
244  on('prompt.edit', async ($, e, next) => {
245    const box = await next(e)
246
247    if (e.text === '' && draft !== '') {
248      // An edit that comes without the draft (seen once on the desktop): keep
249      // ours, as typing at its end; a Backspace takes the last character off.
250      if (e.inputText !== '') draft += e.inputText
251      else if (e.key?.key === 'backspace') draft = draft.slice(0, -1)
252      caret = draft.length
253    } else {
254      draft = box.text
255      caret = box.cursor
256    }
257
258    const hasFlipped = noteDraft(draft !== '')
259    const hasMoved = noteCaret(draft, caret)
260
261    // Boxes change in place, so the band redraws on the keystroke itself.
262    if (hasFlipped || (hasDraft && hasMoved)) {
263      redraws += 1
264      void $.state.set(redrawRef, redraws)
265    }
266
267    return box
268  })
269
270  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
271    if (noteDraft(e.props.isDraft)) {
272      isRedrawDue = true
273    }
274
275    return next(e)
276  })
277
278  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
279    if (e.props.hasSurvey || (await read($, isBandHidden))) {
280      isBandShown = false
281      return next(e)
282    }
283
284    const [total, list, hasShades, usd] = await Promise.all([read($, printed), read($, runs), read($, isUltra), spent($)])
285    const last = list.at(-1)
286    hasPrinted ||= e.props.isWorking
287
288    // A resized band is a resized prompt box: the draft wraps anew, so look again.
289    const width = Math.max(20, e.props.bodyColumns)
290    if (width !== columns) {
291      columns = width
292      noteCaret(draft, caret)
293    }
294    const upcoming: Scene = e.props.isWorking ? level : hasDraft ? 'ready' : hasPrinted ? 'idle' : 'drowsy'
295
296    // A new scene starts its animation over; a new print level carries on. A
297    // draft startles Clawd only out of sleep: awake, he just turns to watch.
298    if ((typeof upcoming === 'number') !== (typeof scene === 'number') || (typeof upcoming !== 'number' && upcoming !== scene)) {
299      const wasAsleep = isAsleep(scene, tick - sceneStart)
300      sceneStart = upcoming === 'ready' && !wasAsleep ? tick - SETTLED_TICKS : tick
301    }
302
303    scene = upcoming
304    isBandShown = true
305    const t = tick - sceneStart
306    const caption = scene === 'ready'
307      ? isIntro(t) ? 'Ooh, a prompt!' : 'Printer ready 👀'
308      : typeof scene === 'number'
309        ? `${WORD[scene]}${hasShades ? ' 😎' : ''}`
310        : `Printer idle${last === undefined ? '' : ` · last run +${grouped(last)}`}`
311    const ledger = [`${grouped(total)} printed`, dollars(usd)].filter(Boolean).join(' · ')
312    const hide = async () => {
313      await update($, isBandHidden, () => true)
314      $.ui.toast('Printer band hidden. /token-reserve brings it back.')
315    }
316
317    // Reading `redraw` is what lets the clock redraw the band, and the band alone.
318    await read($, redraw)
319
320    if (e.surface === 'terminal') {
321      const { Box, Button, Text } = $.ui.resolve(e)
322
323      return (
324        <Box flexDirection="row" gap={1}>
325          <Text color={ORANGE}>✻ {caption}</Text>
326          <Text dimColor>{ledger}</Text>
327          <Button key="hide" label="hide" plain dimColor onPress={hide} />
328        </Box>
329      )
330    }
331
332    const { Box, Text } = $.ui.resolve(e)
333
334    // Clawd and his printer are boxes, 40 columns by 3 rows (3px pixels on the
335    // desktop), with the caption pinned over the band's right. No Hide button:
336    // here the desktop draws it as a pill in the middle; /token-reserve hides.
337    return (
338      <Box flexDirection="row" height={3}>
339        <Box width={40} height={3}>{pixels(Box, frame({ scene, t, hasShades, gaze }))}</Box>
340        <Box position="absolute" top={1} right={3} flexDirection="column">
341          <Text color={ORANGE} bold>{caption}</Text>
342          <Text dimColor>{ledger}</Text>
343        </Box>
344      </Box>
345    )
346  })
347
348  on('command.run', { command: RESERVE }, async $ => {
349    await $.ui.open({ id: RESERVE, title: 'Token Reserve' })
350
351    return { text: 'Token Reserve opened.' }
352  })
353
354  on('ui.render', { component: 'Pane', requestId: RESERVE }, async ($, e) => {
355    const [total, list, hasShades, working, hidden, usd, lifetime] = await Promise.all([
356      read($, printed), read($, runs), read($, isUltra), read($, isPrinting), read($, isBandHidden), spent($), $.store.get('lifetime'),
357    ])
358    const lines = [
359      [`${grouped(total)} tokens printed this session`, dollars(usd)].filter(Boolean).join(' · '),
360      `${list.length} ${list.length === 1 ? 'reply' : 'replies'} · biggest run ${grouped(list.length === 0 ? 0 : Math.max(...list))}`,
361      `${grouped(Number(lifetime ?? 0))} tokens printed all-time`,
362    ]
363    const toggle = () => update($, isBandHidden, isHidden => !isHidden)
364    const label = hidden ? 'Show band' : 'Hide band'
365
366    if (e.surface === 'terminal') {
367      const { Box, Button, Text } = $.ui.resolve(e)
368
369      return (
370        <Box flexDirection="column">
371          {BLOCK_ART.map(row => (
372            <Box flexDirection="row">
373              {row.map(([text, color]) => <Text color={color}>{text}</Text>)}
374            </Box>
375          ))}
376          {lines.map(line => <Text>{line}</Text>)}
377          {list.length > 0 && <Text color={ORANGE}>{sparkline(list)}</Text>}
378          <Button key="band" label={label} onPress={toggle} />
379        </Box>
380      )
381    }
382
383    const { Box, Button, Svg, Text } = $.ui.resolve(e)
384
385    return (
386      <Box flexDirection="column" gap={1}>
387        <Svg {...strip(working ? level : 'idle', hasShades, 2)} alt="Clawd at the token printer" isInteractive />
388        {lines.map(line => <Text>{line}</Text>)}
389        <Text bold>Tokens per reply</Text>
390        {list.length > 0
391          ? <Svg {...chart(list)} alt={`Tokens per reply, oldest first: ${list.map(grouped).join(', ')}`} isInteractive />
392          : <Text dimColor>No replies printed yet.</Text>}
393        <Box flexDirection="row">
394          <Button key="band" label={label} onPress={toggle} />
395        </Box>
396      </Box>
397    )
398  })
399
400  on('command.run', { command: 'brrr' }, async ($, e) => {
401    const [total, usage, hasShades] = await Promise.all([read($, printed), $.session.usage(), read($, isUltra)])
402    const usd = usage.cost?.usd ?? 0
403    const full = usage.context.percent
404    const pleas: [string, ...string[]] = [
405      'STOP! I asked for a one-line fix!',
406      "STOP! You can't just print tokens forever!",
407    ]
408
409    if (total > 0) pleas.push(`STOP! You can't just print ${grouped(total)} tokens to fix a typo!`)
410    if (usd >= 0.01) pleas.push(`STOP! This session already cost $${usd.toFixed(2)}!`)
411    if (full !== undefined && full >= 25) pleas.push(`STOP! The context window is ${Math.round(full)}% full!`)
412
413    const top = e.args.replace(/\s+/g, ' ').trim() || pick(pleas)
414    const bottom = `haha token printer go brrrr${hasShades ? ' 😎' : ''}`
415    const stats = [`${grouped(total)} tokens printed`, usd >= 0.01 ? `${dollars(usd)} this session` : ''].filter(Boolean).join(' · ')
416
417    return { text: [top, bottom, stats].join('\n') }
418  })
419
420  on('ui.render', { component: 'CommandOutput', props: { command: 'brrr' } }, async ($, e, next) => {
421    if (e.props.isErrored) {
422      return next(e)
423    }
424
425    const [top = '', bottom = '', stats = ''] = e.props.text.split('\n')
426
427    if (e.surface === 'terminal') {
428      const { Box, Text } = $.ui.resolve(e)
429
430      return (
431        <Box flexDirection="column">
432          <Text bold>{top}</Text>
433          {BLOCK_ART.map(row => (
434            <Box flexDirection="row">
435              {row.map(([text, color]) => <Text color={color}>{text}</Text>)}
436            </Box>
437          ))}
438          <Text bold color={ORANGE}>{bottom}</Text>
439          <Text dimColor>{stats}</Text>
440        </Box>
441      )
442    }
443
444    const { Svg } = $.ui.resolve(e)
445
446    return <Svg {...meme(top, bottom, stats, bottom.includes('😎'))} alt={`${top} / ${bottom}`} isInteractive />
447  })
448}
449
hooks/art.ts 473 lines
1// The pictures: Clawd, his token printer and the tokens it prints. SVG markup
2// (SMIL-animated) for the surfaces that draw `Svg`, block-art rows for the
3// terminal. Pure strings, so the same scene always yields the same markup.
4
5import type { PrintLevel } from '../types'
6
7export const ORANGE = '#D97757'
8export const MUTED = '#8A8A93'
9const INK = '#2B1D18'
10const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace'
11const IMPACT = "Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif"
12
13/**
14 * What the printer is up to: a print level while a turn runs; between turns
15 * `idle` (it steams after a run, then Clawd dozes), `drowsy` (he dozes right
16 * off) or `ready` (there is a draft in the prompt box: he startles awake).
17 */
18export type Scene = PrintLevel | 'idle' | 'drowsy' | 'ready'
19
20/** How Clawd looks in a picture: blinking, gleeful, too cool (ultra), or nodding off. */
21type Look = 'plain' | 'gleeful' | 'shades' | 'dozing'
22
23/** How many steps Clawd's gaze takes across the prompt box. */
24export const ACROSS = 6
25
26/** Where the caret is, as Clawd sees it: the step across the box, and whether past its first line. */
27export type Gaze = { across: number; isLower: boolean }
28
29/** Seconds the printer steams after a run before Clawd dozes off. */
30const COOLDOWN = 6
31
32
33type Rect = readonly [x: number, y: number, width: number, height: number]
34
35const rects = (color: string, list: readonly Rect[]) =>
36  list.map(([x, y, w, h]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${color}"/>`).join('')
37
38const forever = (seconds: number, begin = 0) =>
39  `dur="${seconds}s" begin="${begin.toFixed(2)}s" repeatCount="indefinite"`
40
41const shaking = (amp: number, seconds: number) =>
42  amp > 0
43    ? `<animateTransform attributeName="transform" type="translate" values="0 0;${amp} ${-amp};${-amp} ${amp / 2};${amp / 2} ${amp};0 0" ${forever(seconds)}/>`
44    : ''
45
46/** Markup-safe and ASCII-only: anything else becomes a numeric entity. */
47const escape = (text: string) => text.replace(/[&<>"']|[^\x20-\x7E]/gu, c => `&#${c.codePointAt(0)};`)
48
49/** An Svg element's props: the markup, and the size to draw it at in CSS pixels. */
50export type Picture = { source: string; width: number; height: number }
51
52// The desktop draws an interactive Svg in a frame of its own: a default 300×150
53// unless told the size, and light unless told otherwise, which a dark app shows
54// as an opaque white backdrop. So every picture carries its size, and its frame
55// follows the app's colour scheme. Any change to a picture (its markup or its
56// size) makes the desktop reload its frame, restarting its animations.
57const svg = (width: number, height: number, body: string, scale = 1): Picture => ({
58  source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width * scale}" height="${height * scale}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMid meet"><style>:root{color-scheme:light dark}</style>${body}</svg>`,
59  width: width * scale,
60  height: height * scale,
61})
62
63export const grouped = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
64
65export const compact = (n: number) =>
66  n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${+(n / 1e3).toFixed(1)}k` : String(n)
67
68/** Clawd as the Claude Code logo draws him: 18×10 pixels of `px` each. */
69function clawd(x: number, y: number, px: number, beat: number, look: Look, doze = COOLDOWN): string {
70  const hop = beat > 0 ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -1;0 0" ${forever(beat)}/>` : ''
71  const arm = (left: number, values: string) =>
72    `<rect x="${left}" y="4" width="2" height="2" fill="${ORANGE}">${beat > 0 ? `<animate attributeName="y" values="${values}" ${forever(beat)}/>` : ''}</rect>`
73  const blink = (top: number) => `<animate attributeName="height" values="2;2;0.3;2" keyTimes="0;0.92;0.96;1" ${forever(3.2)}/><animate attributeName="y" values="${top};${top};${top + 0.85};${top}" keyTimes="0;0.92;0.96;1" ${forever(3.2)}/>`
74  const open = (top = 2) => `<rect x="5" y="${top}" width="1" height="2" fill="${INK}">${blink(top)}</rect><rect x="12" y="${top}" width="1" height="2" fill="${INK}">${blink(top)}</rect>`
75  const at = (seconds: number, visibility: 'visible' | 'hidden') => `<set attributeName="visibility" to="${visibility}" begin="${seconds}s" fill="freeze"/>`
76  const eyes = {
77    plain: open(),
78    gleeful: rects(INK, [[4, 3, 1, 1], [5, 2, 1, 1], [6, 3, 1, 1], [11, 3, 1, 1], [12, 2, 1, 1], [13, 3, 1, 1]]),
79    // Lenses inside the face, so its orange frames them on a dark background too.
80    shades: `<g class="shades">${rects('#111111', [[4, 2, 4, 2], [10, 2, 4, 2], [8, 2, 2, 1]])}<rect x="10" y="2" width="1" height="1" fill="#FFFFFF" opacity="0.45"/><rect x="4" y="2" width="1" height="1" fill="#FFFFFF" opacity="0.45"><animate attributeName="opacity" values="0.45;0.45;1;0.45" keyTimes="0;0.8;0.9;1" ${forever(2.5)}/></rect></g>`,
81    dozing: `<g>${open()}${at(doze, 'hidden')}</g><g visibility="hidden">${rects(INK, [[4, 3, 2, 1], [12, 3, 2, 1]])}${at(doze, 'visible')}</g>`,
82    // Wide-eyed for the beat, then eyes down on the prompt box.
83  }[look]
84
85  return `<g transform="translate(${x} ${y}) scale(${px})" shape-rendering="crispEdges"><g>${hop}${rects(ORANGE, [[3, 0, 12, 8], [4, 8, 1, 2], [6, 8, 1, 2], [11, 8, 1, 2]])}<rect x="13" y="8" width="1" height="2" fill="${ORANGE}"/>${arm(1, '4;2;4')}${arm(15, '2;4;2')}${eyes}</g></g>`
86}
87
88/** The token printer: 26×17 pixels of `px` each, its slot opening at (26, 8). */
89function printer(x: number, y: number, px: number, level: PrintLevel, shake: number, isSteaming: boolean): string {
90  const feed = level > 0
91    ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 1;0 0" ${forever(0.3)}/>`
92    : ''
93  const lights = [4, 6, 8].map((left, i) =>
94    `<rect x="${left}" y="8" width="1" height="1" fill="#5FD38D"><animate attributeName="opacity" values="1;0.15;1" ${forever(level > 0 ? 0.45 : 1.6, -i * 0.15)}/></rect>`).join('')
95  const led = level > 0
96    ? `<rect x="20" y="11" width="2" height="1" fill="#5FD38D"><animate attributeName="opacity" values="1;0.2;1" ${forever(0.25)}/></rect>`
97    : rects('#E5534B', [[20, 11, 2, 1]])
98  const steam = isSteaming
99    ? [7, 11.5, 16].map((cx, i) => {
100      const puff = `dur="1.5s" begin="${i * 0.5}s" repeatCount="4"`
101      return `<circle cx="${cx}" cy="1" r="1" fill="${MUTED}" opacity="0"><animate attributeName="cy" values="1;-2.5" ${puff}/><animate attributeName="r" values="0.8;1.8" ${puff}/><animate attributeName="opacity" values="0.9;0" ${puff}/></circle>`
102    }).join('')
103    : ''
104  const whoosh = level >= 2
105    ? `<g stroke="${MUTED}" stroke-width="0.6" stroke-linecap="round" fill="none"><path d="M-1.5 6l-2.5-1.5M-1.5 10h-3M-1.5 14l-2.5 1.5M4-1l-1.2-2.4M12-1v-2.6"/><animate attributeName="opacity" values="1;0.1;1" ${forever(0.2)}/></g>`
106    : ''
107
108  return `<g transform="translate(${x} ${y}) scale(${px})"><g>${shaking(shake / px, 0.12)}<g shape-rendering="crispEdges"><g>${feed}${rects('#F4EDE1', [[5, 0, 13, 4]])}${rects('#C9C1B3', [[7, 1, 8, 0.4], [7, 2.2, 6, 0.4]])}</g>${rects('#8B8B97', [[1, 3, 22, 3]])}${rects('#5B5B66', [[0, 5, 24, 9]])}${rects('#6E6E7A', [[0, 5, 24, 1]])}${rects('#143326', [[3, 7, 7, 3]])}${lights}${rects('#1E1E24', [[22, 7, 4, 2]])}${rects('#3A3A44', [[22, 9, 4, 1], [1, 14, 22, 2]])}${rects('#2A2A32', [[2, 16, 4, 1], [18, 16, 4, 1]])}${led}</g><path d="M16.5 7.4v4.2M14.4 9.5h4.2M15 8l3 3M18 8l-3 3" stroke="${ORANGE}" stroke-width="0.7" stroke-linecap="round"/></g>${whoosh}${steam}</g>`
109}
110
111/** One token: a bill with Claude's spark on it, `w` wide, centred on 0 0. */
112function bill(w: number): string {
113  const h = w * 0.56
114  const r = h * 0.3
115  const d = r * 0.7
116  return `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${h * 0.12}" fill="#F7EEDC" stroke="#C96442" stroke-width="${w / 16}"/><path d="M0 ${-r}V${r}M${-r} 0H${r}M${-d} ${-d}L${d} ${d}M${d} ${-d}L${-d} ${d}" stroke="${ORANGE}" stroke-width="${w / 14}" stroke-linecap="round"/>`
117}
118
119/** `count` tokens flying out of a slot at x y, each flight `seconds` long; `lift` flattens the arcs. */
120function tokens(x: number, y: number, count: number, seconds: number, reach: number, w: number, lift = 1): string {
121  const flights = Array.from({ length: count }, (_, i) => {
122    const rise = reach * (0.12 + ((i * 7) % 5) * 0.05) * lift
123    const land = reach * (((i * 5) % 7) * 0.05 - 0.05) * lift
124    const far = reach * (0.72 + ((i * 3) % 4) * 0.09)
125    const path = `M0 0C${(far * 0.3).toFixed(1)} ${(-rise * 1.4).toFixed(1)} ${(far * 0.65).toFixed(1)} ${(-rise).toFixed(1)} ${far.toFixed(1)} ${land.toFixed(1)}`
126    const begin = -(i / count) * seconds
127    const tilt = (i * 47) % 360
128    const turn = i % 2 === 0 ? 360 : -360
129    const spin = 0.45 + ((i * 3) % 5) * 0.12
130    return `<g opacity="0"><animateMotion path="${path}" ${forever(seconds, begin)}/><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.06;0.72;1" ${forever(seconds, begin)}/><g><animateTransform attributeName="transform" type="rotate" values="${tilt};${tilt + turn}" ${forever(spin)}/>${bill(w)}</g></g>`
131  })
132  return `<g transform="translate(${x} ${y})">${flights.join('')}</g>`
133}
134
135const TEMPO = [
136  { count: 1, seconds: 2.6, shake: 0, beat: 0 },
137  { count: 3, seconds: 1.8, shake: 0.3, beat: 0.6 },
138  { count: 5, seconds: 1.3, shake: 0.5, beat: 0.45 },
139  { count: 8, seconds: 0.95, shake: 0.8, beat: 0.3 },
140] as const
141
142/** Z's drifting up off Clawd once he has dozed off, `doze` seconds in. */
143const zzz = (doze: number) => [0, 1, 2].map(i => {
144  const drift = `dur="2.4s" begin="${doze + i * 0.8}s" repeatCount="indefinite"`
145  return `<text x="${24 + i * 2.5}" y="7" font-family="${MONO}" font-size="${4 + i}" fill="${MUTED}" opacity="0">z<animate attributeName="y" values="7;1" ${drift}/><animate attributeName="opacity" values="0;1;0" ${drift}/></text>`
146}).join('')
147
148/**
149 * Clawd at the printer as a picture (the Token Reserve draws it). Printing, it
150 * runs at the level; idle, it steams for a few seconds and Clawd dozes off,
151 * unless he wears shades.
152 */
153export function strip(scene: PrintLevel | 'idle', hasShades: boolean, scale = 1): Picture {
154  if (scene === 'idle') {
155    const doze = COOLDOWN
156    return svg(136, 20,
157      clawd(1, 5, 1.5, 0, hasShades ? 'shades' : 'dozing', doze)
158      + printer(34, 3, 1, 0, 0, true)
159      + (hasShades ? '' : zzz(doze)), scale)
160  }
161
162  const tempo = TEMPO[scene]
163  return svg(136, 20,
164    clawd(1, 5, 1.5, tempo.beat, hasShades ? 'shades' : scene === 3 ? 'gleeful' : 'plain')
165    + printer(34, 3, 1, scene, tempo.shake, false)
166    + tokens(62, 11, tempo.count, tempo.seconds, 70, 8, 0.25), scale)
167}
168
169/** Tokens per reply as bars, newest on the right; hovering one names its count. */
170export function chart(runs: readonly number[]): Picture {
171  const width = 280
172  const base = 62
173  const gap = 2
174  const most = Math.max(1, ...runs)
175  const barWidth = Math.min(24, (width - gap * (runs.length - 1)) / runs.length)
176  const columns = runs.map((run, i) => {
177    const x = width - (runs.length - i) * (barWidth + gap) + gap
178    const h = Math.max(2, (run / most) * (base - 14))
179    const r = Math.min(4, barWidth / 2, h)
180    const y = base - h
181    const ago = runs.length - 1 - i
182    return `<g class="col"><title>${ago === 0 ? 'Last reply' : `${ago + 1} replies ago`}: ${grouped(run)} tokens</title><rect x="${x - gap / 2}" y="0" width="${barWidth + gap}" height="${base}" fill="transparent"/><path class="bar" d="M${x} ${base}V${y + r}Q${x} ${y} ${x + r} ${y}H${x + barWidth - r}Q${x + barWidth} ${y} ${x + barWidth} ${y + r}V${base}Z"/></g>`
183  }).join('')
184  return svg(width, 64,
185    '<style>.bar{fill:#D97757}.col:hover .bar{fill-opacity:.7}.ink{fill:#6B6A66}.rule{stroke:#8A8A93;stroke-opacity:.5}'
186    + '@media (prefers-color-scheme:dark){.bar{fill:#D06A48}.ink{fill:#A3A29E}}</style>'
187    + `<text class="ink" x="0" y="8" font-family="${MONO}" font-size="9">max ${grouped(most)}</text>`
188    + columns
189    + `<line class="rule" x1="0" y1="${base + 0.5}" x2="${width}" y2="${base + 0.5}"/>`)
190}
191
192const FACE = [
193  '.....HHHHHH.....',
194  '...HHHHHHHHHH...',
195  '..HHHHHHHHHHHH..',
196  '..HSSSSSSSSSSH..',
197  '.HSSSbSSSSbSSSH.',
198  '.HSbbSSSSSSbbSH.',
199  '.SSEEESSSSEEESS.',
200  '.STTSSSSSSSSTTS.',
201  '.STTSSSSSSSSTTS.',
202  '.STSSSMMMMSSSTS.',
203  '.STSSMMttMMSSTS.',
204  '..TSSMMMMMMSST..',
205  '..TSSSSSSSSSST..',
206  '...SSSSSSSSSS...',
207  '....SSSSSSSS....',
208] as const
209
210const FACE_COLORS: Readonly<Record<string, string>> = {
211  H: '#3B2A20', b: '#3B2A20', E: '#3B2A20', S: '#F2C7A5', T: '#5DADE2', M: '#5A1E1E', t: '#D9534F',
212}
213
214/** A pixel map as runs of rects, one colour per letter, `.` left empty. */
215function pixels(map: readonly string[], colors: Readonly<Record<string, string>>): string {
216  return map.map((row, y) => {
217    const runs: string[] = []
218    for (let x = 0; x < row.length;) {
219      const c = row.charAt(x)
220      let run = 1
221      while (row.charAt(x + run) === c) run += 1
222      const color = colors[c]
223      if (color !== undefined) runs.push(`<rect x="${x}" y="${y}" width="${run}" height="1" fill="${color}"/>`)
224      x += run
225    }
226    return runs.join('')
227  }).join('')
228}
229
230/** The one begging the printer to stop: sobbing, 16×15 pixels of `px` each. */
231function crier(x: number, y: number, px: number): string {
232  const drops = ([[2.5, 0], [13.5, 0.45]] as const).map(([cx, lag]) =>
233    `<rect x="${cx - 0.5}" y="13" width="1" height="1" fill="#5DADE2"><animate attributeName="y" values="13;17" ${forever(0.9, -lag)}/><animate attributeName="opacity" values="1;0" ${forever(0.9, -lag)}/></rect>`).join('')
234  return `<g transform="translate(${x} ${y}) scale(${px})" shape-rendering="crispEdges"><g><animateTransform attributeName="transform" type="translate" values="0 0;0.25 0;-0.25 0;0 0" ${forever(0.3)}/>${pixels(FACE, FACE_COLORS)}${drops}</g></g>`
235}
236
237function wrap(text: string, max: number): string[] {
238  const lines: string[] = []
239  let line = ''
240  for (const word of text.split(/\s+/).filter(Boolean)) {
241    if (line !== '' && line.length + 1 + word.length > max) {
242      lines.push(line)
243      line = word
244    } else {
245      line = line === '' ? word : `${line} ${word}`
246    }
247  }
248  return line === '' ? lines : [...lines, line]
249}
250
251/** Meme text: white Impact with a black outline, wrapped, centred on cx. */
252function caption(text: string, cx: number, y: number): string {
253  const lines = wrap(text.toUpperCase(), 32)
254  const shown = lines.length > 3 ? [...lines.slice(0, 2), `${lines[2]}…`] : lines
255  const size = shown.length > 2 ? 24 : 30
256  const spans = shown.map((line, i) => `<tspan x="${cx}" dy="${i === 0 ? 0 : size * 1.1}">${escape(line)}</tspan>`).join('')
257  return `<text x="${cx}" y="${y}" text-anchor="middle" font-family="${IMPACT}" font-size="${size}" fill="#FFFFFF" stroke="#000000" stroke-width="${size / 5}" stroke-linejoin="round" paint-order="stroke">${spans}</text>`
258}
259
260/** The meme: someone begging the printer to stop, then Clawd printing anyway. */
261export function meme(top: string, bottom: string, stats: string, hasShades = false): Picture {
262  return svg(500, 484,
263    `<rect x="1.5" y="1.5" width="497" height="235" fill="#FFFFFF" stroke="#111111" stroke-width="3"/>`
264    + crier(194, 112, 7)
265    + caption(top, 250, 42)
266    + `<rect x="1.5" y="242.5" width="497" height="240" fill="#FFF4E8" stroke="#111111" stroke-width="3"/>`
267    + clawd(26, 386, 5, 0.3, hasShades ? 'shades' : 'gleeful')
268    + printer(126, 351, 5, 3, 2, false)
269    + tokens(258, 391, 12, 1.05, 220, 24)
270    + caption(bottom, 250, 286)
271    + `<text x="250" y="470" text-anchor="middle" font-family="${MONO}" font-size="12" fill="#7A6A60">${escape(stats)}</text>`)
272}
273
274/** The meme's bottom panel for the terminal: rows of [text, colour] runs. */
275export const BLOCK_ART: readonly (readonly (readonly [string, string])[])[] = [
276  [[' ▐▛███▜▌ ', ORANGE], ['  ┌──────┐', MUTED], ['    ✻     ✻', ORANGE]],
277  [['▝▜█████▛▘', ORANGE], ['  │ ▒▒ ✻ ├═', MUTED], ['  ✻   ✻     ✻', ORANGE]],
278  [['  ▘▘ ▝▝  ', ORANGE], ['  └──────┘', MUTED], ['  ✻    ✻   ✻', ORANGE]],
279]
280
281/** Tokens per reply as one row of block heights, for the terminal. */
282export function sparkline(runs: readonly number[]): string {
283  const most = Math.max(1, ...runs)
284  return runs.map(run => '▁▂▃▄▅▆▇█'.charAt(Math.min(7, Math.floor((run / most) * 8)))).join('')
285}
286
287// The band drawn without pictures: a framebuffer of 100 by 20 pixels that the
288// band lays out as boxes (each pixel 1% wide and 5% tall), so a new moment
289// changes boxes in place and nothing reloads. `frame` paints one moment.
290
291/** How long a tick of the band's clock lasts; animation is counted in ticks. */
292export const TICK_MS = 125
293
294/** What each letter of a frame paints; `.` paints nothing. */
295export const PALETTE: Readonly<Record<string, string>> = {
296  O: ORANGE, E: INK, K: '#111111', W: '#FFFFFF', M: MUTED,
297  P: '#F4EDE1', L: '#C9C1B3', T: '#8B8B97', B: '#5B5B66', D: '#143326', G: '#5FD38D', g: '#2E6B4A',
298  Y: ORANGE, S: '#1E1E24', X: '#3A3A44', F: '#2A2A32', R: '#E5534B', A: '#F2C14E',
299  C: '#F7EEDC', c: '#C96442',
300}
301
302/** One moment of the band: its scene, ticks into it, and how Clawd looks. */
303export type Moment = { scene: Scene; t: number; hasShades: boolean; gaze: Gaze }
304
305const WIDE = 100
306const TALL = 20
307/** Ticks the printer steams after a run; ticks Clawd takes to doze with nothing printed. */
308const COOL_TICKS = 48
309const DROWSE_TICKS = 10
310/** Ticks Clawd stays startled when a draft appears; he blinks every BLINK_TICKS. */
311const STARTLE_TICKS = 8
312const BLINK_TICKS = 28
313
314/** How the printer runs per level: tokens in the air, ticks per flight, Clawd's beat, the shake's. */
315const PRESS = [
316  { bills: 1, flight: 24, beat: 0, shake: 0 },
317  { bills: 2, flight: 16, beat: 4, shake: 0 },
318  { bills: 3, flight: 12, beat: 3, shake: 2 },
319  { bills: 5, flight: 9, beat: 2, shake: 1 },
320] as const
321
322/** Clawd's body as the logo draws it; his arms, legs and eyes go on top. */
323const CLAWD_BODY = Array.from({ length: 8 }, () => '...OOOOOOOOOOOO...')
324
325const PRINTER = [
326  '....PPPPPPPPP....',
327  '....PLLLLLLPP....',
328  '.TTTTTTTTTTTTTTT.',
329  'BBBBBBBBBBBBBBBSS',
330  'BDDDDDBBBBBBBBBSS',
331  'BDgDgDBBBBYBBBB..',
332  'BDDDDDBBBYYYBBB..',
333  'BBBBBBBBBBYBBBB..',
334  'BBBBBBBBBBBBBBB..',
335  '.XXXXXXXXXXXXX...',
336  '.FF.........FF...',
337] as const
338
339const BILL_FLAT = ['cccc', 'cCCc', 'cccc'] as const
340const BILL_UP = ['ccc', 'cCc', 'cCc', 'ccc'] as const
341const ZED = ['MMM', '..M', '.M.', 'MMM'] as const
342const BANG = ['AA', 'AA', 'AA', 'AA', 'AA', '..', 'AA'] as const
343/** Max effort's shades: over his eyes while printing, pushed up on his head otherwise. */
344const SHADES = ['KKKKKKKKKK', 'KKKK..KKKK'] as const
345
346/** Clawd's eyes, one step per sixth of the prompt box: how far across they look. */
347const GAZE_DX = [-1, 0, 0, 1, 1, 2] as const
348
349type Grid = string[][]
350type Eyes = 'open' | 'gleeful' | 'shades' | 'closed' | 'wide' | 'watch'
351
352function stamp(grid: Grid, sprite: readonly string[], x: number, y: number) {
353  sprite.forEach((row, dy) => {
354    const line = grid[y + dy]
355    for (let dx = 0; dx < row.length; dx += 1) {
356      const letter = row.charAt(dx)
357      if (letter !== '.' && line !== undefined && x + dx >= 0 && x + dx < WIDE) line[x + dx] = letter
358    }
359  })
360}
361
362const plot = (grid: Grid, cells: readonly (readonly [number, number])[], x: number, y: number, letter: string) =>
363  cells.forEach(([cx, cy]) => stamp(grid, [letter], x + cx, y + cy))
364
365function drawClawd(grid: Grid, x: number, y: number, eyes: Eyes, { arms = 'down', isTapping = false, isBlinking = false, gaze = { across: 0, isLower: false } }: {
366  arms?: 'down' | 'up' | 'left' | 'right'; isTapping?: boolean; isBlinking?: boolean; gaze?: Gaze
367} = {}) {
368  stamp(grid, CLAWD_BODY, x, y)
369  stamp(grid, ['OO', 'OO'], x + 1, y + (arms === 'up' || arms === 'left' ? 2 : 4))
370  stamp(grid, ['OO', 'OO'], x + 15, y + (arms === 'up' || arms === 'right' ? 2 : 4))
371  for (const leg of [4, 6, 11]) stamp(grid, ['O', 'O'], x + leg, y + 8)
372  stamp(grid, isTapping ? ['O'] : ['O', 'O'], x + 13, y + 8)
373
374  if (eyes === 'shades') {
375    stamp(grid, SHADES, x + 4, y + 2)
376    plot(grid, [[4, 2]], x, y, 'W')
377    return
378  }
379
380  const dx = eyes === 'watch' ? GAZE_DX[gaze.across] ?? 0 : 0
381  const top = eyes === 'watch' ? 3 + (gaze.isLower ? 1 : 0) : 2
382  const cells: Record<Exclude<Eyes, 'shades'>, readonly (readonly [number, number])[]> = {
383    open: [[5, top], [5, top + 1], [12, top], [12, top + 1]],
384    watch: [[5 + dx, top], [5 + dx, top + 1], [12 + dx, top], [12 + dx, top + 1]],
385    gleeful: [[4, 3], [5, 2], [6, 3], [11, 3], [12, 2], [13, 3]],
386    closed: [[4, 3], [5, 3], [12, 3], [13, 3]],
387    wide: [[4, 1], [5, 1], [4, 2], [5, 2], [4, 3], [5, 3], [12, 1], [13, 1], [12, 2], [13, 2], [12, 3], [13, 3]],
388  }
389  // A blink closes open eyes to their lower pixel.
390  plot(grid, isBlinking && (eyes === 'open' || eyes === 'watch') ? cells[eyes].filter((_, i) => i % 2 === 1) : cells[eyes], x, y, 'E')
391}
392
393function drawPrinter(grid: Grid, x: number, y: number, led: string, lit: number) {
394  stamp(grid, PRINTER, x, y)
395  plot(grid, [[13, 7]], x, y, led)
396  if (lit & 1) plot(grid, [[2, 5]], x, y, 'G')
397  if (lit & 2) plot(grid, [[4, 5]], x, y, 'G')
398}
399
400function drawBills(grid: Grid, count: number, flight: number, t: number) {
401  for (let i = 0; i < count; i += 1) {
402    const u = ((t + (i * flight) / count) % flight) / flight
403    if (u > 0.92) continue
404    const reach = 38 + ((i * 7) % 15)
405    const rise = 4 + ((i * 3) % 5)
406    const drop = ((i * 5) % 6) - 1
407    const bill = (Math.floor(t / 2) + i) % 2 === 0 ? BILL_FLAT : BILL_UP
408    stamp(grid, bill, 41 + Math.round(u * reach), Math.round(11 - rise * Math.sin(Math.PI * u) + drop * u))
409  }
410}
411
412/** One moment of the band as rows of palette letters, 100 across and 20 down. */
413export function frame({ scene, t, hasShades, gaze }: Moment): string[] {
414  const grid: Grid = Array.from({ length: TALL }, () => Array.from({ length: WIDE }, () => '.'))
415  const isBlinking = t % BLINK_TICKS >= BLINK_TICKS - 2
416
417  if (typeof scene === 'number') {
418    const press = PRESS[scene]
419    const beat = press.beat > 0 ? Math.floor(t / press.beat) % 2 : 0
420    drawClawd(grid, 1, 10 - beat, hasShades ? 'shades' : scene === 3 ? 'gleeful' : 'open', {
421      arms: press.beat > 0 ? (beat === 1 ? 'left' : 'right') : 'down',
422      isBlinking,
423    })
424    const shake = press.shake > 0 ? Math.floor(t / press.shake) % 2 : 0
425    drawPrinter(grid, 23 + shake, 9, t % 2 === 0 ? 'G' : 'X', 1 << (t % 2))
426    if (scene >= 2 && t % 2 === 0) plot(grid, [[-2, 2], [-3, 5], [-2, 8]], 23, 9, 'M')
427    drawBills(grid, press.bills, press.flight, t)
428  } else if (scene === 'ready') {
429    // A draft startles him (a jump, arms up, a "!"), then he watches the caret,
430    // any shades pushed up so his eyes show.
431    const isStartled = t < STARTLE_TICKS
432    const y = isStartled ? Math.min(10, 8 + Math.floor(t / 2)) : 10
433    drawClawd(grid, 1, y, isStartled ? 'wide' : 'watch',
434      { arms: isStartled ? 'up' : 'down', isTapping: !isStartled && t % 8 < 2, isBlinking, gaze })
435    if (hasShades) stamp(grid, SHADES, 5, y - 1)
436    drawPrinter(grid, 23, 9, Math.floor(t / 4) % 2 === 0 ? 'A' : 'X', 3)
437    if (t < STARTLE_TICKS + 2) stamp(grid, BANG, 20, 2)
438  } else {
439    // After a run the printer steams, then Clawd dozes off, any shades pushed up.
440    const doze = scene === 'idle' ? COOL_TICKS : DROWSE_TICKS
441    const isAsleep = t >= doze
442    drawClawd(grid, 1, 10, isAsleep ? 'closed' : 'open', { isBlinking })
443    if (hasShades) stamp(grid, SHADES, 5, 9)
444    drawPrinter(grid, 23, 9, 'R', 0)
445    if (scene === 'idle' && t < COOL_TICKS) {
446      ;[6, 9, 12].forEach((col, k) => plot(grid, [[col, -1 - ((Math.floor(t / 2) + k * 2) % 5)]], 23, 9, 'M'))
447    }
448    if (isAsleep) {
449      const rise = Math.floor(t / 3) % 6
450      stamp(grid, ZED, 19 + (rise > 2 ? 1 : 0), 7 - rise)
451    }
452  }
453
454  return grid.map(row => row.join(''))
455}
456
457/** Ticks between redraws of a scene, `t` ticks in: only as often as something moves. */
458export function cadence(scene: Scene, t: number): number {
459  if (typeof scene === 'number') return 1
460  if (scene === 'ready') return t < STARTLE_TICKS + 2 ? 1 : 2
461  return scene === 'idle' && t < COOL_TICKS ? 2 : 3
462}
463
464/** Whether the ready scene still says its intro ("Ooh, a prompt!"), `t` ticks in. */
465export const isIntro = (t: number) => t < STARTLE_TICKS + 2
466
467/** Ticks into the ready scene where the startle is over: where it starts if he was awake. */
468export const SETTLED_TICKS = STARTLE_TICKS + 2
469
470/** Whether Clawd has dozed off, `t` ticks into a scene (only idle and drowsy ones doze). */
471export const isAsleep = (scene: Scene, t: number) =>
472  scene === 'idle' ? t >= COOL_TICKS : scene === 'drowsy' && t >= DROWSE_TICKS
473
hooks/caret.ts 99 lines
1// Where the caret sits on screen in the prompt box, worked out the way the
2// box lays text out: words wrap whole at its width, measured in the box's own
3// font. Only the text and the caret's offset reach a mod; this is the rest.
4
5/**
6 * Advance widths, in CSS px, of ' ' to '~' in the prompt box's font (14px
7 * system-ui), measured in the browser engine the desktop app runs on.
8 */
9const WIDTHS = [
10  3.79, 4.2, 6.54, 8.67, 8.67, 12.8, 9.81, 4.01, 5.2, 5.2, 6.45, 8.67, 4.01, 6.45, 4.01, 4.12,
11  8.67, 6.34, 8.3, 8.63, 8.86, 8.5, 8.76, 7.82, 8.79, 8.76, 4.01, 4.01, 8.67, 8.67, 8.67, 7.03,
12  12.7, 9.28, 9.05, 9.87, 10.02, 8.19, 7.86, 10.3, 10.24, 3.6, 7.38, 9.07, 7.8, 12.09, 10.24, 10.65,
13  8.74, 10.65, 9, 8.77, 8.72, 10.17, 9.28, 13.4, 9.35, 9.02, 9.11, 5.2, 4.12, 5.2, 8.67, 8.02,
14  6.85, 7.57, 8.45, 7.68, 8.45, 7.85, 4.92, 8.38, 8.09, 3.31, 3.3, 7.45, 3.39, 12.03, 8.02, 8.12,
15  8.39, 8.38, 5.18, 7.18, 4.94, 8.02, 7.44, 10.69, 7.19, 7.45, 7.4, 5.2, 3.47, 5.2, 8.67,
16] as const
17
18/** Measured the same way: punctuation the box puts in for you, and common accented letters. */
19const OTHER_WIDTHS: Readonly<Record<string, number>> = {
20  '‘': 4.01, '’': 4.01, '“': 6.28, '”': 6.28, '…': 11.12, '–': 8.02, '—': 12.09, '•': 6.45, '·': 4.01,
21  'é': 7.85, 'è': 7.85, 'á': 7.57, 'à': 7.57, 'í': 3.31, 'ó': 8.12, 'ú': 8.02, 'ñ': 8.02, 'ü': 8.02,
22  'ö': 8.12, 'ä': 7.57, 'ç': 7.68, 'É': 8.19, '¿': 7.03, '¡': 4.2, '€': 8.67, '£': 8.67, '°': 6.45,
23  '×': 8.67, '→': 12.53, '←': 12.53, '«': 9.11, '»': 9.11,
24}
25
26/**
27 * The box kerns its text (letters like o and k tuck together), which measuring
28 * letters one by one leaves out: whole lines measure 0.992 of their letters' sum.
29 */
30const KERNING = 0.992
31
32/** How many UTF-16 units the character at `i` takes: 2 for an astral one (most emoji). */
33const unitsAt = (text: string, i: number) => {
34  const code = text.charCodeAt(i)
35  return code >= 0xd800 && code <= 0xdbff ? 2 : 1
36}
37
38/** How wide the character at `i` draws: measured where known, else by its script. */
39function widthAt(text: string, i: number): number {
40  if (unitsAt(text, i) === 2) return 18
41  const code = text.charCodeAt(i)
42  const width = WIDTHS[code - 32] ?? OTHER_WIDTHS[text.charAt(i)]
43    ?? (code >= 0xac00 && code <= 0xd7af ? 12.11 : code >= 0x3000 && code <= 0x9fff ? 13.89 : 8)
44  return width * KERNING
45}
46
47/**
48 * Where the caret lands on screen: its visual line, and how far into it in
49 * CSS px, with lines `width` px wide wrapping whole words as the box does (a
50 * word wider than a line breaks where it overflows; spaces hang at its end).
51 */
52export function caretAt(text: string, cursor: number, width: number): { line: number; x: number } {
53  // A caret past the text (the box and the mod disagreeing) stops at its end.
54  const end = Math.min(cursor, text.length)
55  let line = 0
56  let x = 0
57
58  for (let i = 0; i < end;) {
59    const c = text.charAt(i)
60
61    if (c === '\n') {
62      line += 1
63      x = 0
64      i += 1
65      continue
66    }
67
68    if (c === ' ') {
69      x += widthAt(text, i)
70      i += 1
71      continue
72    }
73
74    // A word that will not fit on this line starts the next one.
75    let after = i
76    let wordWidth = 0
77    while (after < text.length && text.charAt(after) !== ' ' && text.charAt(after) !== '\n') {
78      wordWidth += widthAt(text, after)
79      after += unitsAt(text, after)
80    }
81    if (x > 0 && x + wordWidth > width) {
82      line += 1
83      x = 0
84    }
85
86    // Through the word up to the caret, breaking it where it overflows a line.
87    for (const stop = Math.min(after, end); i < stop; i += unitsAt(text, i)) {
88      const w = widthAt(text, i)
89      if (x > 0 && x + w > width) {
90        line += 1
91        x = 0
92      }
93      x += w
94    }
95  }
96
97  return { line, x }
98}
99
types/index.d.ts 22 lines
1/** How hard the printer runs: warming up, tools, thinking, streaming. */
2export type PrintLevel = 0 | 1 | 2 | 3
3
4declare module 'claude-code' {
5  interface PluginState {
6    'token-printer': {
7      /** Tokens printed this session, every loop's (subagents too). */
8      printed: number
9      /** Tokens printed per reply, oldest first, the last 30. */
10      runs: readonly number[]
11      /** The session runs at Max effort (its last request did): Clawd's shades. */
12      isUltra: boolean
13      /** A reply is being printed right now. */
14      isPrinting: boolean
15      /** The person hid the band above the prompt. */
16      isBandHidden: boolean
17      /** Bumped to redraw the band: only the band reads it. */
18      redraw: number
19    }
20  }
21}
22