SLOPSHOPPER

pond-place

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

newbandtimer
A shopper browsing a rack in a slop shop
README

pond-place

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):

  • signs on at session start with $.comic.addPlace
  • draws the band only while the comic's here is pond-place and its state is idle
  • tells the comic it is alive every second while it draws
  • draws signs to its neighbours ($.comic.route()) and follows them on a/d ($.comic.step)
  • has Claude walk in from the side he came from (cameFrom)
  • draws with the comic's own art ($.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.

Source 3 files
hooks/register.tsx 90 lines
1// 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}
90
hooks/pond.ts 65 lines
1// 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}
65
hooks/paint.ts 76 lines
1// 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