SLOPSHOPPER

idle-art

Draws an ASCII animation above the prompt while the model works: matrix rain, fire, an aquarium, a cat, or a GIF of your own imported as a character clip for…

newbandcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · idle-art
› 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 › /idle-art ⎿ idle-art: on · style random · shows 3s into a turn ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

idle-art

While you wait out a long turn, all that moves above the prompt is a spinner. This mod fills that space with an ASCII animation while the model works. Four scenes are built in: matrix rain, fire, an aquarium and a cat. You can add your own: /idle-art import turns a GIF into a character clip and keeps it for every project. Display only: nothing reaches the model, so the mod costs no tokens and does not touch the prompt cache.

What it shows

The picture appears 3 seconds into a turn, so a short turn shows nothing, and it goes away when the turn ends. By default each turn draws a scene at random from the built-in scenes and your saved clips, never the one shown before. A long turn moves on to another at random: a built-in scene after 20 seconds, the cat and a clip at the end of their first round or loop that ends after 20 seconds, so a long clip plays through once and a short one repeats until then. A scene you chose by name stays for the whole turn, and choosing one while a turn draws changes the picture at once.

● Brewing… (5s) .: . , , ::,,, ::;;:; ;;::; . , ;:+ :*;++++ , ,, :::*oO;*:;*:+: :+++,::o ++:, : ;,, ;,;+;+::+;,;+:+**,; ; , : +;*:+**o*;;;;*+**:,*:;;***o:,::;; :,::;oOoO*ooOOO#oO*o*O*o**OOOoOo;+;,

SceneWhat moves
matrixStreams of half-width katakana and digits fall in green, a bright head over a fading trail; glyphs under a trail flicker.
fireHeat rises from a hidden row under the band and cools on the way up, drawn from . to @ and from dark red to pale yellow.
aquariumA school of fish of four shapes crosses both ways, bubbles rise from them and from the sand and grow, tall seaweed sways on the sand, and the surface ripples on the top row.
catA garden: two kittens play back and forth on the grass, one on each side, a ball of yarn rolls at their feet, and a butterfly drifts over them. A big cat walks in slowly to the middle, head first, sits and blinks, and says meow; then its tricks in a new order each round: it rolls on the ground and back, jumps three times, purrs with a heart floating up, and looks left and right. It says MEOW!, walks out slowly to the right and comes round again. Each word stands in a speech bubble over its head. A round takes about 38 seconds, and random waits for the cat to walk out before it moves on.

● Brewing… (8s) .------. ( meow ) '------' / /\_/\ ( o.o )

^ <

(|)~

The band takes at most 8 rows, fewer when the terminal has less room, and the terminal's whole width; a clip stands in its middle. It draws nothing in a band under 3 rows. It draws on the terminal only, and gives way to a survey. The scenes draw about 60 frames a second, and each moves by the time that passed, so the rain, the fish and the cat glide a step on every frame. The fire's heat changes ten times a second, and a clip changes at its own frame delays.

Your own GIFs

/idle-art import ~/Downloads/kitty.gif kitty

The mod reads the GIF, decodes every frame with its own delay and transparency, and turns each frame into characters: every cell takes the mean colour of its pixels, rounded to six levels a channel so neighbouring cells share a colour, and a glyph from .:-=+*#%@ by its brightness, spread over the clip's own darkest to brightest cell. A cell mostly transparent stays blank. The picture keeps its shape and fills the 8 rows of the band, at most 100 columns wide; a cell counts as twice as tall as wide. The clip then plays in the middle of the band, each frame for its own delay, in a loop.

● Brewing… (6s) ....=*****++=+*+====-.... ....=******=----=*#=====-.... ....-+++++*==+----===---:.... ....:--=###++*=-:-=-:---:.... ....-+#%#%*--==-:-:-==--:.... ....+**#*++-:-:---::::--:.... ....-====+-=:--:::::.:-+-.... ....:-=*++*+++=-:-=--=-=-....

A clip is kept under 90,000 characters, because that is what one drawing may hand the drawing thread. A longer clip keeps every other frame, each kept frame showing for the time of both, and does so again until it fits; the answer says how many frames stayed. The clips live in the mod's store, which holds 4 MiB in all, so about forty clips fit; a clip that does not fit is refused with the reason.

A name is lowercase letters, digits and dashes, starts with a letter or digit, is up to 24 characters (a capital letter is lowered), and cannot be a built-in scene or a word the command reads. Importing under a saved name replaces that clip. A path starting with ~ is under your home directory, and a relative path is under the session's directory. A path may hold spaces: the last word is the name.

Command

/idle-art the state: on or off, the style, the delay /idle-art on | off draw or stop drawing /idle-art <scene or clip> always draw that one: matrix, fire, aquarium, cat, or a saved clip /idle-art random a new scene or clip each turn and every 20 seconds or so (the default) /idle-art delay <n> wait n seconds into a turn, 0 to 60 (default 3) /idle-art import <gif path> <name> turn a GIF into a clip and keep it under that name /idle-art list the built-in scenes and the saved clips /idle-art remove <name> delete a saved clip; a style set to it goes back to random /idle-art help

The settings and the clips stay in the mod's store, shared by every project and every window, and survive updates. Each window reads them again at each /idle-art command, at each turn's start, and every 2 seconds while a turn runs, so an on, an off, a style, a delay or a clip set in another window shows here within 2 seconds in a running turn, and at the next turn otherwise. Two windows that import at the same time keep both clips.

How it draws

An AbovePrompt ui.render hook mounts a Client element while isWorking is true. The Client runs hooks/scene.tsx on the drawing thread: a 16 ms surface.every tick advances the scene by 16 ms and asks for the next frame, so no hook runs per frame. Measured on 2.1.283, the frame clock keeps that rate: the cat, which walks 5 cells a second, moved 5 cells in each second of a live band. Each built-in scene is a pure module under hooks/art/ that answers a grid of cells; a saved clip reaches the drawing thread in the Client's props and plays from hooks/clip.ts. A row draws as one Text per run of one colour. The hooks module picks the scene and a random seed when the band first shows a working turn, a $.clock.after timer redraws the band once the delay has passed, and the main loop's turn.complete ends the turn, so the next one picks again. While a turn runs, a $.clock.every timer reads the settings and the clip names again every 2 seconds, and a turn.start hook reads them at each turn's start, so the draw itself never reads the store. The clips load again only when the stored names or the stamp an import or a remove writes changed, because one read of the store takes longer as the whole store grows: 0.28 ms for a small store and 3.1 ms for one of 3.5 MB, measured on 2.1.283. Under random the drawing thread counts a scene's time itself, and once it has run it posts the scene's name with surface.post; the ui.message hook picks the next scene and answers with its props, which the running instance takes in place. A message that names a scene no longer showing changes nothing, so a late or repeated post cannot skip one. No clip travels to the drawing thread before its turn to show, because one clip may take most of the 100,000 characters a Client's props hold. The GIF decoder in hooks/gif.ts is written for this mod and needs no tool on the machine.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install idle-art@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

Load it from a local checkout for one session:

claude --plugin-dir plugins/idle-art

After installing

Restart Claude Code, or run /reload-plugins in an open session. The mod is on after an install; /idle-art off turns it off.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.tsx hooks: session.start, ui.render{component=AbovePrompt}, ui.message, turn.start, turn.complete, command.run{command=idle-art} ❯ ./register.tsx calls: $.clock.after (via beginTurn), $.clock.every (via beginTurn), $.clock.now (via sceneFor), $.command.register, $.env.get (via resolvePath), $.fs.exists (via readGifBytes), $.fs.read (via readGifBytes), $.fs.stat (via readGifBytes), $.process.spawn (via streamedStdout), $.session.cwd (via resolvePath), $.store.delete (via removeClip), $.store.get (via clipsMark, loadClips, loadConfig, writeClipNames), $.store.set (via importGif, removeClip, setting, writeClipNames), $.ui.invalidate (via beginTurn, setting, sync), $.ui.log (via rereadOrStop), $.ui.resolve ❯ ./register.tsx env writes: nothing ❯ ./register.tsx env reads: HOME ❯ ./register.tsx surface modules: hooks/scene.tsx

Reach L2, runs base64 to read a GIF over 4 MiB.

  1. Reads: the band's props (working, survey, rows, columns) and the clock; its settings and clips from the store, at each command, at each turn's start and every 2 seconds while a turn runs; the GIF a /idle-art import names, once, when it is 32 MiB or smaller; HOME and the session's directory to resolve that path
  2. Runs: base64 -i <path> for a GIF over 4 MiB, once per import; no fork; one timer per turn for the delay, one that reads the settings every 2 seconds while a turn runs, and the drawing thread's 16 ms tick while the band shows
  3. Sends: nothing; no network call and nothing to the model
  4. Persists: the on/off state, the style, the delay, each imported clip, and a stamp of the last import or remove in the mod's store
  5. Hostile input: a GIF is untrusted bytes: the decoder bounds every read by the file's length, refuses a broken code stream or a missing color table, stops at 500 frames, and an import that fails keeps nothing; a stored clip of the wrong shape is skipped at load; the command takes a fixed word list, a whole number from 0 to 60, and a clip name of lowercase letters, digits and dashes

Limits

  • 8 rows is a small canvas: a GIF becomes about 30 columns by 8 rows at the usual 2:1 shape, enough for a silhouette or a motion, not for detail.
  • A GIF of up to 4 MiB is read with $.fs.read. A larger one, up to 32 MiB, is read through base64 -i <path> piece by piece, because $.fs.read refuses a file over 4 MiB and $.process.run cuts its output at 4 MiB (measured on 2.1.283). The bytes that come back must match the file's size, or the import is refused.
  • Each frame is reduced to its cells as it is decoded, so a large GIF holds one frame of pixels at a time. A GIF stops at 500 frames.
  • A long GIF loses frames to the 90,000-character limit; its motion stays as long, but steps more coarsely.
  • The terminal draws the colours with its own palette; a terminal without true colour shows the nearest of its 256 colours.
  • matrix draws half-width katakana. A font without those glyphs draws a replacement character.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 11 files
hooks/register.tsx 351 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { BAND_ROWS, CLIP_COLUMNS, clipNames, configOf, editNames, helpText, isStyle, parseArgs, pickStyle, sceneOf, statusText, STYLES, unknownText, type Action, type Config } from './config.ts'
3import { clipFromGif, isClip, type Clip } from './clip.ts'
4import { bytesOf } from './gif.ts'
5import type { NextMessage, SceneProps } from './scene.tsx'
6
7interface State {
8  cfg: Config
9  /** The saved clips by name, as the store held them at the last read. */
10  clips: Map<string, Clip>
11  /** The stored clip names and stamp the clips were loaded at (`clipsMark`), so a read loads them again only after a change. */
12  clipsMark: string
13  /** When the band first saw the model working in this turn; null while it is idle. */
14  since: number | null
15  /** The scene and seed of the running turn, and the scene of the one before. */
16  style: string | null
17  seed: number
18  last: string | null
19  /** The region the scene was last drawn in, which the next scene of the same turn takes. */
20  size?: Size
21  /** The timer that reads the settings again while a turn runs. */
22  reread?: Timer
23}
24
25type Size = { width: number; height: number }
26
27/** Rows below which a band has no room for a picture. */
28const MIN_ROWS = 3
29/** `$.fs.read` refuses a file over 4 MiB; a larger GIF is read through `base64`, up to 32 MiB. */
30const MAX_READ_BYTES = 4 * 1024 * 1024
31const MAX_BIG_GIF_BYTES = 32 * 1024 * 1024
32/** The store key of the saved clip names, and the prefix of each clip's own key. */
33const CLIPS_KEY = 'clips'
34const CLIP_PREFIX = 'clip:'
35/** The store key of the stamp each import and remove writes, which a replaced clip changes too. */
36const CLIPS_REV_KEY = 'clipsRev'
37/** How often a running turn reads the settings again, so a change made in another window shows within seconds. */
38const REREAD_MS = 2000
39
40function errorText(err: unknown): string {
41  return err instanceof Error ? err.message : String(err)
42}
43
44/**
45 * The saved clips; a name whose entry is gone or broken is left out, and so is one a built-in scene took
46 * after it was saved (`cat`), so the clip never hides the scene.
47 */
48async function loadClips($: EngineInterface): Promise<Map<string, Clip>> {
49  const clips = new Map<string, Clip>()
50  for (const name of clipNames(await $.store.get(CLIPS_KEY))) {
51    const clip = await $.store.get(`${CLIP_PREFIX}${name}`)
52    if (isClip(clip)) clips.set(name, clip)
53  }
54  return clips
55}
56
57async function loadConfig($: EngineInterface, clips: readonly string[]): Promise<Config> {
58  const [enabled, style, delay] = await Promise.all([$.store.get('enabled'), $.store.get('style'), $.store.get('delay')])
59  return configOf(enabled, style, delay, clips)
60}
61
62/** The stored clip names with the stamp beside them; it differs after any window added, replaced or removed a clip. */
63async function clipsMark($: EngineInterface): Promise<string> {
64  const [names, rev] = await Promise.all([$.store.get(CLIPS_KEY), $.store.get(CLIPS_REV_KEY)])
65  return JSON.stringify([names ?? null, rev ?? null])
66}
67
68/** Loads the clips again once their mark moved, and only then, because each clip costs one store read, which grows with the whole store. */
69async function refreshClips($: EngineInterface, state: State): Promise<void> {
70  const mark = await clipsMark($)
71  if (mark === state.clipsMark) return
72  state.clips = await loadClips($)
73  state.clipsMark = mark
74}
75
76/**
77 * Reads the settings and the saved clips again, because every window shares the store, and answers whether
78 * the band changes: the mod was turned on or off, or the style or the delay changed. A new style takes the
79 * place of the scene a running turn shows, as `/idle-art <scene>` does in the window that ran it.
80 */
81async function readSettings($: EngineInterface, state: State): Promise<boolean> {
82  await refreshClips($, state)
83  const was = state.cfg
84  state.cfg = await loadConfig($, [...state.clips.keys()])
85  if (state.cfg.style !== was.style && state.since !== null) pickScene(state)
86  return state.cfg.enabled !== was.enabled || state.cfg.style !== was.style || state.cfg.delaySec !== was.delaySec
87}
88
89/** Reads the settings again, and redraws the band when another window changed what it shows. */
90async function sync($: EngineInterface, state: State): Promise<void> {
91  if (await readSettings($, state)) $.ui.invalidate('ui.render')
92}
93
94/** The timer's read: a read that fails is named once and stops the timer, and the next turn reads again. */
95async function rereadOrStop($: EngineInterface, state: State): Promise<void> {
96  try {
97    await sync($, state)
98  } catch (err) {
99    stopRereading(state)
100    $.ui.log(`the settings could not be read again: ${errorText(err)}`)
101  }
102}
103
104function stopRereading(state: State): void {
105  state.reread?.cancel()
106  state.reread = undefined
107}
108
109/** The turn ended: the next one picks its scene again, and nothing reads the settings until it begins. */
110function endTurn(state: State): void {
111  state.since = null
112  stopRereading(state)
113}
114
115/** Picks the next scene and its seed, never the one shown last. */
116function pickScene(state: State): void {
117  state.style = pickStyle(state.cfg.style, state.last, Math.random, [...state.clips.keys()])
118  state.last = state.style
119  state.seed = Math.floor(Math.random() * 2 ** 31)
120}
121
122/**
123 * A turn began: pick its scene and seed, redraw once the delay has passed, and read the settings again
124 * every 2 seconds while the turn runs.
125 */
126function beginTurn($: EngineInterface, state: State, now: number): void {
127  state.since = now
128  pickScene(state)
129  if (state.cfg.delaySec > 0) $.clock.after(state.cfg.delaySec * 1000, () => $.ui.invalidate('ui.render'))
130  stopRereading(state)
131  state.reread = $.clock.every(REREAD_MS, () => void rereadOrStop($, state))
132}
133
134/** The props of the current scene in a region; under `random` the scene gives way to another once it has run. */
135function propsOf(state: State, style: string, size: Size): SceneProps {
136  const props: SceneProps = { style, seed: state.seed, ...size, rotate: state.cfg.style === 'random' }
137  const clip = state.clips.get(style)
138  return clip === undefined ? props : { ...props, clip }
139}
140
141/** The scene of a working band, or null while the delay runs or the band has no room. */
142async function sceneFor($: EngineInterface, state: State, band: { maxRows: number; bodyColumns: number }): Promise<SceneProps | null> {
143  const now = await $.clock.now()
144  if (state.since === null) beginTurn($, state, now)
145  const height = Math.min(BAND_ROWS, band.maxRows)
146  const waited = now - (state.since ?? now) >= state.cfg.delaySec * 1000
147  if (state.style === null || !waited || height < MIN_ROWS) return null
148  state.size = { width: band.bodyColumns, height }
149  return propsOf(state, state.style, state.size)
150}
151
152/**
153 * The next scene's props when the running one asks to leave: only under `random`, only for the scene
154 * showing now (a late or repeated message changes nothing), and only while a band is drawn.
155 */
156function nextScene(state: State, data: unknown): SceneProps | undefined {
157  const asked = (data as Partial<NextMessage> | null)?.next
158  if (state.cfg.style !== 'random' || state.style === null || state.size === undefined) return undefined
159  if (asked !== sceneOf({ style: state.style, seed: state.seed })) return undefined
160  pickScene(state)
161  return propsOf(state, state.style as string, state.size)
162}
163
164/** A path as typed: `~` is the home directory, and a relative path is under the session's directory. */
165async function resolvePath($: EngineInterface, path: string): Promise<string> {
166  if (path === '~' || path.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${path.slice(1)}`
167  if (path.startsWith('/')) return path
168  return `${(await $.session.cwd()).replace(/\/+$/, '')}/${path}`
169}
170
171/**
172 * A GIF's bytes: `$.fs.read` up to its 4 MiB limit, and `base64 <path>` above it,
173 * whose text the mod decodes itself, up to 32 MiB.
174 */
175async function readGifBytes($: EngineInterface, path: string): Promise<Uint8Array> {
176  if (!(await $.fs.exists(path))) throw new Error(`${path} does not exist`)
177  const st = await $.fs.stat(path)
178  if (st.kind !== 'file') throw new Error(`${path} is not a file`)
179  const size = st.size ?? 0
180  if (size > MAX_BIG_GIF_BYTES) throw new Error(`${path} is ${Math.round(size / 1024 / 1024)} MiB, over the 32 MiB limit`)
181  if (size <= MAX_READ_BYTES) return bytesOf((await $.fs.read(path, { as: 'bytes' })).base64)
182  // macOS base64 takes its input file only after `-i`; GNU base64 reads `-i` as --ignore-garbage, which only
183  // decoding uses, and the path as its input file.
184  const bytes = bytesOf(await streamedStdout($, ['base64', '-i', path]))
185  if (bytes.length !== size) throw new Error(`base64 gave ${bytes.length} bytes of ${size}`)
186  return bytes
187}
188
189/**
190 * A command's whole stdout, read piece by piece: `$.process.run` cuts stdout at
191 * 4 MiB (measured on 2.1.283), and a large GIF's base64 is longer.
192 */
193async function streamedStdout($: EngineInterface, argv: string[]): Promise<string> {
194  const parts: string[] = []
195  const errors: string[] = []
196  const child = $.process.spawn({ argv })
197  for (;;) {
198    const step = await child.next()
199    if (step.done) {
200      if (step.value.code !== 0) throw new Error(`${argv[0]} failed: ${errors.join('').trim().slice(0, 200)}`)
201      return parts.join('')
202    }
203    ;(step.value.stream === 'stdout' ? parts : errors).push(step.value.text)
204  }
205}
206
207/**
208 * Adds one name to the stored clip names or takes it out, over what the store holds now, so a clip another
209 * window saved meanwhile stays listed; the new stamp makes every window load the clips again, this one too.
210 */
211async function writeClipNames($: EngineInterface, state: State, name: string, keep: boolean): Promise<void> {
212  await $.store.set(CLIPS_KEY, editNames(clipNames(await $.store.get(CLIPS_KEY)), name, keep))
213  await $.store.set(CLIPS_REV_KEY, Math.random().toString(36).slice(2))
214  await refreshClips($, state)
215}
216
217/** Turns a GIF into a clip, keeps it in the store under its name, and says what was kept. */
218async function importGif($: EngineInterface, state: State, typed: string, name: string): Promise<string> {
219  const path = await resolvePath($, typed)
220  let clip: Clip
221  let dropped: number
222  let frames: number
223  try {
224    ;({ clip, dropped, frames } = clipFromGif(await readGifBytes($, path), CLIP_COLUMNS, BAND_ROWS))
225  } catch (err) {
226    return `cannot import ${typed}: ${errorText(err)}`
227  }
228  try {
229    await $.store.set(`${CLIP_PREFIX}${name}`, clip)
230  } catch (err) {
231    return `cannot save ${name}: ${errorText(err)}. The store holds 4 MiB in all; /idle-art remove <name> frees room.`
232  }
233  const replaced = state.clips.has(name)
234  await writeClipNames($, state, name, true)
235  const kept = dropped > 0 ? `${clip.frames.length} of ${frames} frames (the rest dropped to fit)` : `${frames} frames`
236  return `${replaced ? 'replaced' : 'saved'} ${name}: ${kept}, ${clip.width}×${clip.height} cells. Use it with /idle-art ${name}; random draws it too.`
237}
238
239async function removeClip($: EngineInterface, state: State, name: string): Promise<string> {
240  if (!state.clips.has(name)) return `no saved clip is named ${name}`
241  await $.store.delete(`${CLIP_PREFIX}${name}`)
242  await writeClipNames($, state, name, false)
243  const wasChosen = state.cfg.style === name
244  if (wasChosen) {
245    state.cfg.style = 'random'
246    await $.store.set('style', 'random')
247  }
248  return `removed ${name}${wasChosen ? '; the style is random again' : ''}`
249}
250
251function listText(state: State): string {
252  const clips = [...state.clips].map(([name, c]) => `${name} (${c.frames.length} frames, ${c.width}×${c.height})`)
253  return [`built in: ${STYLES.join(', ')}`, `saved clips: ${clips.length > 0 ? clips.join(', ') : 'none; add one with /idle-art import <gif path> <name>'}`].join('\n')
254}
255
256/** Stores a setting, redraws, and answers with the state. */
257async function setting($: EngineInterface, state: State, key: 'enabled' | 'style' | 'delay', value: boolean | string | number): Promise<string> {
258  if (key === 'enabled') state.cfg.enabled = value as boolean
259  if (key === 'style') state.cfg.style = value as string
260  if (key === 'delay') state.cfg.delaySec = value as number
261  await $.store.set(key, value)
262  $.ui.invalidate('ui.render')
263  return statusText(state.cfg)
264}
265
266/** Stores the chosen style, and a turn already drawing takes it at once instead of at its end. */
267async function chooseStyle($: EngineInterface, state: State, style: string): Promise<string> {
268  if (style !== 'random' && !isStyle(style) && !state.clips.has(style)) return unknownText(style, [...state.clips.keys()])
269  state.cfg.style = style
270  if (state.since !== null) pickScene(state)
271  return setting($, state, 'style', style)
272}
273
274async function apply($: EngineInterface, state: State, action: Action): Promise<string> {
275  switch (action.kind) {
276    case 'status':
277      return statusText(state.cfg)
278    case 'help':
279      return helpText(state.cfg)
280    case 'list':
281      return listText(state)
282    case 'error':
283      return action.text
284    case 'enable':
285      return setting($, state, 'enabled', action.enabled)
286    case 'delay':
287      return setting($, state, 'delay', action.seconds)
288    case 'style':
289      return chooseStyle($, state, action.style)
290    case 'import':
291      return importGif($, state, action.path, action.name)
292    case 'remove':
293      return removeClip($, state, action.name)
294  }
295}
296
297export const register: Register = on => {
298  const state: State = { cfg: configOf(undefined, undefined, undefined, []), clips: new Map(), clipsMark: '', since: null, style: null, seed: 0, last: null }
299
300  on('session.start', async ($, e, next) => {
301    const r = await next(e)
302    await readSettings($, state)
303    await $.command.register({ name: 'idle-art', description: 'ASCII animation above the prompt while the model works: on, off, a scene, random, delay, import a GIF (idle-art)', immediate: true })
304    return r
305  })
306
307  // The band's draw reads no store: it draws from the settings the last read left.
308  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
309    const drawn = await next(e)
310    if (!e.props.isWorking) endTurn(state)
311    if (!state.cfg.enabled || e.surface !== 'terminal' || e.props.hasSurvey || !e.props.isWorking) return drawn
312    const props = await sceneFor($, state, e.props)
313    if (props === null) return drawn
314    const { Box, Client } = $.ui.resolve(e)
315    // What another plugin drew in the band stays above the picture.
316    return (
317      <Box flexDirection="column">
318        {drawn}
319        <Client key="idle-art" module="./scene.tsx" width={props.width} height={props.height} props={props} />
320      </Box>
321    )
322  })
323
324  // A scene under `random` that has run its time asks for the next; the answer's props start it in place.
325  on('ui.message', async (_, e, next) => {
326    if (e.element !== 'idle-art') return next(e)
327    const props = nextScene(state, e.data)
328    return props === undefined ? next(e) : { props }
329  })
330
331  // Each turn reads the settings at its start, so an on, an off or a style another window stored applies here
332  // too, also while this window has the mod off and draws nothing.
333  on('turn.start', async ($, e, next) => {
334    const r = await next(e)
335    await sync($, state)
336    return r
337  })
338
339  // The band is not drawn idle between two turns, so the main loop's turn end is what starts the next
340  // turn with a new style and its own delay.
341  on('turn.complete', async (_, e, next) => {
342    if (e.agentId === undefined) endTurn(state)
343    return next(e)
344  })
345
346  on('command.run', { command: 'idle-art' }, async ($, e) => {
347    await sync($, state)
348    return { text: await apply($, state, parseArgs(e.args)) }
349  })
350}
351
hooks/config.ts 144 lines
1export const STYLES = ['matrix', 'fire', 'aquarium', 'cat'] as const
2export type Style = (typeof STYLES)[number]
3
4/**
5 * A stored choice: a built-in style, the name of a saved clip, or `random`, a
6 * new one at each turn from both, never the one before it.
7 */
8export type Choice = string
9
10export type Config = { enabled: boolean; style: Choice; delaySec: number }
11
12export const DEFAULT_DELAY_SEC = 3
13export const MAX_DELAY_SEC = 60
14
15/** Rows the band takes at most; a scene takes the band's whole width. */
16export const BAND_ROWS = 8
17/** Columns an imported clip may take at most. */
18export const CLIP_COLUMNS = 100
19
20/** Words the command reads itself, so no clip may take them as its name. */
21const RESERVED = new Set(['random', 'on', 'off', 'help', 'delay', 'import', 'list', 'remove'])
22
23/** A clip name: lowercase letters, digits and dashes, starting with a letter or digit, up to 24 characters. */
24const NAME = /^[a-z0-9][a-z0-9-]{0,23}$/
25
26/** One showing of a scene: its style and seed, which an instance names when it asks to leave it. */
27export function sceneOf(p: { style: string; seed: number }): string {
28  return `${p.style}:${p.seed}`
29}
30
31export function isStyle(v: unknown): v is Style {
32  return typeof v === 'string' && (STYLES as readonly string[]).includes(v)
33}
34
35/** Why a name cannot be a clip's, or null when it can. */
36export function nameProblem(name: string): string | null {
37  if (!NAME.test(name)) return `${name} is not a clip name: use lowercase letters, digits and dashes, up to 24 characters`
38  if (RESERVED.has(name) || isStyle(name)) return `${name} is taken by the command or a built-in scene`
39  return null
40}
41
42/**
43 * The clip names as the store holds them: a value that is not a list reads as none, and a name the mod
44 * refuses now is left out, as one a built-in scene took after it was saved (`cat`).
45 */
46export function clipNames(stored: unknown): string[] {
47  return Array.isArray(stored) ? stored.filter((n): n is string => typeof n === 'string' && nameProblem(n) === null) : []
48}
49
50/** The clip names with `name` added at the end, or taken out; a name already listed keeps its place. */
51export function editNames(names: readonly string[], name: string, keep: boolean): string[] {
52  if (!keep) return names.filter(n => n !== name)
53  return names.includes(name) ? [...names] : [...names, name]
54}
55
56/** The settings as the store holds them; a missing or broken value takes its default. */
57export function configOf(enabled: unknown, style: unknown, delay: unknown, clips: readonly string[]): Config {
58  const delaySec = typeof delay === 'number' && Number.isInteger(delay) && delay >= 0 && delay <= MAX_DELAY_SEC ? delay : DEFAULT_DELAY_SEC
59  const known = isStyle(style) || (typeof style === 'string' && clips.includes(style))
60  return { enabled: enabled !== false, style: known ? (style as string) : 'random', delaySec }
61}
62
63/** The scene of a new turn: the stored one, or at random any built-in style or clip but the last. */
64export function pickStyle(choice: Choice, last: string | null, rng: () => number, clips: readonly string[] = []): string {
65  if (choice !== 'random') return choice
66  const all = [...STYLES, ...clips]
67  const pool = all.length > 1 ? all.filter(s => s !== last) : all
68  return pool[Math.floor(rng() * pool.length)] ?? 'matrix'
69}
70
71export type Action =
72  | { kind: 'status' }
73  | { kind: 'help' }
74  | { kind: 'list' }
75  | { kind: 'enable'; enabled: boolean }
76  | { kind: 'style'; style: Choice }
77  | { kind: 'delay'; seconds: number }
78  | { kind: 'import'; path: string; name: string }
79  | { kind: 'remove'; name: string }
80  | { kind: 'error'; text: string }
81
82const USAGE = `/idle-art [on | off | ${STYLES.join(' | ')} | <clip> | random | delay <0-${MAX_DELAY_SEC}> | import <gif path> <name> | list | remove <name> | help]`
83
84const tooMany: Action = { kind: 'error', text: `too many arguments. Usage: ${USAGE}` }
85
86function delayAction(rest: string[]): Action {
87  const [value] = rest
88  const n = Number(value)
89  if (rest.length > 1) return tooMany
90  if (value === undefined || !Number.isInteger(n) || n < 0 || n > MAX_DELAY_SEC) return { kind: 'error', text: `delay takes whole seconds from 0 to ${MAX_DELAY_SEC}. Usage: ${USAGE}` }
91  return { kind: 'delay', seconds: n }
92}
93
94/** `import <path> <name>`: the name is the last word, so a path may hold spaces. */
95function importAction(rest: string[]): Action {
96  if (rest.length < 2) return { kind: 'error', text: `import takes a GIF path and a name. Usage: ${USAGE}` }
97  const name = (rest[rest.length - 1] as string).toLowerCase()
98  const problem = nameProblem(name)
99  if (problem !== null) return { kind: 'error', text: problem }
100  return { kind: 'import', path: rest.slice(0, -1).join(' '), name }
101}
102
103function removeAction(rest: string[]): Action {
104  if (rest.length !== 1) return { kind: 'error', text: `remove takes one clip name. Usage: ${USAGE}` }
105  return { kind: 'remove', name: (rest[0] as string).toLowerCase() }
106}
107
108const WITH_VALUE: Record<string, (rest: string[]) => Action> = { delay: delayAction, import: importAction, remove: removeAction }
109
110/** What a `/idle-art` argument asks for. The keyword reads case-blind; a path keeps its case. */
111export function parseArgs(args: string): Action {
112  const [first = '', ...rest] = args.trim().split(/\s+/)
113  const word = first.toLowerCase()
114  const withValue = WITH_VALUE[word]
115  if (withValue !== undefined) return withValue(rest)
116  return rest.length > 0 ? tooMany : wordAction(word)
117}
118
119/** What a single-word argument asks for; a word the command does not know names a scene or a clip. */
120function wordAction(word: string): Action {
121  const fixed: Record<string, Action> = { '': { kind: 'status' }, help: { kind: 'help' }, list: { kind: 'list' }, on: { kind: 'enable', enabled: true }, off: { kind: 'enable', enabled: false } }
122  return fixed[word] ?? { kind: 'style', style: word }
123}
124
125export function unknownText(word: string, clips: readonly string[]): string {
126  return `unknown scene: ${word}. Scenes: ${[...STYLES, ...clips].join(', ')}. Usage: ${USAGE}`
127}
128
129export function statusText(c: Config): string {
130  return `${c.enabled ? 'on' : 'off'} · style ${c.style} · shows ${c.delaySec}s into a turn`
131}
132
133export function helpText(c: Config): string {
134  return [
135    statusText(c),
136    `Usage: ${USAGE}`,
137    'on, off: draw or stop drawing the animation above the prompt while the model works.',
138    `${STYLES.join(', ')}, or a saved clip's name: always draw that one. random: a new one each turn, clips included.`,
139    'delay <n>: wait n seconds into a turn before drawing, so short turns show nothing.',
140    'import <gif path> <name>: turn a GIF into a character clip and keep it under that name, for every project.',
141    'list: the built-in scenes and the saved clips. remove <name>: delete a saved clip.',
142  ].join('\n')
143}
144
hooks/clip.ts 200 lines
1import { readGif, type Gif } from './gif.ts'
2import { BLANK, blankFrame, runsOf, type Animation, type Cell, type Frame } from './art/grid.ts'
3
4/** A run of one colour in a stored row: its text and its colour ('' for none). */
5export type ClipRun = [string, string]
6
7/** A GIF turned into character frames, as the store keeps it and the band plays it. */
8export type Clip = { width: number; height: number; delays: number[]; frames: ClipRun[][][] }
9
10/** Glyphs from the dimmest opaque cell to the brightest; a transparent cell is a space. */
11const RAMP = [...'.:-=+*#%@']
12
13/** The largest clip in JSON characters: a `Client`'s props are bounded at 100,000. */
14export const MAX_CLIP_CHARS = 90_000
15
16/** A cell less covered than this by opaque pixels is left blank. */
17const MIN_COVER = 0.5
18
19/** Rounds a channel to one of six levels, so neighbouring cells share a colour and a run. */
20function level(v: number): number {
21  return Math.round(v / 51) * 51
22}
23
24function hex(r: number, g: number, b: number): string {
25  return `#${[r, g, b].map(v => level(v).toString(16).padStart(2, '0')).join('')}`
26}
27
28type Box = { x0: number; x1: number; y0: number; y1: number }
29
30/** The mean colour and the opaque share of a pixel box of one frame. */
31function average(rgba: Uint8Array, width: number, box: Box): { r: number; g: number; b: number; cover: number } {
32  let r = 0
33  let g = 0
34  let b = 0
35  let opaque = 0
36  let all = 0
37  for (let y = box.y0; y < box.y1; y++) {
38    for (let x = box.x0; x < box.x1; x++) {
39      const p = (y * width + x) * 4
40      all += 1
41      if ((rgba[p + 3] ?? 0) < 128) continue
42      opaque += 1
43      r += rgba[p] ?? 0
44      g += rgba[p + 1] ?? 0
45      b += rgba[p + 2] ?? 0
46    }
47  }
48  if (opaque === 0) return { r: 0, g: 0, b: 0, cover: 0 }
49  return { r: r / opaque, g: g / opaque, b: b / opaque, cover: opaque / Math.max(1, all) }
50}
51
52type Mean = ReturnType<typeof average>
53
54function lumOf(a: Mean): number {
55  return (0.2126 * a.r + 0.7152 * a.g + 0.0722 * a.b) / 255
56}
57
58/** The darkest and brightest opaque cell of the whole clip, so the ramp spans the clip's own range. */
59type Range = { lo: number; hi: number }
60
61function rangeOf(means: Mean[][][]): Range {
62  let lo = 1
63  let hi = 0
64  for (const cell of means.flat(2)) {
65    if (cell.cover < MIN_COVER) continue
66    lo = Math.min(lo, lumOf(cell))
67    hi = Math.max(hi, lumOf(cell))
68  }
69  return hi > lo ? { lo, hi } : { lo: 0, hi: 1 }
70}
71
72function cellOf(a: Mean, range: Range): Cell {
73  if (a.cover < MIN_COVER) return BLANK
74  const t = (lumOf(a) - range.lo) / (range.hi - range.lo)
75  return { ch: RAMP[Math.max(0, Math.min(RAMP.length - 1, Math.floor(t * RAMP.length)))] as string, color: hex(a.r, a.g, a.b) }
76}
77
78/**
79 * The size in cells a picture takes in a band of `cols` by `rows`, its shape kept:
80 * a cell is about twice as tall as wide, so one row covers two pixel rows' worth.
81 */
82export function fitSize(width: number, height: number, cols: number, rows: number): { cols: number; rows: number } {
83  const scale = Math.min(cols / width, (rows * 2) / height)
84  return { cols: Math.max(1, Math.min(cols, Math.round(width * scale))), rows: Math.max(1, Math.min(rows, Math.round((height * scale) / 2))) }
85}
86
87/** The pixel span of cell `i` of `n` over `size` pixels, at least one pixel. */
88function span(i: number, n: number, size: number): [number, number] {
89  const a = Math.floor((i * size) / n)
90  return [a, Math.max(a + 1, Math.floor(((i + 1) * size) / n))]
91}
92
93/** The mean of every cell of one frame of `width` by `height` pixels, row by row. */
94function frameMeans(rgba: Uint8Array, width: number, height: number, cols: number, rows: number): Mean[][] {
95  return Array.from({ length: rows }, (_, cy) => {
96    const [y0, y1] = span(cy, rows, height)
97    return Array.from({ length: cols }, (_, cx) => {
98      const [x0, x1] = span(cx, cols, width)
99      return average(rgba, width, { x0, x1, y0, y1 })
100    })
101  })
102}
103
104function frameRows(means: Mean[][], range: Range): ClipRun[][] {
105  return means.map(row => runsOf(row.map(m => cellOf(m, range))).map(run => [run.text, run.color] as ClipRun))
106}
107
108/** Keeps every other frame, each kept frame showing for the time of both. */
109function thinned(clip: Clip): Clip {
110  const frames: ClipRun[][][] = []
111  const delays: number[] = []
112  for (let i = 0; i < clip.frames.length; i += 2) {
113    frames.push(clip.frames[i] as ClipRun[][])
114    delays.push((clip.delays[i] ?? 0) + (clip.delays[i + 1] ?? 0))
115  }
116  return { ...clip, frames, delays }
117}
118
119/** The cell means and delays of every frame, as the frames are decoded. */
120type Means = { cols: number; rows: number; means: Mean[][][]; delays: number[] }
121
122/** A clip from the frames' cell means, with frames dropped until it fits the size limit. */
123function clipOf(m: Means): { clip: Clip; dropped: number } {
124  const range = rangeOf(m.means)
125  let clip: Clip = { width: m.cols, height: m.rows, delays: m.delays, frames: m.means.map(f => frameRows(f, range)) }
126  while (JSON.stringify(clip).length > MAX_CLIP_CHARS && clip.frames.length > 1) clip = thinned(clip)
127  if (JSON.stringify(clip).length > MAX_CLIP_CHARS) throw new Error(`one frame is over ${MAX_CLIP_CHARS} characters`)
128  return { clip, dropped: m.means.length - clip.frames.length }
129}
130
131/** A decoded GIF as a clip for a band of `cols` by `rows`. */
132export function toClip(gif: Gif, cols: number, rows: number): { clip: Clip; dropped: number } {
133  const size = fitSize(gif.width, gif.height, cols, rows)
134  const means = gif.frames.map(f => frameMeans(f.rgba, gif.width, gif.height, size.cols, size.rows))
135  return clipOf({ ...size, means, delays: gif.frames.map(f => f.delayMs) })
136}
137
138/**
139 * A GIF file's bytes as a clip for a band of `cols` by `rows`: each frame is
140 * reduced to its cell means as it is decoded, so a large GIF never holds more
141 * than one frame of pixels.
142 */
143export function clipFromGif(bytes: Uint8Array, cols: number, rows: number): { clip: Clip; dropped: number; frames: number } {
144  const m: Means = { cols: 0, rows: 0, means: [], delays: [] }
145  const { count } = readGif(bytes, (frame, width, height) => {
146    if (m.means.length === 0) Object.assign(m, fitSize(width, height, cols, rows))
147    m.means.push(frameMeans(frame.rgba, width, height, m.cols, m.rows))
148    m.delays.push(frame.delayMs)
149  })
150  return { ...clipOf(m), frames: count }
151}
152
153/** Whether a stored value has the shape of a clip, so a broken store entry is refused rather than drawn. */
154export function isClip(v: unknown): v is Clip {
155  if (typeof v !== 'object' || v === null) return false
156  const c = v as Partial<Clip>
157  return typeof c.width === 'number' && typeof c.height === 'number' && Array.isArray(c.delays) && Array.isArray(c.frames) && c.frames.length > 0 && c.frames.length === c.delays.length
158}
159
160/** Draws a stored row into a frame from column `x`, cutting what falls outside. */
161function putRow(out: Frame, y: number, x: number, runs: ClipRun[]): void {
162  const row = out[y]
163  if (row === undefined) return
164  let col = x
165  for (const [text, color] of runs) {
166    for (const ch of text) {
167      if (col >= 0 && col < row.length) row[col] = ch === ' ' ? BLANK : { ch, color }
168      col += 1
169    }
170  }
171}
172
173/**
174 * Plays a clip centred in a region of `w` by `h`, each frame for its own delay, in a loop; a step without a
175 * length of its own is `tickMs` long.
176 */
177export function playClip(clip: Clip, w: number, h: number, tickMs: number): Animation {
178  let index = 0
179  let shown = 0
180  let wrapped = false
181  const x = Math.floor((w - clip.width) / 2)
182  const y = Math.floor((h - clip.height) / 2)
183  const step = (dtMs?: number): void => {
184    shown += dtMs ?? tickMs
185    wrapped = false
186    // A stored delay under 20 ms is read as 20 ms, so a broken entry cannot spin this loop.
187    for (let d = Math.max(20, clip.delays[index] ?? tickMs); shown >= d; d = Math.max(20, clip.delays[index] ?? tickMs)) {
188      shown -= d
189      index = (index + 1) % clip.frames.length
190      if (index === 0) wrapped = true
191    }
192  }
193  const frame = (): Frame => {
194    const out = blankFrame(w, h)
195    clip.frames[index]?.forEach((runs, row) => putRow(out, y + row, x, runs))
196    return out
197  }
198  return { step, frame, wrapped: () => wrapped }
199}
200
hooks/gif.ts 275 lines
1/** One composed frame of a GIF: RGBA pixels of the whole logical screen, and how long it shows. */
2export type GifFrame = { rgba: Uint8Array; delayMs: number }
3export type Gif = { width: number; height: number; frames: GifFrame[] }
4
5/** The frame control a Graphic Control Extension sets for the next image. */
6type Control = { delayMs: number; transparent: number | null; disposal: number }
7
8/** A delay of 0 or 10 ms is played by browsers as 100 ms; so is it here. */
9const MIN_DELAY_MS = 20
10const DEFAULT_DELAY_MS = 100
11/** Stop decoding past this many frames; the store could not hold more anyway. */
12const MAX_FRAMES = 500
13
14const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
15
16/** The bytes of a base64 text, as `$.fs.read(path, { as: 'bytes' })` hands them. */
17export function bytesOf(base64: string): Uint8Array {
18  const text = base64.replace(/[^A-Za-z0-9+/]/g, '')
19  const out = new Uint8Array(Math.floor((text.length * 3) / 4))
20  let acc = 0
21  let bits = 0
22  let o = 0
23  for (const ch of text) {
24    acc = ((acc << 6) | B64.indexOf(ch)) & 0xffff
25    bits += 6
26    if (bits >= 8) {
27      bits -= 8
28      out[o++] = (acc >> bits) & 0xff
29    }
30  }
31  return out.subarray(0, o)
32}
33
34class Reader {
35  pos = 0
36  readonly bytes: Uint8Array
37  constructor(bytes: Uint8Array) {
38    this.bytes = bytes
39  }
40  byte(): number {
41    const b = this.bytes[this.pos]
42    if (b === undefined) throw new Error('the GIF ends early')
43    this.pos += 1
44    return b
45  }
46  u16(): number {
47    return this.byte() | (this.byte() << 8)
48  }
49  take(n: number): Uint8Array {
50    if (this.pos + n > this.bytes.length) throw new Error('the GIF ends early')
51    const out = this.bytes.subarray(this.pos, this.pos + n)
52    this.pos += n
53    return out
54  }
55  /** The data sub-blocks up to their zero terminator, joined. */
56  blocks(): Uint8Array {
57    const parts: Uint8Array[] = []
58    let total = 0
59    for (let n = this.byte(); n !== 0; n = this.byte()) {
60      parts.push(this.take(n))
61      total += n
62    }
63    const out = new Uint8Array(total)
64    let at = 0
65    for (const p of parts) {
66      out.set(p, at)
67      at += p.length
68    }
69    return out
70  }
71}
72
73function colorTable(r: Reader, packed: number): Uint8Array {
74  return r.take(3 * (1 << ((packed & 7) + 1)))
75}
76
77/** Reads the variable-width, least-significant-bit-first codes of an LZW stream. */
78class Codes {
79  private pos = 0
80  private acc = 0
81  private bits = 0
82  private readonly data: Uint8Array
83  constructor(data: Uint8Array) {
84    this.data = data
85  }
86  /** The next code of `size` bits, or -1 when the data ends. */
87  next(size: number): number {
88    while (this.bits < size) {
89      const b = this.data[this.pos++]
90      if (b === undefined) return -1
91      this.acc |= b << this.bits
92      this.bits += 8
93    }
94    const code = this.acc & ((1 << size) - 1)
95    this.acc >>>= size
96    this.bits -= size
97    return code
98  }
99}
100
101/** The LZW string table: each code past the roots is a prefix code plus one suffix index. */
102class Table {
103  readonly prefix = new Int16Array(4096).fill(-1)
104  readonly suffix = new Uint8Array(4096)
105  readonly first = new Uint8Array(4096)
106  private readonly stack = new Uint8Array(4096)
107  readonly clear: number
108  next = 0
109  size = 0
110  readonly minCodeSize: number
111  constructor(minCodeSize: number) {
112    this.minCodeSize = minCodeSize
113    this.clear = 1 << minCodeSize
114    for (let i = 0; i < this.clear; i++) {
115      this.suffix[i] = i
116      this.first[i] = i
117    }
118    this.reset()
119  }
120  reset(): void {
121    this.size = this.minCodeSize + 1
122    this.next = this.clear + 2
123  }
124  /** Writes the string of `code` into `out` from `o`; the code being defined is the previous string plus its first index. */
125  expand(code: number, prev: number, out: Uint8Array, o: number): number {
126    if (code > this.next || (code === this.next && prev === -1)) throw new Error('the GIF image data is broken')
127    let sp = 0
128    let c = code
129    if (code === this.next) {
130      this.stack[sp++] = this.first[prev] as number
131      c = prev
132    }
133    while (c >= this.clear) {
134      this.stack[sp++] = this.suffix[c] as number
135      c = this.prefix[c] as number
136    }
137    this.stack[sp++] = c
138    while (sp > 0 && o < out.length) out[o++] = this.stack[--sp] as number
139    return o
140  }
141  /** Adds the previous string plus the first index of the current one. */
142  add(code: number, prev: number): void {
143    if (prev === -1 || this.next >= 4096) return
144    this.prefix[this.next] = prev
145    this.suffix[this.next] = this.first[code === this.next ? prev : code] as number
146    this.first[this.next] = this.first[prev] as number
147    this.next += 1
148    if (this.next === 1 << this.size && this.size < 12) this.size += 1
149  }
150}
151
152/** The LZW-coded pixel indices of one image, `count` of them; a short stream leaves the rest 0. */
153export function lzwDecode(data: Uint8Array, minCodeSize: number, count: number): Uint8Array {
154  if (minCodeSize < 2 || minCodeSize > 8) throw new Error('the GIF image data is broken')
155  const out = new Uint8Array(count)
156  const table = new Table(minCodeSize)
157  const codes = new Codes(data)
158  let prev = -1
159  let o = 0
160  while (o < count) {
161    const code = codes.next(table.size)
162    if (code === -1 || code === table.clear + 1) break
163    if (code === table.clear) {
164      table.reset()
165      prev = -1
166      continue
167    }
168    o = table.expand(code, prev, out, o)
169    table.add(code, prev)
170    prev = code
171  }
172  return out
173}
174
175/** The row order of an interlaced image: every 8th from 0, every 8th from 4, every 4th from 2, every 2nd from 1. */
176function interlacedRows(h: number): number[] {
177  const rows: number[] = []
178  for (const [start, stepBy] of [[0, 8], [4, 8], [2, 4], [1, 2]] as const) for (let y = start; y < h; y += stepBy) rows.push(y)
179  return rows
180}
181
182function readControl(r: Reader): Control {
183  const block = r.blocks()
184  const packed = block[0] ?? 0
185  const delay = ((block[1] ?? 0) | ((block[2] ?? 0) << 8)) * 10
186  return { disposal: (packed >> 2) & 7, transparent: packed & 1 ? (block[3] ?? 0) : null, delayMs: delay < MIN_DELAY_MS ? DEFAULT_DELAY_MS : delay }
187}
188
189type Screen = { width: number; height: number; canvas: Uint8Array; global: Uint8Array | null }
190
191/** Draws one image onto the canvas and answers the frame the screen then shows. */
192function drawImage(r: Reader, s: Screen, ctl: Control): GifFrame {
193  const x0 = r.u16()
194  const y0 = r.u16()
195  const w = r.u16()
196  const h = r.u16()
197  const packed = r.byte()
198  const table = packed & 0x80 ? colorTable(r, packed) : s.global
199  if (table === null) throw new Error('a GIF image has no color table')
200  const minCodeSize = r.byte()
201  const indices = lzwDecode(r.blocks(), minCodeSize, w * h)
202  const saved = ctl.disposal === 3 ? s.canvas.slice() : null
203  const rows = packed & 0x40 ? interlacedRows(h) : Array.from({ length: h }, (_, y) => y)
204  rows.forEach((y, i) => paintRow(s, table, indices.subarray(i * w, i * w + w), x0, y0 + y, ctl.transparent))
205  const frame = { rgba: s.canvas.slice(), delayMs: ctl.delayMs }
206  if (ctl.disposal === 2) clearRect(s, x0, y0, w, h)
207  if (saved !== null) s.canvas = saved
208  return frame
209}
210
211function paintRow(s: Screen, table: Uint8Array, row: Uint8Array, x0: number, y: number, transparent: number | null): void {
212  if (y < 0 || y >= s.height) return
213  row.forEach((index, i) => {
214    const x = x0 + i
215    if (index === transparent || x < 0 || x >= s.width) return
216    const p = (y * s.width + x) * 4
217    s.canvas.set([table[index * 3] ?? 0, table[index * 3 + 1] ?? 0, table[index * 3 + 2] ?? 0, 255], p)
218  })
219}
220
221function clearRect(s: Screen, x0: number, y0: number, w: number, h: number): void {
222  for (let y = Math.max(0, y0); y < Math.min(s.height, y0 + h); y++) s.canvas.fill(0, (y * s.width + Math.max(0, x0)) * 4, (y * s.width + Math.min(s.width, x0 + w)) * 4)
223}
224
225/** The signature, the logical screen and the global color table. */
226function readScreen(r: Reader): Screen {
227  const sig = String.fromCharCode(...r.take(6))
228  if (sig !== 'GIF87a' && sig !== 'GIF89a') throw new Error('the file is not a GIF')
229  const width = r.u16()
230  const height = r.u16()
231  const packed = r.byte()
232  r.take(2)
233  if (width === 0 || height === 0) throw new Error('the GIF has no size')
234  return { width, height, canvas: new Uint8Array(width * height * 4), global: packed & 0x80 ? colorTable(r, packed) : null }
235}
236
237/**
238 * Decodes a GIF87a or GIF89a file frame by frame, handing each composed frame
239 * to `visit` as it is made, so no more than one frame's pixels are held at once.
240 * Answers the screen's size and the number of frames. Throws with a reason on a
241 * file it cannot read.
242 */
243export function readGif(bytes: Uint8Array, visit: (frame: GifFrame, width: number, height: number) => void): { width: number; height: number; count: number } {
244  const r = new Reader(bytes)
245  const s = readScreen(r)
246  let count = 0
247  let ctl: Control = { delayMs: DEFAULT_DELAY_MS, transparent: null, disposal: 0 }
248  while (count < MAX_FRAMES) {
249    const kind = r.byte()
250    if (kind === 0x3b) break
251    if (kind === 0x2c) {
252      visit(drawImage(r, s, ctl), s.width, s.height)
253      count += 1
254      ctl = { delayMs: DEFAULT_DELAY_MS, transparent: null, disposal: 0 }
255    } else if (kind === 0x21) ctl = readExtension(r, ctl)
256    else throw new Error(`the GIF holds an unknown block 0x${kind.toString(16)}`)
257  }
258  if (count === 0) throw new Error('the GIF has no image')
259  return { width: s.width, height: s.height, count }
260}
261
262/** Decodes a GIF into all its composed frames at once; for a small GIF, as the tests use. */
263export function decodeGif(bytes: Uint8Array): Gif {
264  const frames: GifFrame[] = []
265  const { width, height } = readGif(bytes, f => frames.push(f))
266  return { width, height, frames }
267}
268
269function readExtension(r: Reader, ctl: Control): Control {
270  const label = r.byte()
271  if (label === 0xf9) return readControl(r)
272  r.blocks()
273  return ctl
274}
275
hooks/scene.tsx 84 lines
1import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
2import { isStyle, sceneOf } from './config.ts'
3import { rngOf, runsOf, sceneDone, type Animation, type Frame } from './art/grid.ts'
4import { SCENES } from './art/scenes.ts'
5import { playClip, type Clip } from './clip.ts'
6
7/**
8 * What the hooks module hands this instance: the scene's style and seed, the region, a saved clip's frames,
9 * and whether the scene gives way to another once it has run its time (the `random` choice).
10 */
11export type SceneProps = { style: string; seed: number; width: number; height: number; clip?: Clip; rotate?: boolean }
12
13/** What an instance posts when its scene has run its time: the scene it asks to leave. */
14export type NextMessage = { next: string }
15
16/**
17 * The running animation and what it was started for, so new props start a new one; how long it has run,
18 * and whether it has asked for the next scene.
19 */
20type Live = { anim: Animation; key: string; scene: string; rotate: boolean; ms: number; asked: boolean }
21
22/**
23 * Milliseconds per animation tick, about 60 frames a second. Each scene moves by the time a tick stands for,
24 * so its pace does not hang on this rate; a fire and a clip still change at their own pace.
25 */
26const TICK_MS = 16
27
28function keyOf(p: SceneProps): string {
29  return `${sceneOf(p)}:${p.width}x${p.height}`
30}
31
32function animOf(p: SceneProps): Animation | undefined {
33  if (p.width < 1 || p.height < 1) return undefined
34  if (p.clip !== undefined) return playClip(p.clip, p.width, p.height, TICK_MS)
35  return isStyle(p.style) ? SCENES[p.style](p.width, p.height, rngOf(p.seed)) : undefined
36}
37
38function start(p: SceneProps): Live | undefined {
39  const anim = animOf(p)
40  return anim === undefined ? undefined : { anim, key: keyOf(p), scene: sceneOf(p), rotate: p.rotate === true, ms: 0, asked: false }
41}
42
43/** One tick: advance the scene, and once it has run its time, ask the hooks module for the next one, once. */
44function tick(surface: ClientSurface<Live>): void {
45  const now = surface.state
46  if (now === undefined) return
47  now.anim.step(TICK_MS)
48  now.ms += TICK_MS
49  if (now.rotate && !now.asked && sceneDone(now.anim, now.ms)) {
50    now.asked = true
51    surface.post({ next: now.scene } satisfies NextMessage)
52  }
53  surface.setState({ ...now })
54}
55
56function draw(surface: ClientSurface<Live>, frame: Frame): RenderElement {
57  const { Box, Text } = surface.elements
58  return (
59    <Box flexDirection="column">
60      {frame.map((row, y) => (
61        <Text key={String(y)} wrap="truncate">
62          {runsOf(row).map((run, i) => (run.color === '' ? run.text : <Text key={String(i)} color={run.color}>{run.text}</Text>))}
63        </Text>
64      ))}
65    </Box>
66  )
67}
68
69/** Draws one frame per tick; the tick only advances the animation and asks for the next call. */
70const Scene: ClientModule<SceneProps, Live> = (props, surface) => {
71  let live = surface.state
72  if (live === undefined || live.key !== keyOf(props)) {
73    const fresh = start(props)
74    if (fresh === undefined) return surface.elements.Text({ children: '' })
75    // The first call starts the clock; later calls reach here only on new props.
76    if (live === undefined) surface.every(TICK_MS, () => tick(surface))
77    live = fresh
78    surface.setState(live)
79  }
80  return draw(surface, live.anim.frame())
81}
82
83export default Scene
84
hooks/art/grid.ts 108 lines
1/** One character cell of a frame; an empty `color` draws in the terminal's default colour. */
2export type Cell = { ch: string; color: string }
3
4/** A frame: `h` rows of `w` cells. */
5export type Frame = Cell[][]
6
7/** Consecutive cells of one colour, drawn as one Text. */
8export type Run = { text: string; color: string }
9
10/** A random source in [0, 1). */
11export type Rng = () => number
12
13/**
14 * One running animation: `step` advances it by `dtMs` milliseconds (STEP_MS when not given), `frame` draws
15 * where it stands. An animation with an end, a clip, says through `wrapped` whether the last step went from
16 * its last frame back to its first.
17 */
18export type Animation = { step: (dtMs?: number) => void; frame: () => Frame; wrapped?: () => boolean }
19
20/** The step every scene's speeds are written for: a speed of 1 is one cell per STEP_MS. */
21export const STEP_MS = 100
22
23/** A step of `dtMs` in units of STEP_MS, the factor a per-step speed is multiplied by. */
24export function unitsOf(dtMs: number | undefined): number {
25  return (dtMs ?? STEP_MS) / STEP_MS
26}
27
28/**
29 * Runs `fn` once for every whole STEP_MS the steps have added up to, for a scene that changes in whole steps
30 * (a fire's heat, a board's generation) and keeps its pace at any frame rate.
31 */
32export function everyStep(fn: () => void): (dtMs?: number) => void {
33  let owed = 0
34  return dtMs => {
35    owed += dtMs ?? STEP_MS
36    for (; owed >= STEP_MS; owed -= STEP_MS) fn()
37  }
38}
39
40/** Starts an animation for a region of `w` columns and `h` rows. */
41export type Maker = (w: number, h: number, rng: Rng) => Animation
42
43export const BLANK: Cell = { ch: ' ', color: '' }
44
45/** How long a scene runs before `random` moves to another in the same turn. */
46export const SCENE_MS = 20_000
47
48/**
49 * Whether a scene that has run `ms` has run its time: a built-in scene after SCENE_MS, a clip at the first
50 * end of a loop from then on, so a long clip plays through once and a short one repeats until then.
51 */
52export function sceneDone(anim: Animation, ms: number): boolean {
53  return ms >= SCENE_MS && (anim.wrapped === undefined || anim.wrapped())
54}
55
56/** A seeded generator (mulberry32), so a test replays the same frames. */
57export function rngOf(seed: number): Rng {
58  let a = seed >>> 0
59  return () => {
60    a = (a + 0x6d2b79f5) >>> 0
61    let t = a
62    t = Math.imul(t ^ (t >>> 15), t | 1)
63    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
64    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
65  }
66}
67
68/** A whole number in [0, n). */
69export function below(rng: Rng, n: number): number {
70  return Math.floor(rng() * n)
71}
72
73/** One item of a list, picked at random. */
74export function pick<T>(rng: Rng, items: readonly T[]): T {
75  return items[below(rng, items.length)] as T
76}
77
78export function blankFrame(w: number, h: number): Frame {
79  return Array.from({ length: h }, () => Array.from({ length: w }, () => BLANK))
80}
81
82/** Writes `text` into a row from column `x`, cutting what falls outside the frame. */
83export function put(frame: Frame, x: number, y: number, text: string, color: string): void {
84  const row = frame[y]
85  if (row === undefined) return
86  const chars = [...text]
87  for (let i = 0; i < chars.length; i++) {
88    const col = x + i
89    if (col >= 0 && col < row.length) row[col] = { ch: chars[i] as string, color }
90  }
91}
92
93/** A row as runs of one colour, so a frame draws as few Text elements. */
94export function runsOf(row: readonly Cell[]): Run[] {
95  const runs: Run[] = []
96  for (const cell of row) {
97    const last = runs[runs.length - 1]
98    if (last !== undefined && last.color === cell.color) last.text += cell.ch
99    else runs.push({ text: cell.ch, color: cell.color })
100  }
101  return runs
102}
103
104/** A frame as plain text, one line per row. */
105export function textOf(frame: Frame): string {
106  return frame.map(row => row.map(c => c.ch).join('')).join('\n')
107}
108
hooks/art/scenes.ts 9 lines
1import { aquarium } from './aquarium.ts'
2import { cat } from './cat.ts'
3import { fire } from './fire.ts'
4import type { Maker } from './grid.ts'
5import { matrix } from './matrix.ts'
6import type { Style } from '../config.ts'
7
8export const SCENES: Record<Style, Maker> = { matrix, fire, aquarium, cat }
9
hooks/art/aquarium.ts 93 lines
1import { below, blankFrame, pick, put, unitsOf, type Animation, type Frame, type Rng } from './grid.ts'
2
3/** Each fish as it looks swimming right, and the same fish swimming left. */
4const SHAPES: readonly { right: string; left: string }[] = [
5  { right: '><>', left: '<><' },
6  { right: '><(((°>', left: '<°)))><' },
7  { right: '>=>', left: '<=<' },
8  { right: '><((°>', left: '<°))><' },
9]
10const FISH_COLORS = ['#ffa94d', '#ffd43b', '#66d9e8', '#f783ac', '#b197fc']
11const BUBBLE = '#a5d8ff'
12const WEED = ['#2f9e44', '#51cf66']
13const SAND = '#c9a66b'
14const SURFACE = '#339af0'
15
16type Fish = { x: number; y: number; dir: 1 | -1; speed: number; shape: number; color: string }
17type Bubble = { x: number; y: number }
18type Weed = { x: number; height: number; phase: number }
19
20/** Ticks between two sways of the seaweed. */
21const SWAY_TICKS = 6
22
23function newFish(rng: Rng, w: number, h: number, anywhere: boolean): Fish {
24  const dir = rng() < 0.5 ? 1 : -1
25  const shape = below(rng, SHAPES.length)
26  const len = (SHAPES[shape]?.right.length ?? 3)
27  const edge = dir === 1 ? -len : w
28  return { x: anywhere ? below(rng, w) : edge, y: 1 + below(rng, Math.max(1, h - 2)), dir, speed: 0.3 + rng() * 0.6, shape, color: pick(rng, FISH_COLORS) }
29}
30
31function fishText(f: Fish): string {
32  const s = SHAPES[f.shape] ?? { right: '><>', left: '<><' }
33  return f.dir === 1 ? s.right : s.left
34}
35
36function isGone(f: Fish, w: number): boolean {
37  return f.dir === 1 ? f.x > w : f.x + fishText(f).length < 0
38}
39
40/** The glyph of a bubble: it grows as it rises. */
41function bubbleGlyph(y: number, h: number): string {
42  if (y > h * 0.66) return '.'
43  return y > h * 0.33 ? 'o' : 'O'
44}
45
46/** The water's surface, its ripple moving one cell each sway. */
47function surfaceRow(w: number, shift: number): string {
48  return Array.from({ length: w }, (_, x) => ((x + shift) % 4 === 0 ? '-' : '~')).join('')
49}
50
51/**
52 * An aquarium: fish crossing both ways, bubbles rising from them and from the sand, seaweed swaying on the
53 * sand, and the water's surface rippling on the top row.
54 */
55export function aquarium(w: number, h: number, rng: Rng): Animation {
56  const fish = Array.from({ length: Math.max(3, Math.floor(w / 7)) }, () => newFish(rng, w, h, true))
57  const bubbles: Bubble[] = []
58  const weeds: Weed[] = Array.from({ length: Math.max(3, Math.floor(w / 5)) }, () => ({ x: below(rng, w), height: 1 + below(rng, Math.max(1, h - 3)), phase: below(rng, 2) }))
59  const sand = Array.from({ length: w }, () => pick(rng, ['.', ',', '_', '.', ' ']))
60  let tick = 0
61
62  const step = (dtMs?: number): void => {
63    const u = unitsOf(dtMs)
64    tick += u
65    fish.forEach((f, i) => {
66      f.x += f.dir * f.speed * u
67      if (isGone(f, w)) fish[i] = newFish(rng, w, h, false)
68      else if (rng() < 0.05 * u) bubbles.push({ x: Math.round(f.dir === 1 ? f.x + fishText(f).length : f.x - 1), y: f.y })
69    })
70    if (rng() < (w / 40) * 0.3 * u) bubbles.push({ x: below(rng, w), y: h - 2 })
71    for (let i = bubbles.length - 1; i >= 0; i--) {
72      const b = bubbles[i] as Bubble
73      b.y -= 0.3 * u
74      if (b.y < 1) bubbles.splice(i, 1)
75    }
76  }
77
78  const frame = (): Frame => {
79    const out = blankFrame(w, h)
80    put(out, 0, h - 1, sand.join(''), SAND)
81    put(out, 0, 0, surfaceRow(w, Math.floor(tick / SWAY_TICKS)), SURFACE)
82    const sway = Math.floor(tick / SWAY_TICKS)
83    for (const wd of weeds) {
84      for (let k = 0; k < wd.height; k++) put(out, wd.x, h - 2 - k, (k + wd.phase + sway) % 2 === 0 ? '(' : ')', WEED[k % 2] as string)
85    }
86    for (const b of bubbles) put(out, b.x, Math.floor(b.y), bubbleGlyph(b.y, h), BUBBLE)
87    for (const f of fish) put(out, Math.round(f.x), f.y, fishText(f), f.color)
88    return out
89  }
90
91  return { step, frame }
92}
93
hooks/art/cat.ts 253 lines
1import { blankFrame, put, unitsOf, type Animation, type Frame, type Rng } from './grid.ts'
2
3const FUR = '#ffb86b'
4const KITTEN_FUR = ['#e9ecef', '#adb5bd']
5const SPEECH = '#ffffff'
6const HEART = '#ff6b9d'
7const GRASS = '#51cf66'
8const YARN = '#ff6b6b'
9const BUTTERFLY = '#f783ac'
10
11/** The big cat's sprites are 5 rows tall, drawn with their last row just over the ground row. */
12const SPRITE_ROWS = 5
13/** The sitting cat's width, which places it in the middle. */
14const SIT_COLS = 13
15
16/** The big cat walking right, its head ahead and its tail behind, two steps. */
17const WALK = [
18  ['           /\\_/\\', '    ______( o.o )', '  ~(            )', '    (__________)', '     /\\ /\\  /\\ /\\'],
19  ['           /\\_/\\', '    ______( o.o )', '  ~(            )', '    (__________)', '     || ||  || ||'],
20]
21const WALK_COLS = 19
22
23/** The big cat sitting; `eyes` is three characters, its first and last the two eyes. */
24function sitting(eyes: string): string[] {
25  const [l = 'o', , r = 'o'] = [...eyes]
26  return ['  /\\_____/\\', ` (  ${l}   ${r}  )`, ' (  = ^ =  )', '  )       (', ' (__(_|_)__)~']
27}
28
29/** The big cat rolling on the ground: on one side, on its back with its paws up, on the other side, on its back. */
30const ROLL = [
31  ['', '        /\\_/\\', '  _____( -.- )', ' (____________)=~', "   `----------'"],
32  ['  |\\/|    |\\/|', ' (            )~', ' (   ( o.o )  )', '  \\__________/', ''],
33  ['', '   /\\_/\\', '  ( -.- )_____', '~=(____________)', "   `----------'"],
34  ['  |\\/|    |\\/|', ' (            )~', ' (   ( ^.^ )  )', '  \\__________/', ''],
35]
36
37/** A kitten walking right, two steps, 4 rows tall. */
38const KITTEN = [
39  ['    /\\_/\\ ', ' __( o.o )', '~(      ) ', '  /\\  /\\  '],
40  ['    /\\_/\\ ', ' __( o.o )', '~(      ) ', '  ||  ||  '],
41]
42const KITTEN_COLS = 10
43
44/** Cells a walking cat moves per tick. */
45const WALK_SPEED = 0.5
46
47const MIRROR: Record<string, string> = { '/': '\\', '\\': '/', '(': ')', ')': '(', '<': '>', '>': '<', '{': '}', '}': '{' }
48
49/** A sprite row seen from the other side. */
50export function mirrored(row: string, width: number): string {
51  return [...row.padEnd(width)].reverse().map(c => MIRROR[c] ?? c).join('')
52}
53
54/** One part of the show: how many ticks it lasts, and how its tick-th frame is drawn. */
55type Phase = { ticks: number; draw: (t: number, out: Frame) => void }
56
57/** The band, the sitting cat's column, the big cat's top row, and the ground row. */
58type Stage = { w: number; h: number; center: number; base: number; ground: number }
59
60function sprite(out: Frame, rows: readonly string[], x: number, y: number, color = FUR): void {
61  rows.forEach((row, i) => {
62    // Only the blanks around a row let what is behind it show, so a kitten is not cut by the cat's box,
63    // and does not show through the cat's body either.
64    const lead = row.length - row.trimStart().length
65    put(out, x + lead, y + i, row.trim(), color)
66  })
67}
68
69/**
70 * A speech bubble beside the cat's head, its tail pointing down to the head:
71 *
72 *      .------.
73 *     ( meow )
74 *      '------'
75 *     /
76 */
77export function bubbleRows(text: string): string[] {
78  const rule = '-'.repeat(text.length + 2)
79  return [` .${rule}.`, `( ${text} )`, ` '${rule}'`, '/']
80}
81
82function bubble(out: Frame, s: Stage, text: string): void {
83  bubbleRows(text).forEach((row, i) => put(out, s.center + SIT_COLS + 1, s.base - 2 + i, row, SPEECH))
84}
85
86function walkPhase(s: Stage, from: number, to: number): Phase {
87  const ticks = Math.max(1, Math.ceil(Math.abs(to - from) / WALK_SPEED))
88  return { ticks, draw: (t, out) => sprite(out, WALK[Math.floor(t / 2) % 2] as string[], Math.round(from + t * WALK_SPEED), s.base) }
89}
90
91function sitPhase(s: Stage): Phase {
92  // Two blinks.
93  const blinking = (t: number): boolean => (t >= 8 && t < 10) || (t >= 18 && t < 20)
94  return { ticks: 25, draw: (t, out) => sprite(out, sitting(blinking(t) ? '-.-' : 'o.o'), s.center, s.base) }
95}
96
97function meowPhase(s: Stage, text: string, eyes: string): Phase {
98  return {
99    ticks: 20,
100    draw: (_, out) => {
101      sprite(out, sitting(eyes), s.center, s.base)
102      bubble(out, s, text)
103    },
104  }
105}
106
107/** Rolls three cells right and back, one pose every two ticks. */
108function rollPhase(s: Stage): Phase {
109  const drift = [0, 1, 2, 3, 3, 2, 1, 0]
110  return { ticks: 48, draw: (t, out) => sprite(out, ROLL[Math.floor(t / 2) % ROLL.length] as string[], s.center + (drift[Math.floor(t / 6) % drift.length] ?? 0), s.base) }
111}
112
113/** Three jumps. */
114function jumpPhase(s: Stage): Phase {
115  const lift = [0, 1, 2, 2, 1, 0, 0, 0]
116  return {
117    ticks: 24,
118    draw: (t, out) => {
119      const up = lift[Math.floor(t) % lift.length] ?? 0
120      sprite(out, sitting(up === 0 ? 'o.o' : '^o^'), s.center, s.base - up)
121    },
122  }
123}
124
125/** Purring, with a heart floating up from its head. */
126function purrPhase(s: Stage): Phase {
127  return {
128    ticks: 32,
129    draw: (t, out) => {
130      sprite(out, sitting('^.^'), s.center, s.base)
131      bubble(out, s, 'purr~')
132      put(out, s.center + 6, s.base - 1 - Math.floor(t / 8), '♥', HEART)
133    },
134  }
135}
136
137/** Looking left and right, twice. */
138function lookPhase(s: Stage): Phase {
139  const eyes = ['o.o', 'O.o', 'O.o', 'o.o', 'o.O', 'o.O']
140  return { ticks: 30, draw: (t, out) => sprite(out, sitting(eyes[Math.floor(t / 2.5) % eyes.length] as string), s.center, s.base) }
141}
142
143/** The tricks in the middle of the show, in a new order each time round. */
144function tricks(s: Stage, rng: Rng): Phase[] {
145  const all = [rollPhase(s), jumpPhase(s), purrPhase(s), lookPhase(s)]
146  for (let i = all.length - 1; i > 0; i--) {
147    const j = Math.floor(rng() * (i + 1))
148    ;[all[i], all[j]] = [all[j] as Phase, all[i] as Phase]
149  }
150  return all
151}
152
153/** One round: walk in to the middle, sit and blink, meow, the tricks, meow again, walk out. */
154function show(s: Stage, rng: Rng): Phase[] {
155  return [
156    walkPhase(s, -WALK_COLS, s.center),
157    sitPhase(s),
158    meowPhase(s, 'meow', 'o.o'),
159    ...tricks(s, rng),
160    meowPhase(s, 'MEOW!', '>.<'),
161    walkPhase(s, s.center, s.w),
162  ]
163}
164
165/** Where a thing that walks back and forth over `span` cells stands at `clock`, and which way it faces. */
166export function bounce(start: number, speed: number, span: number, clock: number): { x: number; dir: 1 | -1 } {
167  const period = 2 * Math.max(1, span)
168  const p = (((start + speed * clock) % period) + period) % period
169  return p < span ? { x: Math.round(p), dir: 1 } : { x: Math.round(period - p), dir: -1 }
170}
171
172/** The ground: grass tufts over the band's last row. */
173function groundRow(w: number, rng: Rng): string {
174  return Array.from({ length: w }, () => ['_', '_', ',', '"', '_', '.'][Math.floor(rng() * 6)] as string).join('')
175}
176
177/**
178 * Two kittens playing back and forth on the ground, each facing the way it walks: one left of the place the
179 * big cat sits, one right of it, so they never walk into each other or over the sitting cat.
180 */
181function kittens(out: Frame, s: Stage, clock: number): void {
182  const yards = [{ from: 0, to: s.center - KITTEN_COLS - 1, speed: 0.3 }, { from: s.center + SIT_COLS + 1, to: s.w - KITTEN_COLS, speed: 0.45 }]
183  yards.forEach((yard, i) => {
184    const span = yard.to - yard.from
185    if (span < 2) return
186    const { x, dir } = bounce(span * 0.4, yard.speed, span, clock)
187    const rows = KITTEN[Math.floor(clock / 2) % 2] as string[]
188    sprite(out, dir === 1 ? rows : rows.map(r => mirrored(r, KITTEN_COLS)), yard.from + x, s.ground - 4, KITTEN_FUR[i])
189  })
190}
191
192/** A ball of yarn rolling back and forth on the ground, its loose thread behind it. */
193function yarn(out: Frame, s: Stage, clock: number): void {
194  const { x, dir } = bounce(s.w * 0.6, 0.35, Math.max(1, s.w - 1), clock)
195  put(out, x - dir * 2, s.ground - 1, '~', YARN)
196  put(out, x - dir, s.ground - 1, '~', YARN)
197  put(out, x, s.ground - 1, '@', YARN)
198}
199
200/** A butterfly drifting across the top rows, its wings beating. */
201function butterfly(out: Frame, s: Stage, clock: number): void {
202  const x = Math.floor((clock * 0.4) % (s.w + 4)) - 2
203  const y = Math.round(1 + Math.sin(clock / 5))
204  put(out, x, y, Math.floor(clock / 3) % 2 === 0 ? '}{' : ')(', BUTTERFLY)
205}
206
207/**
208 * A cat and its garden: the big cat walks in slowly to the middle of the band, sits and blinks, says meow,
209 * rolls on the ground, jumps, purrs with a heart, looks about, says MEOW and walks out, then comes round
210 * again, while two kittens play on the grass, a ball of yarn rolls and a butterfly drifts over them.
211 */
212export function cat(w: number, h: number, rng: Rng): Animation {
213  const ground = h - 1
214  const s: Stage = { w, h, center: Math.max(0, Math.floor((w - SIT_COLS) / 2)), base: ground - SPRITE_ROWS, ground }
215  const grass = groundRow(w, rng)
216  let phases = show(s, rng)
217  let index = 0
218  let t = 0
219  let clock = 0
220  let wrapped = false
221
222  // `t` counts steps of STEP_MS within the phase, with a fraction between them, so a walk moves on every frame.
223  // A round that ends says so through `wrapped`, so `random` moves on only once the cat has walked out.
224  const step = (dtMs?: number): void => {
225    const u = unitsOf(dtMs)
226    t += u
227    clock += u
228    wrapped = false
229    for (let ticks = phases[index]?.ticks ?? 0; t >= ticks; ticks = phases[index]?.ticks ?? 0) {
230      t -= ticks
231      index += 1
232      if (index >= phases.length) {
233        index = 0
234        phases = show(s, rng)
235        wrapped = true
236      }
237    }
238  }
239
240  const frame = (): Frame => {
241    const out = blankFrame(w, h)
242    put(out, 0, ground, grass, GRASS)
243    kittens(out, s, clock)
244    butterfly(out, s, clock)
245    phases[index]?.draw(t, out)
246    // The yarn rolls in front, at the cats' feet, so no cat hides it.
247    yarn(out, s, clock)
248    return out
249  }
250
251  return { step, frame, wrapped: () => wrapped }
252}
253
hooks/art/fire.ts 54 lines
1import { below, blankFrame, everyStep, type Animation, type Frame, type Rng } from './grid.ts'
2
3/** Glyphs and colours from the coolest visible heat to the hottest. */
4const GLYPHS = [...'.,:;+*oO#@']
5const COLORS = ['#4a0d00', '#7a1a00', '#a82800', '#d23c00', '#ef6a00', '#ff9200', '#ffb52e', '#ffd75e', '#ffec9e', '#fff8dc']
6
7const MAX = 1
8
9/** The heat a column's source gives: full in the middle, falling off toward both edges. */
10function sourceHeat(x: number, w: number, rng: Rng): number {
11  const edge = Math.min(x + 1, w - x) / Math.max(1, w / 6)
12  return Math.min(1, edge) * (0.55 + rng() * 0.45)
13}
14
15/**
16 * A fire: heat rises from a hidden source row, each cell taking the heat of a cell
17 * below it (drifting one column at most) less a random cooling sized so flames
18 * reach about the top of the region.
19 */
20export function fire(w: number, h: number, rng: Rng): Animation {
21  // Row h is the hidden source; rows 0..h-1 are drawn.
22  const heat = Array.from({ length: h + 1 }, () => new Array<number>(w).fill(0))
23  const cooling = (2 * MAX) / h
24
25  // The heat changes in whole steps, ten a second, at any frame rate: the flicker the person liked.
26  const step = everyStep((): void => {
27    const source = heat[h] as number[]
28    for (let x = 0; x < w; x++) source[x] = sourceHeat(x, w, rng)
29    for (let y = 0; y < h; y++) {
30      const row = heat[y] as number[]
31      const under = heat[y + 1] as number[]
32      for (let x = 0; x < w; x++) {
33        const from = Math.min(w - 1, Math.max(0, x + below(rng, 3) - 1))
34        row[x] = Math.max(0, (under[from] ?? 0) - rng() * cooling)
35      }
36    }
37  })
38
39  const frame = (): Frame => {
40    const out = blankFrame(w, h)
41    for (let y = 0; y < h; y++) {
42      for (let x = 0; x < w; x++) {
43        const v = heat[y]?.[x] ?? 0
44        if (v < 0.08) continue
45        const i = Math.min(GLYPHS.length - 1, Math.floor(v * GLYPHS.length))
46        ;(out[y] as Frame[number])[x] = { ch: GLYPHS[i] as string, color: COLORS[i] as string }
47      }
48    }
49    return out
50  }
51
52  return { step, frame }
53}
54
hooks/art/matrix.ts 65 lines
1import { below, blankFrame, everyStep, pick, unitsOf, type Animation, type Frame, type Rng } from './grid.ts'
2
3/** Half-width katakana and digits: one cell wide each. */
4const GLYPHS = [...'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789']
5
6const HEAD = '#e6ffe6'
7/** The trail from just behind the head to its faded end. */
8const TRAIL = ['#5dff7a', '#1fd14a', '#12a03a', '#0b6e28', '#06451a']
9
10/** One falling stream: its head row (fractional), rows per tick and trail length. */
11type Drop = { y: number; speed: number; len: number }
12
13function newDrop(rng: Rng, h: number, fresh: boolean): Drop {
14  const len = 3 + below(rng, Math.max(2, h))
15  // A fresh start spreads the streams over the region; a respawn starts above it.
16  const y = fresh ? below(rng, h + len) - len : -below(rng, h * 2) - 1
17  return { y, speed: 0.25 + rng() * 0.6, len }
18}
19
20function trailColor(dist: number, len: number): string {
21  const i = Math.min(TRAIL.length - 1, Math.floor((dist / len) * TRAIL.length))
22  return TRAIL[i] as string
23}
24
25/** Matrix rain: one stream in every other column, glyphs flickering under it. */
26export function matrix(w: number, h: number, rng: Rng): Animation {
27  const glyphs = Array.from({ length: h }, () => Array.from({ length: w }, () => pick(rng, GLYPHS)))
28  const drops: (Drop | null)[] = Array.from({ length: w }, (_, x) => (x % 2 === 0 ? newDrop(rng, h, true) : null))
29
30  // A few glyphs change each step, so a still trail shimmers at the same pace at any frame rate.
31  const shimmer = everyStep(() => {
32    for (let i = 0; i < Math.ceil((w * h) / 30); i++) {
33      const row = glyphs[below(rng, h)]
34      if (row !== undefined) row[below(rng, w)] = pick(rng, GLYPHS)
35    }
36  })
37
38  const step = (dtMs?: number): void => {
39    const u = unitsOf(dtMs)
40    drops.forEach((d, x) => {
41      if (d === null) return
42      d.y += d.speed * u
43      if (d.y - d.len > h) drops[x] = newDrop(rng, h, false)
44    })
45    shimmer(dtMs)
46  }
47
48  const frame = (): Frame => {
49    const out = blankFrame(w, h)
50    drops.forEach((d, x) => {
51      if (d === null) return
52      const head = Math.floor(d.y)
53      for (let dist = 0; dist < d.len; dist++) {
54        const y = head - dist
55        const row = out[y]
56        if (row === undefined) continue
57        row[x] = { ch: glyphs[y]?.[x] ?? ' ', color: dist === 0 ? HEAD : trailColor(dist, d.len) }
58      }
59    })
60    return out
61  }
62
63  return { step, frame }
64}
65