An example place for agent-comic: between turns Claude goes fishing at a little pond

An example place for agent-comic: between turns, Claude can walk over to a little pond and fish. A bite now and then makes him light up.
It shows everything a place does, in about 100 lines (hooks/register.tsx):
$.comic.addPlacehere is pond-place and its state is idle$.comic.route()) and follows them on a/d ($.comic.step)cameFrom)$.comic.kit()) and a small painter (hooks/paint.ts)hooks/pond.test.ts tests it against a stand-in comic. Run claude plugin test . here.
Without agent-comic the pond stays out of the way.
hooks/register.tsx 90 lines1// An example place for agent-comic: a pond where Claude goes fishing between turns.
2// It shows everything a place does (EXTENDING.md in agent-comic says why):
3// 1. signs on at session start, with $.comic.addPlace
4// 2. draws the band only while the comic's `here` is this place and its `state` is idle
5// 3. says it is alive each second while it draws, or Claude is sent home
6// 4. draws signs to its neighbours (route) and follows them (step) on a/d
7// 5. lets Claude walk in from the side he came from (cameFrom)
8// Without agent-comic it stays out of the way.
9
10import type { Register } from 'claude-code'
11
12import { draw } from './pond'
13import type { Arrival, Kit, Stop } from './pond'
14
15const PLACE = { id: 'pond-place', name: 'Pond' }
16const KEY = 'pond'
17const LEFT = 'pond-left'
18const RIGHT = 'pond-right'
19const FRAME_MS = 100
20const MIN_ROWS = 6
21const MAX_ROWS = 10
22
23let kit: Kit | null = null // the comic's art, fetched once it is there
24let mount: { requestId: string; columns: number; rows: number } | null = null
25let signs: { left: Stop | null; right: Stop | null } = { left: null, right: null }
26let arrival: Arrival | null = null
27let isBlitting = false
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 try {
32 await $.comic.addPlace(PLACE)
33 kit = await $.comic.kit()
34 } catch {
35 kit = null // no comic: no world to be a place in
36 }
37 $.clock.every(FRAME_MS, async () => {
38 if (!mount || isBlitting || !kit) return
39 const { requestId, columns, rows } = mount
40 isBlitting = true
41 await $.ui.blit({ requestId, key: KEY, cells: draw(kit, columns, rows, await $.clock.now(), arrival, signs) }).catch(() => undefined)
42 isBlitting = false
43 })
44 // while it draws, the pond tells the comic so; silent, Claude would be sent home
45 $.clock.every(1000, async () => {
46 if (mount) await $.comic.alive(PLACE).catch(() => undefined)
47 })
48 return next(e)
49 })
50
51 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
52 // reading the comic's state here redraws the band whenever it changes
53 const here = (await $.state.get({ plugin: 'agent-comic', key: 'here' })).value
54 const state = (await $.state.get({ plugin: 'agent-comic', key: 'state' })).value
55 await $.state.get({ plugin: 'agent-comic', key: 'places' }) // and when places come and go
56 const rows = Math.min(MAX_ROWS, e.props.maxRows - 1)
57 const isMine = here === PLACE.id && state === 'idle' && !e.props.isWorking
58 if (!isMine || !kit || e.props.hasSurvey || rows < MIN_ROWS || e.surface !== 'terminal') {
59 mount = null
60 arrival = null
61 return next(e)
62 }
63 const now = await $.clock.now()
64 if (!arrival) arrival = { from: (await $.state.get({ plugin: 'agent-comic', key: 'cameFrom' })).value ?? null, at: now }
65 signs = await $.comic.route()
66 const columns = Math.max(20, Math.min(512, e.props.bodyColumns - 5))
67 mount = { requestId: e.requestId, columns, rows }
68 const { Box, Button, Raster } = $.ui.resolve(e)
69 return (
70 <Box flexDirection="row">
71 <Raster key={KEY} columns={columns} rows={rows} cells={draw(kit, columns, rows, now, arrival, signs)} />
72 <Box flexDirection="column" paddingLeft={1}>
73 {signs.left ? <Button key={LEFT} plain hotkey="a" onPress={() => undefined}>◂</Button> : null}
74 {signs.right ? <Button key={RIGHT} plain hotkey="d" onPress={() => undefined}>▸</Button> : null}
75 </Box>
76 </Box>
77 )
78 })
79
80 on('ui.press', { element: LEFT }, async $ => {
81 await $.comic.step({ dir: -1 })
82 return { element: LEFT }
83 })
84
85 on('ui.press', { element: RIGHT }, async $ => {
86 await $.comic.step({ dir: 1 })
87 return { element: RIGHT }
88 })
89}
90hooks/pond.ts 65 lines1// The pond as pixels: grass, water, Claude fishing (walking in first), and the signs.
2
3import type { EngineInterface } from 'claude-code'
4
5import { Paint } from './paint'
6
7export type Kit = Awaited<ReturnType<EngineInterface['comic']['kit']>>
8export type Stop = { id: string | null; name: string }
9/** Where Claude came in from, and when: he walks from that edge to his spot. */
10export type Arrival = { from: -1 | 1 | null; at: number }
11
12const WALK_PX_PER_SEC = 14
13
14/** The pond at time `now`: grass, water, Claude fishing (walking in first), the signs. */
15export function draw(art: Kit, columns: number, rows: number, now: number, arrival: Arrival | null, signs: { left: Stop | null; right: Stop | null }): string {
16 const p = new Paint(columns, rows)
17 const P = art.palette
18 const H = rows * 2
19 const ground = H - 2
20
21 p.rect(0, ground, columns, 2, P.moss!)
22 for (let x = 0; x < columns; x += 3) p.set(x, ground, P.leaf!)
23
24 const pondLeft = Math.floor(columns * 0.55)
25 const pondRight = columns - 6
26 p.rect(pondLeft, ground - 1, pondRight - pondLeft, 3, P.navy!)
27 p.rect(pondLeft + 1, ground - 1, pondRight - pondLeft - 2, 1, P.sky!)
28 const glint = pondLeft + 2 + (Math.floor(now / 300) % Math.max(1, pondRight - pondLeft - 4))
29 p.set(glint, ground - 1, P.white!)
30
31 // Claude: in from the side he came from, then on his spot at the water's edge
32 const spot = pondLeft - 13
33 const elapsed = arrival ? (now - arrival.at) / 1000 : Infinity
34 const start = arrival?.from === -1 ? -11 : arrival?.from === 1 ? columns : spot
35 const dist = spot - start
36 const travelled = Math.min(Math.abs(dist), elapsed * WALK_PX_PER_SEC)
37 const x = Math.round(start + Math.sign(dist) * travelled)
38 const isWalking = travelled < Math.abs(dist)
39 const c = art.claude
40 // a bite every eight seconds or so: he lights up for a moment
41 const isBite = !isWalking && now % 8000 < 1500
42 const frame = isWalking ? c.walk[Math.floor(now / 120) % 4]! : isBite ? c.happy : now % 4000 < 150 ? c.blink : c.stand
43 const top = ground - frame.length
44 p.sprite(x, top, frame, c.palette, isWalking && dist < 0)
45 if (!isWalking) {
46 // the rod, the line, and the bobber riding the ripples
47 for (let i = 0; i < 7; i++) p.set(x + 10 + i, top + 2 - Math.floor(i / 2), P.wood!)
48 const tipX = x + 16
49 const tipY = top - 1
50 const bob = isBite ? 1 : Math.floor(now / 600) % 2
51 for (let y = tipY + 1; y < ground - 1 + bob; y++) p.set(tipX, y, P.silver!)
52 p.set(tipX, ground - 2 + bob, P.red!)
53 if (isBite) p.write(x + 5, Math.max(0, Math.floor(top / 2) - 1), '!', P.yellow!)
54 }
55
56 // the signs on to the neighbouring places, in the comic's colours
57 const sign = art.sign
58 if (signs.left) p.write(0, 1, ` ◂ ${signs.left.name} `, sign.fg, sign.bg)
59 if (signs.right) {
60 const label = ` ${signs.right.name} ▸ `
61 p.write(columns - [...label].length, 1, label, sign.fg, sign.bg)
62 }
63 return p.encode()
64}
65hooks/paint.ts 76 lines1// A small pixel painter for a Raster: two pixels to a cell (half blocks), and text on top.
2// The same cell format the comic draws with, so a place looks like part of the same band.
3
4export const NONE = 0x01000000 // the terminal's own colour: nothing painted
5
6export class Paint {
7 private readonly px: Uint32Array
8 private readonly text = new Map<number, { cp: number; fg: number; bg: number }>()
9
10 constructor(readonly columns: number, readonly rows: number) {
11 this.px = new Uint32Array(columns * rows * 2).fill(NONE)
12 }
13
14 set(x: number, y: number, color: number) {
15 x = Math.round(x)
16 y = Math.round(y)
17 if (x < 0 || y < 0 || x >= this.columns || y >= this.rows * 2) return
18 this.px[y * this.columns + x] = color
19 }
20
21 rect(x: number, y: number, w: number, h: number, color: number) {
22 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, color)
23 }
24
25 /** Rows of palette letters, '.' transparent; `flip` mirrors it (the kit's art faces right). */
26 sprite(x: number, y: number, rows: readonly string[], palette: Record<string, number>, flip = false) {
27 rows.forEach((row, j) => {
28 for (let i = 0; i < row.length; i++) {
29 const color = palette[row[flip ? row.length - 1 - i : i]!]
30 if (color !== undefined) this.set(x + i, y + j, color)
31 }
32 })
33 }
34
35 /** Text in cell `row`, one character to a cell. */
36 write(col: number, row: number, s: string, fg: number, bg = NONE) {
37 let i = 0
38 for (const ch of s) {
39 const c = col + i++
40 if (c >= 0 && c < this.columns && row >= 0 && row < this.rows) this.text.set(row * this.columns + c, { cp: ch.codePointAt(0)!, fg, bg })
41 }
42 }
43
44 /** A Raster's `cells`: base64 of [codePoint, fg, bg] u32 triplets, row by row. */
45 encode(): string {
46 const out = new Uint32Array(this.columns * this.rows * 3)
47 for (let r = 0; r < this.rows; r++) {
48 for (let c = 0; c < this.columns; c++) {
49 const cell = r * this.columns + c
50 const top = this.px[2 * r * this.columns + c]!
51 const bottom = this.px[(2 * r + 1) * this.columns + c]!
52 const t = this.text.get(cell)
53 const [cp, fg, bg] = t ? [t.cp, t.fg, t.bg === NONE ? bottom : t.bg]
54 : top === bottom ? [0x20, top, top]
55 : top === NONE ? [0x2584, bottom, NONE]
56 : [0x2580, top, bottom]
57 out.set([cp, fg, bg], cell * 3)
58 }
59 }
60 return base64(new Uint8Array(out.buffer))
61 }
62}
63
64const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
65
66function base64(bytes: Uint8Array): string {
67 let out = ''
68 for (let i = 0; i < bytes.length; i += 3) {
69 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
70 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
71 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
72 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
73 }
74 return out
75}
76