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

Mods for Claude Code (CLI and the Desktop app's Code tab), each in its own folder under mods/.
| Mod | What it does |
|---|---|
| pillbar | A 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-run | While Claude works on a long turn, Clawd plays a side-scrolling platformer above the prompt, with the turn's tokens and cost. |
| token-tank | A 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-narrator | A 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.

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.
Each mod is self-contained. From a mod's folder:
claude plugin test .
claude plugin validate .
/narrator).~/.claude; it reads the language row of your settings to pick its words.PILLBAR_JETDECK_DIR names it.~/.claude, downloads code, or runs a shell.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.
hooks/register.tsx 113 lines1import { 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}
113hooks/game.tsx 661 lines1import 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
661types/index.d.ts 11 lines1export 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