SLOPSHOPPER

clawd-run

While Claude works on a long turn, Clawd plays a side-scrolling platformer above the prompt

newbandcommandtimer
v0.2.0MITupdated 2026-10-22SheldonChangL/claude-mods/mods/clawd-run
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-run
› fix the failing auth test and add an audit log call ⏺ 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 › /clawd-run ⎿ clawd-run: Clawd is playing. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods for Claude Code (CLI and the Desktop app's Code tab), each in its own folder under mods/.

ModWhat it does
pillbarA capsule band above the prompt: the 5-hour and weekly plan usage, this session's context window, and the machine's CPU, memory and disk.
clawd-runWhile Claude works on a long turn, Clawd plays a side-scrolling platformer above the prompt, with the turn's tokens and cost.
token-tankA pixel-art aquarium above the prompt: Claude's turns feed the fish, the fish spit coins onto the sand and you pick them up; a shop, feeding and levels live in a pane (/fish).
plain-narratorA pane where Haiku explains, in plain language, what Claude is doing, with a side chat you can ask using the model you pick (Haiku, Sonnet, Opus, Fable, or the main conversation itself). Off until /narrator turns it on: it sends Claude's visible text and tool arguments to Haiku on your own quota.

pillbar, clawd-run and token-tank share the band above the prompt and stack instead of replacing each other.

token-tank above the prompt, pillbar beneath

Install

From a terminal session of Claude Code, one line per mod (y to add the marketplace, then pick a scope):

/plugin install token-tank --marketplace SheldonChangL/claude-mods

From a shell: claude plugin install token-tank --marketplace SheldonChangL/claude-mods. The same works for pillbar, clawd-run and plain-narrator.

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with SheldonChangL/claude-mods, then Add plugin.

To run a mod from a checkout instead, point CLAUDE_CODE_PLUGIN_DIRS at the mods you want, colon-separated, in the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/mods/pillbar:/path/to/claude-mods/mods/token-tank:/path/to/claude-mods/mods/plain-narrator"
  }
}

Then start a new session (on Desktop, restart the app).

pillbar's quota meters fill as soon as the engine reports usage. If the separate JetDeck mod is loaded, pillbar reads its daemon instead, so the meters fill without sending a message first; without it pillbar samples the machine itself.

Develop

Each mod is self-contained. From a mod's folder:

claude plugin test .
claude plugin validate .

What they touch

  • Nothing leaves your machine except calls to the Claude API through your own Claude Code session, and only plain-narrator makes any (to Haiku, and only after /narrator).
  • token-tank keeps its save in the plugin's own store under ~/.claude; it reads the language row of your settings to pick its words.
  • pillbar runs a small Node child from its own folder that samples CPU, memory and disk; it talks to the private JetDeck daemon only when PILLBAR_JETDECK_DIR names it.
  • No mod writes outside ~/.claude, downloads code, or runs a shell.

Support

Buy the fish some food on Ko-fi

These mods are free and open source. If one of them made your day a little better, a coffee on Ko-fi is appreciated and never expected.

Source 3 files
hooks/register.tsx 113 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const turn = atom({ plugin: 'clawd-run', key: 'turn' } as const, null)
5const isPreview = atom({ plugin: 'clawd-run', key: 'isPreview' } as const, false)
6const spend = atom({ plugin: 'clawd-run', key: 'spend' } as const, { tokens: 0, usd: null })
7
8export const register: Register = (on, options) => {
9  const delay = Number(options.delaySeconds)
10  const delayMs = Math.max(0, Number.isFinite(delay) ? delay : 20) * 1000
11  // the session's cost when the running main turn began, and that turn
12  let usdAtStart: number | null = null
13  let runningTurn: string | null = null
14
15  on('session.start', async ($, e, next) => {
16    await $.command.register({
17      name: 'clawd-run',
18      description: 'Show or hide Clawd\'s game above the prompt right now',
19    })
20
21    return next(e)
22  })
23
24  on('command.run', { command: 'clawd-run' }, async $ => {
25    const shown = await update($, isPreview, was => !was)
26
27    return { text: shown ? 'Clawd is playing.' : 'Clawd went back to watching.' }
28  })
29
30  on('turn.start', async ($, e, next) => {
31    if (e.agentId === undefined) {
32      const turnId = e.turnId
33      runningTurn = turnId
34      // no figure (no ledger, or no answer): the tally shows tokens alone
35      usdAtStart = await $.session.usage().then(
36        now => now.cost?.usd ?? null,
37        () => null,
38      )
39      await update($, spend, () => ({ tokens: 0, usd: usdAtStart === null ? null : 0 }))
40      await update($, turn, () => ({ turnId, isLong: false }))
41      $.clock.after(delayMs, () => {
42        void update($, turn, now => (now?.turnId === turnId ? { turnId, isLong: true } : now))
43      })
44    }
45
46    return next(e)
47  })
48
49  // every response in the running turn, its subagents' included, adds its
50  // tokens; the dollars are the session's cost since the turn began
51  on('turn.step', async function* ($, e, next) {
52    const response = yield* next(e)
53    const usage = response.usage
54    if (runningTurn !== null && usage) {
55      const tokens =
56        usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
57      const usd = await $.session.usage().then(
58        now => now.cost?.usd,
59        () => undefined,
60      )
61      const since = usd === undefined || usdAtStart === null ? null : Math.max(0, usd - usdAtStart)
62      await update($, spend, was => ({ tokens: was.tokens + tokens, usd: since }))
63    }
64
65    return response
66  })
67
68  on('turn.complete', async ($, e, next) => {
69    const turnId = e.turnId
70    if (runningTurn === turnId) runningTurn = null
71    await update($, turn, now => (now?.turnId === turnId ? null : now))
72
73    return next(e)
74  })
75
76  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77    const now = await read($, turn)
78    const used = await read($, spend)
79    const isLong = e.props.isWorking && now?.isLong === true
80    const isShown = isLong || (await read($, isPreview))
81    const rows = Math.min(11, e.props.maxRows - 3)
82
83    if (e.props.hasSurvey || !isShown || rows < 7 || e.surface === 'mobile' || e.surface === 'vscode') {
84      return next(e)
85    }
86
87    const { Box, Client } = $.ui.resolve(e)
88    // Another plugin's band (a meter row) may sit beneath: keep it under the
89    // game, by the prompt, rather than drawing in its place.
90    const below = await next(e)
91
92    return (
93      <Box key="clawd-run-stack" flexDirection="column">
94        <Client
95          key="clawd-run"
96          module="./game.tsx"
97          props={{
98            rows,
99            columns: e.props.bodyColumns,
100            isWorking: e.props.isWorking,
101            surface: e.surface,
102            tokens: used.tokens,
103            usd: used.usd,
104          }}
105          height={rows}
106          width="100%"
107        />
108        {below}
109      </Box>
110    )
111  })
112}
113
hooks/game.tsx 661 lines
1import type { ClientModule, ClientSurface, JsonValue, RenderElement } from 'claude-code'
2
3// An original side-scroller Clawd plays by itself: pipes, gaps, bugs to stomp
4// and coins, generated as the camera moves. Runs on the surface's frame clock.
5
6type Props = {
7  rows: number
8  columns: number
9  isWorking: boolean
10  surface: string
11  // what the running turn has used
12  tokens: number
13  usd: number | null
14}
15
16type Bug = { x: number; dir: number; squashedFor: number }
17type Coin = { x: number; y: number; isTaken: boolean }
18type Spark = { x: number; y: number; ttl: number; text: string }
19
20type Game = {
21  seed: number
22  // world, per column: is there ground, how tall a pipe stands there
23  ground: boolean[]
24  pipe: number[]
25  // which of a pipe's four columns this is
26  pipeIx: number[]
27  builtTo: number
28  bugs: Bug[]
29  coins: Coin[]
30  sparks: Spark[]
31  // Clawd
32  x: number
33  y: number
34  vy: number
35  isOnGround: boolean
36  blinkFor: number
37  // tally
38  score: number
39  coinsTaken: number
40  stomps: number
41  falls: number
42  ticks: number
43  // the player's hand: ticks the last arrow still holds, its direction, a
44  // jump asked for, and ticks until Clawd plays by itself again
45  holdFor: number
46  dir: number
47  wantsUp: boolean
48  manualFor: number
49}
50
51// `view` is the latest size, which the timer reads; the drawing updates it in place.
52type View = { rows: number; columns: number }
53type State = { game: Game; view: View; frame: number }
54
55const TICK_MS = 50
56const SPEED = 0.5
57const GRAVITY = 0.17
58const JUMP = 1.3
59const SCREEN_X = 10
60// how long without an arrow before the autopilot plays again: 6 s
61const IDLE_TICKS = 120
62
63// Clawd in pixels, designed by Codex after Claude Desktop's own sprite: 12 by
64// 8, O body, s shade, E eyes. Each text row draws two pixel rows (half blocks),
65// so Clawd takes 12 cells by 4 rows. RUN is a four-frame cycle.
66const STAND = [
67  '..OOOOOOOO..',
68  '..OEOOOOEO..',
69  'OOOOOOOOOOOO',
70  'OOOOOOOOOOOO',
71  '..OOOOOOOO..',
72  '..OOOOOOOO..',
73  '..O.O..O.O..',
74  '..O.O..O.O..',
75]
76const RUN = [
77  [
78    '..OOOOOOOO..',
79    '..OEOOOOEO..',
80    'OOOOOOOOOOOO',
81    'OOOOOOOOOOOO',
82    '..OOOOOOOO..',
83    '..OOOOOOOO..',
84    '..O.O..s.O..',
85    '.OO.O..s.OO.',
86  ],
87  [
88    '..OOOOOOOO..',
89    '..OEOOOOEO..',
90    'OOOOOOOOOOOO',
91    'OOOOOOOOOOOO',
92    '..OOOOOOOO..',
93    '..OOOOOOOO..',
94    '...Os..Os...',
95    '...O...O....',
96  ],
97  [
98    '..OOOOOOOO..',
99    '..OEOOOOEO..',
100    'OOOOOOOOOOOO',
101    'OOOOOOOOOOOO',
102    '..OOOOOOOO..',
103    '..OOOOOOOO..',
104    '..s.O..O.s..',
105    '..s.O..O.ss.',
106  ],
107  [
108    '..OOOOOOOO..',
109    '..OEOOOOEO..',
110    'OOOOOOOOOOOO',
111    'OOOOOOOOOOOO',
112    '..OOOOOOOO..',
113    '..OOOOOOOO..',
114    '...O.s.sO...',
115    '...O....O...',
116  ],
117]
118const LEAP = [
119  '...O....O...',
120  '..OOO..OOO..',
121  '..OOOOOOOO..',
122  '..OEOOOOEO..',
123  '..OOOOOOOO..',
124  '..OOOOOOOO..',
125  '..O.O..O.O..',
126  '............',
127]
128const W = 12
129const H = 4
130// the cells that collide: the body less its arms
131const HIT_L = 2
132const HIT_R = 10
133
134const ORANGE = '#D97757'
135const SHADE = '#BF694D'
136const EYE = '#141413'
137const PIXEL: Record<string, string> = { O: ORANGE, s: SHADE, E: EYE }
138const BRICK = '#C0582E'
139const SOIL = '#7A3B1A'
140const PIPE = '#3FA34D'
141const PIPE_DARK = '#2B7A37'
142const GOLD = '#F2C230'
143const BUG = '#8E5BD6'
144const CLOUD = '#9AA4B2'
145
146function rand(game: Game): number {
147  game.seed = (game.seed * 1103515245 + 12345) % 2147483648
148  return game.seed / 2147483648
149}
150
151function groundRow(rows: number): number {
152  return rows - 2
153}
154
155// Builds the world out to `to`: flat runs broken by a gap, a pipe, bugs, coins.
156function build(game: Game, rows: number, to: number): void {
157  const top = groundRow(rows)
158  while (game.builtTo < to) {
159    const start = game.builtTo
160    const flat = 10 + Math.floor(rand(game) * 14)
161    for (let c = start; c < start + flat; c++) {
162      game.ground[c] = true
163      game.pipe[c] = 0
164    }
165    if (rand(game) < 0.55) {
166      const at = start + 3 + Math.floor(rand(game) * (flat - 6))
167      game.bugs.push({ x: at, dir: -1, squashedFor: 0 })
168    }
169    if (rand(game) < 0.6) {
170      const n = 3 + Math.floor(rand(game) * 4)
171      const y = top - H - 1 - Math.floor(rand(game) * 2)
172      for (let i = 0; i < n; i++) game.coins.push({ x: start + 2 + i * 2, y, isTaken: false })
173    }
174    let c = start + flat
175    const pick = rand(game)
176    if (pick < 0.4) {
177      const gap = 4 + Math.floor(rand(game) * 5)
178      for (let i = 0; i < gap; i++, c++) {
179        game.ground[c] = false
180        game.pipe[c] = 0
181      }
182      // coins over the gap, along the jump
183      game.coins.push({ x: c - Math.ceil(gap / 2), y: top - H - 3, isTaken: false })
184    } else if (pick < 0.85) {
185      const tall = Math.min(rows - 8, 2 + Math.floor(rand(game) * 2))
186      for (let i = 0; i < 4; i++, c++) {
187        game.ground[c] = true
188        game.pipe[c] = tall
189        game.pipeIx[c] = i
190      }
191    }
192    game.builtTo = c
193  }
194}
195
196function isSolid(game: Game, rows: number, col: number, row: number): boolean {
197  if (col < 0) return true
198  // the tally's row is a ceiling
199  if (row < 1) return true
200  const top = groundRow(rows)
201  if (row >= top) return game.ground[col] === true
202  const tall = game.pipe[col] ?? 0
203  return tall > 0 && row >= top - tall
204}
205
206function hits(game: Game, rows: number, x: number, y: number): boolean {
207  const c0 = Math.floor(x + HIT_L + 0.001)
208  const c1 = Math.ceil(x + HIT_R - 0.001) - 1
209  const r0 = Math.floor(y + 0.001)
210  const r1 = Math.ceil(y + H - 0.001) - 1
211  for (let c = c0; c <= c1; c++) {
212    for (let r = r0; r <= r1; r++) {
213      if (isSolid(game, rows, c, r)) return true
214    }
215  }
216  return false
217}
218
219function newGame(rows: number): Game {
220  const game: Game = {
221    seed: 7 + Math.floor(Math.random() * 100000),
222    ground: [],
223    pipe: [],
224    pipeIx: [],
225    builtTo: 0,
226    bugs: [],
227    coins: [],
228    sparks: [],
229    x: 2,
230    y: groundRow(rows) - H,
231    vy: 0,
232    isOnGround: true,
233    blinkFor: 0,
234    score: 0,
235    coinsTaken: 0,
236    stomps: 0,
237    falls: 0,
238    ticks: 0,
239    holdFor: 0,
240    dir: 1,
241    wantsUp: false,
242    manualFor: 0,
243  }
244  // a safe runway to start on
245  for (let c = 0; c < 24; c++) {
246    game.ground[c] = true
247    game.pipe[c] = 0
248  }
249  game.builtTo = 24
250  return game
251}
252
253type Body = { x: number; y: number; vy: number; isOnGround: boolean }
254
255// One tick of Clawd's physics; the game and the look-ahead both run it.
256function step(game: Game, rows: number, body: Body, jump: boolean, dx = SPEED): void {
257  if (jump && body.isOnGround) {
258    body.vy = -JUMP
259    body.isOnGround = false
260  }
261  const nx = body.x + dx
262  if (!hits(game, rows, nx, body.y)) body.x = nx
263  body.vy = Math.min(body.vy + GRAVITY, 1.2)
264  const ny = body.y + body.vy
265  if (!hits(game, rows, body.x, ny)) {
266    body.y = ny
267    body.isOnGround = false
268  } else {
269    if (body.vy > 0) {
270      body.y = Math.floor(ny + H) - H
271      while (hits(game, rows, body.x, body.y) && body.y > 0) body.y -= 1
272      body.isOnGround = true
273    }
274    body.vy = 0
275  }
276}
277
278type Outcome = 'ok' | 'fall' | 'bug' | 'wall'
279
280// Plays the next ticks ahead: walking on, or jumping now and until landing.
281function foresee(game: Game, rows: number, jump: boolean, steps: number): Outcome {
282  const top = groundRow(rows)
283  const body: Body = { x: game.x, y: game.y, vy: game.vy, isOnGround: game.isOnGround }
284  for (let i = 1; i <= steps; i++) {
285    const was = body.x
286    step(game, rows, body, jump && i === 1)
287    if (body.y + H > top + 0.5) return 'fall'
288    if (body.x === was && body.isOnGround) return 'wall'
289    const feet = body.y + H
290    for (const bug of game.bugs) {
291      if (bug.squashedFor > 0) continue
292      const bx = bug.x + 0.15 * bug.dir * i
293      const overlapsX = bx + 2 > body.x + HIT_L && bx < body.x + HIT_R
294      if (overlapsX && feet > top - 0.5 && !(body.vy > 0 && feet <= top + 0.4)) return 'bug'
295    }
296    if (jump && i > 1 && body.isOnGround) return 'ok'
297  }
298  return 'ok'
299}
300
301// Should Clawd jump now? Jumps when walking on goes wrong and a jump lands.
302function wantsJump(game: Game, rows: number): boolean {
303  const walk = foresee(game, rows, false, 10)
304  if (walk === 'wall') return true
305  const leap = foresee(game, rows, true, 60)
306  if (walk !== 'ok') {
307    // the last moment: jump even onto a worse landing rather than walk in
308    return leap === 'ok' || foresee(game, rows, false, 2) !== 'ok'
309  }
310  // now and then a hop for coins overhead, when it lands safely
311  const front = game.x + HIT_R
312  const top = groundRow(rows)
313  return (
314    game.ticks % 3 === 0 &&
315    leap === 'ok' &&
316    game.coins.some(coin => !coin.isTaken && coin.y < top - H && coin.x >= front - 4 && coin.x < front)
317  )
318}
319
320function tick(game: Game, rows: number, columns: number): void {
321  game.ticks += 1
322  build(game, rows, Math.floor(game.x) + columns + 40)
323  const top = groundRow(rows)
324
325  if (game.manualFor > 0) {
326    game.manualFor -= 1
327    const dx = game.holdFor > 0 ? SPEED * game.dir : 0
328    if (game.holdFor > 0) game.holdFor -= 1
329    step(game, rows, game, game.wantsUp, dx)
330    game.wantsUp = false
331  } else {
332    step(game, rows, game, game.isOnGround && wantsJump(game, rows))
333  }
334
335  // fell into a gap: back on the next ground ahead
336  if (game.y > rows) {
337    game.falls += 1
338    let c = Math.floor(game.x) + 1
339    while (!(game.ground[c] && game.ground[c + W] && !game.pipe[c] && !game.pipe[c + W])) c += 1
340    game.x = c
341    game.y = top - H - 3
342    game.vy = 0
343    game.blinkFor = 30
344    game.sparks.push({ x: c, y: top - H - 4, ttl: 20, text: 'oops!' })
345  }
346
347  // bugs walk, turn at gaps and pipes, get stomped from above
348  for (const bug of game.bugs) {
349    if (bug.squashedFor > 0) {
350      bug.squashedFor -= 1
351      continue
352    }
353    const ahead = Math.floor(bug.x + (bug.dir < 0 ? -0.2 : 2.2))
354    if (!game.ground[ahead] || (game.pipe[ahead] ?? 0) > 0) bug.dir = -bug.dir
355    else bug.x += 0.15 * bug.dir
356    const overlapsX = bug.x + 2 > game.x + HIT_L && bug.x < game.x + HIT_R
357    const feet = game.y + H
358    if (overlapsX && feet > top - 1 && feet <= top + 0.4 && game.vy > 0) {
359      bug.squashedFor = 12
360      game.vy = -1.1
361      game.isOnGround = false
362      game.stomps += 1
363      game.score += 100
364      game.sparks.push({ x: bug.x, y: top - 2, ttl: 14, text: '+100' })
365    } else if (overlapsX && feet > top - 0.5 && game.blinkFor === 0) {
366      game.blinkFor = 30
367      game.sparks.push({ x: game.x + 2, y: game.y - 1, ttl: 14, text: 'ouch' })
368    }
369  }
370
371  for (const coin of game.coins) {
372    if (coin.isTaken) continue
373    if (coin.x >= game.x + HIT_L && coin.x < game.x + HIT_R && coin.y >= game.y - 0.5 && coin.y < game.y + H + 0.5) {
374      coin.isTaken = true
375      game.coinsTaken += 1
376      game.score += 10
377    }
378  }
379
380  if (game.blinkFor > 0) game.blinkFor -= 1
381  for (const spark of game.sparks) {
382    spark.ttl -= 1
383    spark.y -= 0.1
384  }
385  game.score += game.ticks % 10 === 0 ? 1 : 0
386
387  // forget what scrolled away
388  const gone = Math.floor(game.x) - SCREEN_X - 10
389  game.bugs = game.bugs.filter(bug => bug.x > gone && !(bug.squashedFor === 1))
390  game.coins = game.coins.filter(coin => coin.x > gone && !coin.isTaken)
391  game.sparks = game.sparks.filter(spark => spark.ttl > 0)
392}
393
394type Cell = { ch: string; color?: string; bg?: string }
395
396function formatTokens(n: number): string {
397  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 1 : 2)}M`
398  if (n >= 1000) return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`
399  return String(n)
400}
401
402function formatUsd(usd: number): string {
403  return usd < 1 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`
404}
405
406function paint(game: Game, rows: number, columns: number, props: Props): Cell[][] {
407  const isWorking = props.isWorking
408  const grid: Cell[][] = []
409  for (let r = 0; r < rows; r++) {
410    const line: Cell[] = []
411    for (let c = 0; c < columns; c++) line.push({ ch: ' ' })
412    grid.push(line)
413  }
414  const put = (col: number, row: number, ch: string, color?: string) => {
415    const line = grid[row]
416    if (!line || col < 0 || col >= columns || ch === ' ') return
417    line[col] = { ch, color }
418  }
419  const text = (col: number, row: number, s: string, color?: string) => {
420    for (let i = 0; i < s.length; i++) put(col + i, row, s.charAt(i), color)
421  }
422
423  const cam = Math.floor(game.x) - SCREEN_X
424  const top = groundRow(rows)
425
426  // clouds at half speed
427  const cloud = '▁▃▅▆▅▃▁'
428  for (let k = -1; k < columns / 22 + 2; k++) {
429    const base = Math.floor(cam / 2 / 22) + k
430    const col = base * 22 - Math.floor(cam / 2) + ((base * 7) % 11)
431    text(col, 1 + (Math.abs(base) % 2), cloud, CLOUD)
432  }
433
434  for (let sc = 0; sc < columns; sc++) {
435    const c = cam + sc
436    if (game.ground[c]) {
437      put(sc, top, c % 4 === 0 ? '▙' : '▄', BRICK)
438      put(sc, top + 1, c % 4 === 2 ? '▓' : '█', SOIL)
439    }
440    const tall = game.pipe[c] ?? 0
441    if (tall > 0) {
442      const i = game.pipeIx[c] ?? 0
443      for (let r = top - tall; r < top; r++) {
444        const isCap = r === top - tall
445        const ch = isCap ? '█' : i === 0 ? '▐' : i === 3 ? '▌' : '█'
446        put(sc, r, ch, isCap || i === 1 ? PIPE : PIPE_DARK)
447      }
448    }
449  }
450
451  const spin = ['●', '◐', '│', '◑']
452  for (const coin of game.coins) {
453    put(Math.floor(coin.x) - cam, Math.round(coin.y), spin[(game.ticks >> 2) % 4] ?? '●', GOLD)
454  }
455
456  for (const bug of game.bugs) {
457    const col = Math.floor(bug.x) - cam
458    if (bug.squashedFor > 0) text(col, top - 1, '▁▁', BUG)
459    else text(col, top - 1, (game.ticks >> 2) % 2 === 0 ? '▟▙' : '▙▟', BUG)
460  }
461
462  const isHidden = game.blinkFor > 0 && (game.blinkFor >> 1) % 2 === 0
463  if (!isHidden) {
464    const col = Math.floor(game.x) - cam
465    const row = Math.round(game.y)
466    const art = game.isOnGround ? (RUN[(game.ticks >> 1) % RUN.length] ?? STAND) : LEAP
467    // two pixel rows to a cell: the top as the glyph's colour, the bottom
468    // as the cell's background when both are drawn
469    for (let i = 0; i < H; i++) {
470      const top = art[2 * i] ?? ''
471      const bottom = art[2 * i + 1] ?? ''
472      for (let x = 0; x < W; x++) {
473        const up = PIXEL[top.charAt(x)]
474        const down = PIXEL[bottom.charAt(x)]
475        const line = grid[row + i]
476        if (!line || col + x < 0 || col + x >= columns) continue
477        if (up && down) line[col + x] = up === down ? { ch: '█', color: up } : { ch: '▀', color: up, bg: down }
478        else if (up) line[col + x] = { ch: '▀', color: up }
479        else if (down) line[col + x] = { ch: '▄', color: down }
480      }
481    }
482  }
483
484  for (const spark of game.sparks) {
485    text(Math.floor(spark.x) - cam, Math.round(spark.y), spark.text, GOLD)
486  }
487
488  // the tally on the top row
489  const seconds = Math.floor((game.ticks * TICK_MS) / 1000)
490  const clock = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
491  // this turn's tokens and their cost, then how long Clawd has been running
492  const cost = props.usd === null ? '' : `  ${formatUsd(props.usd)}`
493  const hud = `${formatTokens(props.tokens)} tokens${cost}  ⏱ ${clock}`
494  text(1, 0, hud, ORANGE)
495  const note =
496    game.manualFor > 0
497      ? '← → run · ↑ / space jump'
498      : isWorking
499        ? 'Claude is working… · click to play'
500        : 'preview · click to play · /clawd-run hides'
501  text(columns - note.length - 1, 0, note)
502
503  return grid
504}
505
506const draw: ClientModule<JsonValue, State> = (raw, surface: ClientSurface<State>) => {
507  const props = raw as unknown as Props
508  const { Box, Text } = surface.elements
509  const rows = Math.max(7, props.rows)
510  const columns = Math.max(30, surface.columns || props.columns)
511
512  let state = surface.state
513  if (state === undefined) {
514    const first: State = { game: newGame(rows), view: { rows, columns }, frame: 0 }
515    state = first
516    surface.setState(first)
517    // arrows take over from the autopilot; idle a while and it plays again.
518    // Terminals send no key-up, so a press holds the run for a few ticks and
519    // the key's repeat keeps it going.
520    surface.onKey(event => {
521      const game = (surface.state ?? first).game
522      const key = event.key
523      if (key === 'left' || key === 'a' || key === 'right' || key === 'd') {
524        game.dir = key === 'left' || key === 'a' ? -1 : 1
525        game.holdFor = 7
526      } else if (key === 'up' || key === ' ' || key === 'w') {
527        game.wantsUp = true
528      } else {
529        return
530      }
531      game.manualFor = IDLE_TICKS
532    })
533    surface.every(TICK_MS, () => {
534      const now = surface.state ?? first
535      tick(now.game, now.view.rows, now.view.columns)
536      surface.setState({ ...now, frame: now.frame + 1 })
537    })
538  }
539  state.view.rows = rows
540  state.view.columns = columns
541
542  const grid = paint(state.game, rows, columns, props)
543
544  return props.surface === 'desktop' ? drawPixels(grid, surface) : drawGlyphs(grid, surface)
545}
546
547// The terminal: each cell a glyph, half blocks for Clawd's pixels.
548function drawGlyphs(grid: Cell[][], surface: ClientSurface<State>): RenderElement {
549  const { Box, Text } = surface.elements
550
551  return (
552    <Box flexDirection="column">
553      {grid.map((line, r) => {
554        const runs: { s: string; color?: string; bg?: string }[] = []
555        for (const cell of line) {
556          const last = runs[runs.length - 1]
557          // a blank takes no colour: it joins the run before it
558          if (last && ((last.color === cell.color && last.bg === cell.bg) || (cell.ch === ' ' && !last.bg))) last.s += cell.ch
559          else runs.push({ s: cell.ch, color: cell.color, bg: cell.bg })
560        }
561        return (
562          <Box key={`r${r}`} flexDirection="row">
563            {runs.map(run =>
564              run.color ? (
565                <Text mono color={run.color} backgroundColor={run.bg}>
566                  {run.s}
567                </Text>
568              ) : (
569                <Text mono>{run.s}</Text>
570              ),
571            )}
572          </Box>
573        )
574      })}
575    </Box>
576  )
577}
578
579// What a glyph fills of its cell, as two pixels: the top half, the bottom half.
580const FILL: Record<string, 'top' | 'bottom' | 'both'> = {
581  '█': 'both', '▓': 'both', '▐': 'both', '▌': 'both', '▙': 'both', '▟': 'both',
582  '●': 'both', '◐': 'both', '◑': 'both', '│': 'both', '▆': 'both', '▅': 'both',
583  '▀': 'top', '▄': 'bottom', '▃': 'bottom', '▁': 'bottom', '▂': 'bottom',
584}
585
586type Span = { n: number; color?: string }
587
588function spans(colors: (string | undefined)[]): Span[] {
589  const out: Span[] = []
590  for (const color of colors) {
591    const last = out[out.length - 1]
592    if (last && last.color === color) last.n += 1
593    else out.push({ n: 1, color })
594  }
595  return out
596}
597
598// The desktop: glyphs there leave gaps between rows and differ in width, so
599// the scene is drawn as coloured boxes, each row two pixel rows high; words
600// (the tally, the sparks) stay text.
601function drawPixels(grid: Cell[][], surface: ClientSurface<State>): RenderElement {
602  const { Box, Text } = surface.elements
603
604  return (
605    <Box flexDirection="column">
606      {grid.map((line, r) => {
607        // runs of pixel cells and runs of words, in order
608        const parts: ({ kind: 'px'; cells: Cell[] } | { kind: 'text'; s: string; color?: string })[] = []
609        for (const cell of line) {
610          const last = parts[parts.length - 1]
611          // a blank after words stays with them, so a phrase is one text
612          // the tally's row is all words
613          const isPixel = r > 0 && (cell.ch === ' ' ? last?.kind !== 'text' : FILL[cell.ch] !== undefined)
614          if (isPixel) {
615            if (last?.kind === 'px') last.cells.push(cell)
616            else parts.push({ kind: 'px', cells: [cell] })
617          } else if (last?.kind === 'text' && (last.color === cell.color || cell.ch === ' ')) last.s += cell.ch
618          else parts.push({ kind: 'text', s: cell.ch, color: cell.color })
619        }
620        return (
621          <Box key={`r${r}`} flexDirection="row" height={1}>
622            {parts.map(part => {
623              if (part.kind === 'text') {
624                // as wide as its cells, whatever the font's advance
625                return (
626                  <Box width={part.s.length} height={1} overflow="hidden">
627                    <Text mono color={part.color} wrap="truncate">
628                      {part.s}
629                    </Text>
630                  </Box>
631                )
632              }
633              const top = part.cells.map(cell => {
634                const fill = FILL[cell.ch]
635                return fill === 'both' || fill === 'top' ? cell.color : fill === 'bottom' ? cell.bg : undefined
636              })
637              const bottom = part.cells.map(cell => {
638                const fill = FILL[cell.ch]
639                return fill === 'both' || fill === 'bottom' ? cell.color : fill === 'top' ? cell.bg : undefined
640              })
641              return (
642                <Box flexDirection="column" width={part.cells.length} height={1}>
643                  {[top, bottom].map(half => (
644                    <Box flexDirection="row" height="50%">
645                      {spans(half).map(span => (
646                        <Box width={span.n} height="100%" backgroundColor={span.color} />
647                      ))}
648                    </Box>
649                  ))}
650                </Box>
651              )
652            })}
653          </Box>
654        )
655      })}
656    </Box>
657  )
658}
659
660export default draw
661
types/index.d.ts 11 lines
1export type LongTurn = { turnId: string; isLong: boolean }
2// what the running turn has used: every response's tokens, and dollars when
3// the host keeps a cost ledger
4export type Spend = { tokens: number; usd: number | null }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'clawd-run': { turn: LongTurn | null; isPreview: boolean; spend: Spend }
9  }
10}
11