SLOPSHOPPER

prompt-posse

The boss and a posse of subagent creatures walk back and forth above the prompt while Claude Code works

newbandcommandtimeragents
v0.4.3MITupdated 2026-10-04RyanEmslie/prompt-posse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-posse
› 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 › /posse ⎿ prompt-posse: The posse is off. It stays off in new sessions. Run `/posse` to bring it back. ██▜██▜ ▀██████▀ ▀▀▛▀▀▛ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
██▜██▜ ▀██████▀ ▀▀▛▀▀▛ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
README

prompt-posse

A Claude Code mod that puts a little parade above your prompt while Claude works. The boss walks back and forth, and every subagent Claude starts joins in as a creature of its own.

The boss and five subagent creatures walking above the Claude Code prompt while Claude works. Two of them are Explore agents in different hat colors. A legend under them lists each agent's task, marked in its hat color

The whole lineup, as text:

  █▜██▜█      ▝▖▗▘        ▗▄██▄▖        ▀▙▟▀         ▙▟▙▟         ▝▀▀▘
 ▀██████▀     █▜▛█         █▜▛█         █▜▛█         █▜▛█         █▜▛█
  ▜▀▛▜▀▛     ▀▛▛▜▜▀       ▀▛▛▜▜▀       ▀▛▛▜▜▀       ▀▛▛▜▜▀       ▀▛▛▜▜▀
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔

From left to right: the boss, the biggest of them, then the creatures for Explore, Plan, general-purpose, claude and fork subagents. The boss is drawn after Claude Code's mascot. Every creature has dark eyes and a darker side at its back, which flips when it turns; when one stops to face you, it stands square on.

What it does

  • The boss appears when a turn starts and walks the full width of the prompt. At each edge it stops, looks at you, and turns around. It blinks now and then. It leaves once the turn has ended and no subagent is still walking.
  • Each running subagent gets its own, smaller creature. Its headwear, body color and headwear color depend on the subagent's type:
Subagent typeHeadwearBodyHeadwear color
main turn (the boss)none; bigger than everyone elseorange, with a darker side at its back and dark eyes—
Exploreantennaebluelight gray
Plantop hatgreenred
general-purposepropeller cappurplecyan
claudecrownpinkgold
forkhalopeachpale yellow
any other typeears, horns, a mohawk or a sproutpicked from the type's namepicked from the type's name

A custom type always gets the same look, never headwear a built-in type wears, and never a hat the color of its body.

No two creatures on the strip wear the same hat color. When a hat color is already taken, often by another agent of the same type, the newcomer keeps its body and headwear but wears a spare color, and keeps it until it leaves.

  • While subagents walk, a legend row under the ground lists each one's task, as the Agent call described it, in the order they joined. Each entry starts with a mark in its creature's hat color, and the task is written in its body color. When the row is too narrow, the longest tasks are cut short first, down to 10 characters, so that more of them fit. What doesn't fit even then is counted at the end as +N more.
  • The boss always walks at the same pace: 10 columns a second at the default speed. Each subagent's creature gets one of six speeds of its own, from 7 to 12 columns a second, picked from the subagent's id. How fast it actually walks follows how hard its agent is working: the output tokens its model wrote over the last 10 seconds. An agent waiting on a tool strolls at half its own speed; one writing 50 tokens a second or more runs at one and a half times it. A creature eases into a new pace over about a second. In the desktop app, a creature's SVG is drawn again once its pace has changed by a tenth or more, setting out from where it already is, so it never jumps; one pausing at an edge waits to walk on first. In the terminal, when two meet, they stop briefly and turn around. In the desktop app they walk past each other.
  • Subagents running in the background keep walking after the main turn ends, with the boss leading them. The strip disappears when the last one finishes.
  • The mod checks which subagents are running whenever one starts, whenever a turn ends, and every half second while anything is walking. So a creature can arrive or leave up to half a second after its subagent starts or finishes.
  • The strip steps aside while Claude Code is showing a survey, when there are fewer than 4 free rows above the prompt (the legend row needs a fifth, and is the first to go), or when it's under 8 columns wide, too narrow for the boss. It comes back when there's room again.

Commands

  • /posse switches the posse on or off. The setting is remembered across sessions.
  • /posse on and /posse off pick one.
  • /posse legend shows which creature is which.
  • /posse demo brings the whole posse out for 20 seconds, even while Claude isn't working: the boss, a creature for each kind of agent, and a second Explore in a spare hat, with the legend naming each one. Each walks at a different pace, as agents doing more or less work would. It starts no agents and uses no tokens. Run it again to end it sooner.

The command runs right away, even while Claude is working and the posse is walking.

Requirements

  • Claude Code 2.1.289 or newer. The function-hook API this mod uses is in early access and may change between releases.
  • The terminal or the Claude desktop app. VS Code and mobile don't draw the creatures.

In the terminal, the creatures are drawn in text characters and repainted 20 times a second. In the desktop app, they're one SVG that animates itself, so nothing is sent per frame. It's redrawn when a creature arrives or leaves, when a creature's pace changes by a tenth or more, when a turn starts or ends, and when the window is resized. A resize changes how far each creature has to walk, so the creatures jump to new spots.

Install

Clone the repo:

git clone https://github.com/RyanEmslie/prompt-posse.git

The steps below write the folder this makes as /path/to/prompt-posse. Use its absolute path instead, such as /Users/you/code/prompt-posse.

To try it in one session, point Claude Code at the folder:

claude --plugin-dir /path/to/prompt-posse

To load it in every session, add the folder's absolute path to the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/prompt-posse"
  }
}

If CLAUDE_CODE_PLUGIN_DIRS already names other plugin folders, keep them and add this one to the list, separated by : (; on Windows):

"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/another-plugin:/path/to/prompt-posse"

Sessions the desktop app starts read the same setting.

Interactive terminal sessions watch the folder, so edits reload in sessions that are already open. Desktop sessions only do that when CLAUDE_CODE_PLUGIN_DIR_WATCH is also set to 1. Otherwise edits show up in new sessions.

Check that it works

Start a new session and run /posse demo. The whole posse walks above the prompt box for 20 seconds. Or send any prompt: the boss walks while Claude works, and leaves when the turn ends. /posse legend lists who's who. If nothing shows, check that the strip has room: it needs at least 4 free rows above the prompt and 8 columns.

Update

Pull the latest version into the folder:

git -C /path/to/prompt-posse pull

Terminal sessions that watch the folder pick it up on their own. Other sessions show it once restarted.

Remove

To hide the posse but keep it installed, run /posse off. It stays off in new sessions until you run /posse on.

To remove it, take its path out of CLAUDE_CODE_PLUGIN_DIRS (or stop passing --plugin-dir), start a new session, and delete the folder.

Customizing

  • Speed: change TICK_MS in hooks/walker.ts. Lower is faster.
  • Pace: hooks/pace.ts sets how an agent's work moves its creature's pace: WINDOW_MS is how far back its output tokens count, FULL_RATE the tokens a second for the fastest pace, and MIN_PACE and MAX_PACE the slowest and fastest pace as a share of the creature's own speed.
  • Looks: edit hooks/looks.ts. Headwear is two text rows of 12 characters, with # for a filled pixel. Colors are 0xRRGGBB values.
  • Body and legs: the sprite is in hooks/sprite.ts, drawn the same way, with % for a pixel of the shaded side and o for an eye. The shade is a darker version of the body color unless the look sets shadeColor, and the eyes are near black unless it sets eyeColor.
  • Desktop drawing: hooks/svg.ts turns the same sprites into the animated SVG.

Development

claude plugin validate .
claude plugin test .

What changed in each version is in CHANGELOG.md.

Known limits

  • In the terminal, the [-] collapse button sits on the strip's top row, so near the right edge it briefly covers a creature's headwear.
  • Colors are fixed RGB values. They don't follow your terminal's color scheme or the desktop app's theme.
  • Claude Code keeps one empty row between the strip and the prompt box, so the creatures can't stand directly on the prompt's border. The ground line under them stands in for it.
  • When more creatures are out than fit side by side, they walk through each other instead of bumping. At 80 columns that's the boss plus 10; at 120, the boss plus 16.
  • A teammate running in its own terminal pane keeps reporting "running" if that pane dies, so its creature is retired after 10 minutes of running. A live teammate on one very long turn loses its creature too, until it next goes idle. If nothing was walking for a while, a retired teammate that's still listed as running comes back for another 10 minutes, since the mod can't tell whether it went idle in between.

License

MIT. See LICENSE.

This is an unofficial fan project, not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.

Source 7 files
hooks/register.tsx 657 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { hex, legendRow } from './legend'
4import { ease, paceFor, rateAt } from './pace'
5import type { Work } from './pace'
6import { BOSS, hash, hatFor, legendText } from './looks'
7import { BOSS_WIDTH, SPRITE_ROWS, SPRITE_WIDTH, rasterCells } from './sprite'
8import type { Look } from './sprite'
9import { posseSvg, strideAt } from './svg'
10import { TICK_MS, createWalker, freeSpot, lastX, pose, step } from './walker'
11import type { Walker } from './walker'
12
13const KEY = 'posse'
14const GROUND = '▔'
15// The columns the engine keeps at the band's right for its `[-]`, beside the
16// first row, the creatures' headwear row; the creatures and the ground reach
17// under the mark's columns to span the prompt.
18const MARK_COLUMNS = 5
19const BAND_ROWS = SPRITE_ROWS + 1
20// The legend takes a row of its own under the ground, while subagents walk
21// and there's room for it.
22const LEGEND_ROWS = 1
23// How often, in ticks, the session's agents are listed again.
24export const LIST_EVERY = 10
25const ACTIVE = new Set(['pending', 'running', 'waiting'])
26// How much a desktop creature's pace must change before its SVG is drawn
27// again at the new one, so a drift of its pace doesn't redraw it each time.
28const REDRAW_CHANGE = 0.1
29// A teammate in a terminal pane of its own reports running by what it last
30// wrote, which a pane that died leaves standing; after this long its
31// creature is retired until the teammate goes idle or ends.
32export const PANE_STALE_MS = 10 * 60_000
33// Checks this far apart may have missed a retired teammate going idle, so
34// it gets a fresh start.
35const UNSEEN_MS = TICK_MS * LIST_EVERY * 4
36// Times a failed listing is retried while nothing walks to keep retrying it.
37export const IDLE_RETRIES = 3
38// Where `/posse` remembers being switched off, across sessions.
39const STORE_KEY = 'isOn'
40const OPTIONS =
41  '`/posse` to switch the posse on or off, `/posse on` or `/posse off` to pick one, `/posse legend` to see which creature is which, or `/posse demo` to watch them walk.'
42// How long `/posse demo` walks, and who walks in it: one of each kind of
43// agent and a second Explore, to show the spare hat a newcomer wears.
44export const DEMO_MS = 20_000
45// Each walks at a pace of its own, as agents doing more or less work would.
46const DEMO = [
47  ['Explore', 'Explore', 1.5],
48  ['Plan', 'Plan', 0.6],
49  ['general-purpose', 'general-purpose', 1.2],
50  ['claude', 'claude', 0.9],
51  ['fork', 'fork', 0.5],
52  ['Explore', 'a second Explore', 1.3],
53  ['my-agent', 'any other type', 1],
54] as const
55
56type Creature = {
57  walker: Walker
58  look: Look
59  /** The kind of agent it walks for (`Explore`, `Plan`, ...); the boss's is empty. */
60  type: string
61  /** What its agent is doing, as the Agent call described it; the boss's is empty. */
62  description: string
63  /** When it started walking: the desktop's animation runs from it. */
64  since: number
65  /** Its own speed, which the desktop walks it at. */
66  baseSpeed: number
67  /** In the terminal, the share of its own speed its agent's work sets it walking at. */
68  pace: number
69  /** A demo creature's pace, as there is no agent's work to set it. */
70  demoPace?: number
71  /** Where the desktop's SVG walks it from: the time it set out from `walker.x`, and how fast. */
72  drawnAt: number
73  drawnSpeed: number
74}
75
76type Walk = {
77  boss: Creature
78  /** A creature for each active subagent, by agent id; background ones keep walking after the main turn ends. */
79  subagents: Map<string, Creature>
80  /** The tokens each agent's model wrote lately, by agent id, which set its creature's pace. */
81  work: Map<string, Work[]>
82  /** The creatures `/posse demo` brought out, until its time is up. */
83  demo: Map<string, Creature>
84  demoTimer: Timer | null
85  isMainTurn: boolean
86  /** Whether `/posse` has it switched on. */
87  isOn: boolean
88  /** Listings started so far. */
89  listings: number
90  /** The newest listing applied: an older one that answers later is ignored. */
91  applied: number
92  /** Failed listings in a row. */
93  failures: number
94  /** When the last listing that read the time was applied. */
95  listedAt: number
96  /** Pane teammates whose creatures were retired, until they stop running. */
97  stale: Set<string>
98  /** Whether this load has caught up with a turn and agents already under way. */
99  isSynced: boolean
100  /** The band while it shows creatures, so the timer knows where to repaint. */
101  band: {
102    requestId: string
103    columns: number
104    hasBoss: boolean
105    surface: 'terminal' | 'desktop'
106  } | null
107  timer: Timer | null
108  /** How often the timer fires: every tick on the terminal, only to list agents on the desktop. */
109  timerMs: number
110  ticks: number
111  /** The walk's own milliseconds, which run as the timer fires; agents' work is timed by them. */
112  clockMs: number
113}
114
115const shown = (walk: Walk, hasBoss: boolean) => [
116  ...(hasBoss ? [walk.boss] : []),
117  ...walk.subagents.values(),
118  ...walk.demo.values(),
119]
120
121/** Whether anyone besides the boss is out walking. */
122const hasCompany = (walk: Walk) => walk.subagents.size > 0 || walk.demo.size > 0
123
124const frame = (columns: number, creatures: readonly Creature[]) =>
125  rasterCells(
126    columns,
127    creatures.map(({ walker, look }) => ({ x: walker.x, pose: pose(walker), look })),
128  )
129
130/** What the desktop's SVG shows, for a reader that can't see it. */
131function describe(hasBoss: boolean, types: readonly string[]) {
132  const agents = `${types.length} agent${types.length === 1 ? '' : 's'}`
133  const who = [hasBoss ? 'The boss' : '', types.length > 0 ? agents : '']
134    .filter(Boolean)
135    .join(' and ')
136  const kinds =
137    types.length > 1
138      ? `${types.slice(0, -1).join(', ')} and ${types.at(-1)}`
139      : (types[0] ?? '')
140
141  return `${who} walking above the prompt${kinds === '' ? '' : `: ${kinds}`}`
142}
143
144/** An error's message, whatever was thrown. */
145const reasonOf = (error: unknown) =>
146  error instanceof Error ? error.message : typeof error === 'string' ? error : JSON.stringify(error)
147
148function stop(walk: Walk) {
149  walk.timer?.cancel()
150  walk.timer = null
151}
152
153// The desktop's SVG animates itself, so its timer only wakes to list agents.
154const periodFor = (walk: Walk) =>
155  walk.band?.surface === 'desktop' ? TICK_MS * LIST_EVERY : TICK_MS
156
157/** Starts the timer, or restarts it when the band moved to a surface that wants another pace. */
158function wake($: EngineInterface, walk: Walk) {
159  const ms = periodFor(walk)
160  if (!walk.isOn || (walk.timer !== null && walk.timerMs === ms)) {
161    return
162  }
163  walk.timer?.cancel()
164  walk.timerMs = ms
165  walk.timer = $.clock.every(ms, () => tick($, walk))
166}
167
168/**
169 * Lists the agents again. A failure is retried on the timer while anything
170 * walks, and otherwise a few times on its own before giving up.
171 */
172function relist($: EngineInterface, walk: Walk) {
173  void list($, walk).then(
174    () => {
175      walk.failures = 0
176    },
177    (error: unknown) => {
178      walk.failures += 1
179      if (walk.failures === 1) {
180        $.ui.log(
181          `prompt-posse: couldn't list the session's agents (${reasonOf(error)}); trying again in half a second`,
182          { to: 'debug' },
183        )
184      }
185      if (walk.isMainTurn || hasCompany(walk)) {
186        wake($, walk)
187      } else if (walk.failures <= IDLE_RETRIES) {
188        $.clock.after(TICK_MS * LIST_EVERY, () => relist($, walk))
189      }
190    },
191  )
192}
193
194/** Brings the creatures in line with the session's active agents. */
195async function list($: EngineInterface, walk: Walk) {
196  const listing = ++walk.listings
197  const agents = await $.agent.list()
198  const active = new Map(
199    agents.filter(agent => ACTIVE.has(agent.status)).map(agent => [agent.id, agent]),
200  )
201  const isPane = (agent: { id: string; teammateId?: string }) => agent.id === agent.teammateId
202  const needsTime =
203    walk.stale.size > 0 ||
204    [...active.values()].some(agent => !walk.subagents.has(agent.id) || isPane(agent))
205  const now = needsTime ? await $.clock.now() : 0
206  // A listing older than one already applied has nothing newer to say.
207  if (listing <= walk.applied) {
208    return
209  }
210  walk.applied = listing
211
212  if (needsTime) {
213    if (now - walk.listedAt > UNSEEN_MS) {
214      walk.stale.clear()
215    }
216    walk.listedAt = now
217  }
218  for (const id of walk.stale) {
219    if (!active.has(id)) {
220      walk.stale.delete(id)
221    }
222  }
223  for (const agent of active.values()) {
224    const creature = walk.subagents.get(agent.id)
225    if (isPane(agent) && creature !== undefined && now - creature.since > PANE_STALE_MS) {
226      walk.stale.add(agent.id)
227    }
228  }
229  for (const id of walk.stale) {
230    active.delete(id)
231  }
232
233  let isChanged = false
234  for (const id of walk.subagents.keys()) {
235    if (!active.has(id)) {
236      walk.subagents.delete(id)
237      isChanged = true
238    }
239  }
240  for (const id of walk.work.keys()) {
241    if (!active.has(id)) {
242      walk.work.delete(id)
243    }
244  }
245
246  for (const agent of active.values()) {
247    if (walk.subagents.has(agent.id)) {
248      continue
249    }
250    walk.subagents.set(
251      agent.id,
252      join(walk, agent.id, agent.type, agent.description.trim() || agent.type, now),
253    )
254    isChanged = true
255  }
256
257  if (isChanged) {
258    $.ui.invalidate('ui.render')
259  }
260  if (hasCompany(walk)) {
261    wake($, walk)
262  } else if (!walk.isMainTurn) {
263    stop(walk)
264  }
265}
266
267/** A newcomer's creature, in the freest spot and a hat color no one on the strip wears. */
268function join(walk: Walk, id: string, type: string, description: string, now: number): Creature {
269  const columns = walk.band?.columns ?? 80
270  const creatures = shown(walk, walk.band?.hasBoss ?? walk.isMainTurn)
271  const x = freeSpot(columns, SPRITE_WIDTH, creatures.map(c => c.walker))
272  const heading = x < lastX(columns) / 2 ? 1 : -1
273  const speed = 0.7 + (hash(id) % 6) / 10
274  const pace = paceFor(rateAt(walk.work.get(id) ?? [], walk.clockMs))
275  const worn = creatures.filter(c => c !== walk.boss).map(({ look }) => look.hatColor ?? look.color)
276
277  return {
278    walker: createWalker(x, heading, speed, SPRITE_WIDTH),
279    look: hatFor(type, worn),
280    type,
281    description,
282    since: now,
283    baseSpeed: speed,
284    pace,
285    drawnAt: now,
286    drawnSpeed: speed * pace,
287  }
288}
289
290/** Moves each subagent's creature `ms` along toward the pace its agent's work calls for. */
291function setPaces(walk: Walk, ms: number) {
292  const set = (creature: Creature, target: number) => {
293    creature.pace = ease(creature.pace, target, ms)
294    creature.walker.speed = creature.baseSpeed * creature.pace
295  }
296  for (const [id, creature] of walk.subagents) {
297    set(creature, paceFor(rateAt(walk.work.get(id) ?? [], walk.clockMs)))
298  }
299  for (const creature of walk.demo.values()) {
300    set(creature, creature.demoPace ?? 1)
301  }
302}
303
304/**
305 * On the desktop, sets out again at its new pace each creature whose pace has
306 * moved far enough from the one its SVG walks at: from where the SVG has it
307 * now, so it doesn't jump. One pausing at an edge waits until it walks on.
308 */
309async function redrawPaces($: EngineInterface, walk: Walk) {
310  const columns = walk.band?.columns
311  if (columns === undefined) {
312    return
313  }
314  const now = await $.clock.now()
315  let isChanged = false
316  for (const creature of [...walk.subagents.values(), ...walk.demo.values()]) {
317    const speed = creature.baseSpeed * creature.pace
318    if (Math.abs(speed - creature.drawnSpeed) <= creature.drawnSpeed * REDRAW_CHANGE) {
319      continue
320    }
321    const { walker, look } = creature
322    const at = strideAt(columns, {
323      x: walker.x,
324      heading: walker.heading,
325      speed: creature.drawnSpeed,
326      elapsedMs: now - creature.drawnAt,
327      look,
328    })
329    if (at.isPausing) {
330      continue
331    }
332    walker.x = Math.round(at.x)
333    walker.heading = at.heading
334    creature.drawnAt = now
335    creature.drawnSpeed = speed
336    isChanged = true
337  }
338  if (isChanged) {
339    $.ui.invalidate('ui.render')
340  }
341}
342
343/** Sends the demo's creatures home, and stops the walk if no one else is out. */
344function endDemo($: EngineInterface, walk: Walk) {
345  walk.demoTimer?.cancel()
346  walk.demoTimer = null
347  walk.demo.clear()
348  $.ui.invalidate('ui.render')
349  if (!walk.isMainTurn && !hasCompany(walk)) {
350    stop(walk)
351  }
352}
353
354function tick($: EngineInterface, walk: Walk) {
355  walk.ticks += 1
356  walk.clockMs += walk.timerMs
357  if (walk.timerMs !== TICK_MS || walk.ticks % LIST_EVERY === 0) {
358    relist($, walk)
359  }
360  if (walk.band === null) {
361    return
362  }
363  setPaces(walk, walk.timerMs)
364  // The desktop's SVG animates itself, drawn again only for a new pace; the
365  // terminal is repainted each tick.
366  if (walk.band.surface !== 'terminal') {
367    void redrawPaces($, walk)
368    return
369  }
370
371  const { requestId, columns, hasBoss } = walk.band
372  const creatures = shown(walk, hasBoss)
373  for (const creature of creatures) {
374    const others = creatures.filter(c => c !== creature).map(c => c.walker)
375    step(creature.walker, columns, others)
376  }
377  void $.ui.blit({ requestId, key: KEY, cells: frame(columns, creatures) })
378}
379
380export const register: Register = on => {
381  const walk: Walk = {
382    boss: {
383      walker: createWalker(0, 1, 1, BOSS_WIDTH),
384      look: BOSS,
385      type: '',
386      description: '',
387      since: 0,
388      baseSpeed: 1,
389      pace: 1,
390      drawnAt: 0,
391      drawnSpeed: 1,
392    },
393    subagents: new Map(),
394    work: new Map(),
395    demo: new Map(),
396    demoTimer: null,
397    isMainTurn: false,
398    isOn: true,
399    listings: 0,
400    applied: 0,
401    failures: 0,
402    listedAt: 0,
403    stale: new Set(),
404    isSynced: false,
405    band: null,
406    timer: null,
407    timerMs: TICK_MS,
408    ticks: 0,
409    clockMs: 0,
410  }
411
412  on('session.start', async ($, e, next) => {
413    walk.isOn = (await $.store.get(STORE_KEY)) !== false
414    await $.command.register({
415      name: 'posse',
416      description: "Show or hide the creatures that walk above the prompt while Claude and its agents work, or see who's who",
417      argumentHint: '[on|off|legend|demo]',
418      immediate: true,
419    })
420
421    return next(e)
422  })
423
424  on('command.run', { command: 'posse' }, async ($, e) => {
425    const arg = e.args.trim().toLowerCase()
426    if (arg === 'legend') {
427      return { text: legendText() }
428    }
429    if (arg === 'demo') {
430      if (!walk.isOn) {
431        return { text: 'The posse is off. Run `/posse on` first, then `/posse demo`.' }
432      }
433      if (walk.demo.size > 0) {
434        endDemo($, walk)
435        return { text: 'The demo is over.' }
436      }
437      const now = await $.clock.now()
438      for (const [i, [type, description, pace]] of DEMO.entries()) {
439        const id = `demo-${i}`
440        const creature = join(walk, id, type, description, now)
441        walk.demo.set(id, {
442          ...creature,
443          pace,
444          demoPace: pace,
445          drawnSpeed: creature.baseSpeed * pace,
446        })
447      }
448      walk.demoTimer = $.clock.after(DEMO_MS, () => endDemo($, walk))
449      wake($, walk)
450      $.ui.invalidate('ui.render')
451      return {
452        text: `The posse walks for ${DEMO_MS / 1000} seconds: the boss, a creature for each kind of agent, and a second Explore in a spare hat, each at a different pace as busier or idler agents would walk. The legend under them names each one. Run \`/posse demo\` again to end it sooner.`,
453      }
454    }
455    if (arg !== '' && arg !== 'on' && arg !== 'off') {
456      return { text: `\`${e.args.trim()}\` isn't a /posse option. Use ${OPTIONS}` }
457    }
458
459    walk.isOn = arg === '' ? !walk.isOn : arg === 'on'
460    await $.store.set(STORE_KEY, walk.isOn)
461    if (walk.isOn) {
462      if (walk.isMainTurn) {
463        wake($, walk)
464      }
465      relist($, walk)
466    } else {
467      walk.demoTimer?.cancel()
468      walk.demoTimer = null
469      walk.demo.clear()
470      stop(walk)
471    }
472    $.ui.invalidate('ui.render')
473
474    return {
475      text: walk.isOn
476        ? 'The posse is on. The boss walks above the prompt while Claude works, with a creature for each agent it starts. It stays on in new sessions.'
477        : 'The posse is off. It stays off in new sessions. Run `/posse` to bring it back.',
478    }
479  })
480
481  on('turn.start', async ($, e, next) => {
482    walk.isMainTurn = true
483    walk.boss.since = await $.clock.now()
484    walk.boss.drawnAt = walk.boss.since
485    wake($, walk)
486    $.ui.invalidate('ui.render')
487
488    return next(e)
489  })
490
491  on('turn.complete', ($, e, next) => {
492    // A subagent's run ends with a turn.complete of its own: its creature
493    // leaves once the listing no longer shows it active.
494    if (e.agentId === undefined) {
495      walk.isMainTurn = false
496      if (!hasCompany(walk)) {
497        stop(walk)
498      }
499      $.ui.invalidate('ui.render')
500    }
501    relist($, walk)
502
503    return next(e)
504  })
505
506  // Each response a subagent's model gives adds its output tokens to that
507  // agent's work, which sets how fast its creature walks.
508  on('turn.step', async function* ($, e, next) {
509    const result = yield* next(e)
510    if (e.agentId !== undefined && result.usage !== null && result.usage.output_tokens > 0) {
511      const work = walk.work.get(e.agentId) ?? []
512      work.push({ at: walk.clockMs, tokens: result.usage.output_tokens })
513      walk.work.set(e.agentId, work)
514    }
515
516    return result
517  })
518
519  on('agent.spawn', async ($, e, next) => {
520    const result = await next(e)
521    if (result.agentId !== undefined) {
522      relist($, walk)
523    }
524
525    return result
526  })
527
528  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
529    // Only the terminal's band reaches under the engine's columns.
530    const reach = e.surface === 'terminal' ? MARK_COLUMNS : 0
531    const columns = Math.min(e.props.bodyColumns + reach, 512)
532    // The boss leads the posse: it walks during the turn and for as long
533    // as any subagent is still out.
534    const hasBoss = e.props.isWorking || hasCompany(walk)
535
536    if ((e.surface !== 'terminal' && e.surface !== 'desktop') || !walk.isOn) {
537      walk.band = null
538      return next(e)
539    }
540
541    // A load (a hot reload among them) can come in mid-turn, or while
542    // background subagents walk: the first drawing catches up with both.
543    if (!walk.isSynced) {
544      walk.isSynced = true
545      if (e.props.isWorking && !walk.isMainTurn) {
546        walk.isMainTurn = true
547        walk.boss.since = await $.clock.now()
548        walk.boss.drawnAt = walk.boss.since
549        wake($, walk)
550      }
551      relist($, walk)
552    }
553
554    if (
555      !hasBoss ||
556      e.props.hasSurvey ||
557      e.props.maxRows < BAND_ROWS ||
558      columns < BOSS_WIDTH / 2
559    ) {
560      walk.band = null
561      return next(e)
562    }
563
564    const creatures = shown(walk, hasBoss)
565    for (const { walker } of creatures) {
566      walker.x = Math.min(walker.x, lastX(columns, walker.width))
567    }
568    walk.band = { requestId: e.requestId, columns, hasBoss, surface: e.surface }
569    if (walk.timer !== null) {
570      wake($, walk)
571    }
572
573    // The subagents' marks and tasks, in the order they joined.
574    const entries = [...walk.subagents.values(), ...walk.demo.values()].map(({ look, description }) => ({
575      text: description,
576      mark: look.hatColor ?? look.color,
577      color: look.color,
578    }))
579    const hasLegend = entries.length > 0 && e.props.maxRows >= BAND_ROWS + LEGEND_ROWS
580    const segments = hasLegend ? legendRow(columns, entries) : []
581
582    if (e.surface === 'desktop') {
583      const now = await $.clock.now()
584      const strides = creatures.map(({ walker, look, drawnAt, drawnSpeed }) => ({
585        x: walker.x,
586        heading: walker.heading,
587        speed: drawnSpeed,
588        elapsedMs: now - drawnAt,
589        look,
590      }))
591      const { Box, Svg, Text } = $.ui.resolve(e)
592      const svg = (
593        <Svg
594          source={posseSvg(columns, strides)}
595          alt={describe(hasBoss, [...walk.subagents.values(), ...walk.demo.values()].map(c => c.type))}
596          isInteractive
597        />
598      )
599      if (!hasLegend) {
600        return svg
601      }
602
603      return (
604        <Box flexDirection="column">
605          {svg}
606          <Text wrap="truncate">
607            {segments.map(({ text, color, isDim }) => (
608              <Text color={color === undefined ? undefined : hex(color)} dimColor={isDim}>
609                {text}
610              </Text>
611            ))}
612          </Text>
613        </Box>
614      )
615    }
616
617    const { Box, Raster, Text } = $.ui.resolve(e)
618
619    // Each row is placed absolutely so it can reach under the engine's
620    // columns; the ground sits at the top of its row, so feet touch it.
621    return (
622      <Box flexDirection="column">
623        <Box height={SPRITE_ROWS}>
624          <Box position="absolute" left={0} width={columns}>
625            <Raster
626              key={KEY}
627              columns={columns}
628              rows={SPRITE_ROWS}
629              cells={frame(columns, creatures)}
630            />
631          </Box>
632        </Box>
633        <Box height={1}>
634          <Box position="absolute" left={0} width={columns}>
635            <Text dimColor wrap="truncate">
636              {GROUND.repeat(columns)}
637            </Text>
638          </Box>
639        </Box>
640        {hasLegend && (
641          <Box height={LEGEND_ROWS}>
642            <Box position="absolute" left={0} width={columns}>
643              <Text wrap="truncate">
644                {segments.map(({ text, color, isDim }) => (
645                  <Text color={color === undefined ? undefined : hex(color)} dimColor={isDim}>
646                    {text}
647                  </Text>
648                ))}
649              </Text>
650            </Box>
651          </Box>
652        )}
653      </Box>
654    )
655  })
656}
657
hooks/legend.ts 87 lines
1// The row under the ground that says what each creature walks for: a mark in
2// its hat color, then its agent's task in its body color, in the order they
3// joined. Long tasks are cut short, the longest first, so more of them fit;
4// what doesn't fit even then is counted at the end.
5
6export type Entry = {
7  /** The agent's task, as the Agent call described it. */
8  text: string
9  /** The creature's hat color. */
10  mark: number
11  /** The creature's body color. */
12  color: number
13}
14
15/** A run of the row's text, in one color or dim. */
16export type Segment = { text: string; color?: number; isDim?: boolean }
17
18const GAP = '  '
19const MARK = '■'
20
21// The fewest characters a task is cut to, so it can still be told apart.
22const MIN_CHARS = 10
23
24const more = (count: number) => `+${count} more`
25
26const cut = (text: string, chars: number) =>
27  text.length <= chars ? text : `${text.slice(0, chars - 1)}…`
28
29const widthAt = (entries: readonly Entry[], chars: number) =>
30  entries.reduce((sum, { text }) => sum + MARK.length + 1 + Math.min(text.length, chars), 0) +
31  GAP.length * Math.max(0, entries.length - 1)
32
33/**
34 * The legend's row, `columns` wide at most. Tasks are cut to the most
35 * characters at which every entry fits, but no fewer than `MIN_CHARS`; what
36 * still doesn't fit is counted as `+N more`.
37 */
38export function legendRow(columns: number, entries: readonly Entry[]): Segment[] {
39  let chars = Math.max(0, ...entries.map(({ text }) => text.length))
40  while (chars > MIN_CHARS && widthAt(entries, chars) > columns) {
41    chars -= 1
42  }
43
44  return fit(columns, entries.map(entry => ({ ...entry, text: cut(entry.text, chars) })))
45}
46
47/** As many entries as fit whole, then `+N more`. */
48function fit(columns: number, entries: readonly Entry[]): Segment[] {
49  const row: Segment[] = []
50  let used = 0
51  for (const [i, entry] of entries.entries()) {
52    const gap = i === 0 ? '' : GAP
53    const after = entries.length - i - 1
54    // Room is kept after each entry for the count of those that follow.
55    const reserve = after > 0 ? GAP.length + more(after).length : 0
56    const width = gap.length + MARK.length + 1 + entry.text.length
57    if (used + width + reserve <= columns) {
58      row.push({ text: gap }, { text: MARK, color: entry.mark }, { text: ` ${entry.text}`, color: entry.color })
59      used += width
60      continue
61    }
62
63    if (i > 0) {
64      row.push({ text: `${GAP}${more(entries.length - i)}`, isDim: true })
65      return row
66    }
67    // Not even the first fits whole: it's cut short.
68    const room = columns - MARK.length - 1 - reserve
69    if (room < 2) {
70      return [{ text: more(entries.length), isDim: true }]
71    }
72    row.push(
73      { text: MARK, color: entry.mark },
74      { text: ` ${entry.text.slice(0, room - 1)}…`, color: entry.color },
75    )
76    if (after > 0) {
77      row.push({ text: `${GAP}${more(after)}`, isDim: true })
78    }
79    return row
80  }
81
82  return row
83}
84
85/** A color as the `Text` element takes it. */
86export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
87
hooks/pace.ts 40 lines
1// How fast a subagent's creature walks follows how hard its agent works: the
2// tokens its model wrote over the last few seconds. A busy agent's creature
3// runs; one waiting on a tool slows to a stroll. Times are the walk's own
4// milliseconds, which run as its timer fires: every tick on the terminal,
5// less often on the desktop.
6
7import { TICK_MS } from './walker'
8
9/** How far back an agent's work counts. */
10export const WINDOW_MS = 10_000
11/** Output tokens a second at which a creature walks its fastest. */
12export const FULL_RATE = 50
13/** The slowest and fastest a creature walks, as a share of its own speed. */
14export const MIN_PACE = 0.5
15export const MAX_PACE = 1.5
16// How much of the way to its new pace a creature goes in a tick: about a
17// second to settle.
18const EASE = 0.05
19
20/** One model response an agent got: when it ended, in the walk's milliseconds, and the tokens it wrote. */
21export type Work = { at: number; tokens: number }
22
23/** A creature's pace for its agent's output tokens a second. */
24export const paceFor = (rate: number) =>
25  MIN_PACE + (MAX_PACE - MIN_PACE) * Math.min(Math.max(rate, 0) / FULL_RATE, 1)
26
27/** Output tokens a second over the window ending at `now`; older work is dropped. */
28export function rateAt(work: Work[], now: number): number {
29  const since = now - WINDOW_MS
30  while (work.length > 0 && (work[0]?.at ?? 0) <= since) {
31    work.shift()
32  }
33
34  return work.reduce((sum, { tokens }) => sum + tokens, 0) / (WINDOW_MS / 1000)
35}
36
37/** The step from `pace` toward `target` that `ms` milliseconds make. */
38export const ease = (pace: number, target: number, ms = TICK_MS) =>
39  pace + (target - pace) * (1 - (1 - EASE) ** (ms / TICK_MS))
40
hooks/looks.ts 107 lines
1import type { Headwear, Look } from './sprite'
2
3export const ANTENNAE: Headwear = ['...#....#...', '....#..#....']
4export const TOP_HAT: Headwear = ['....####....', '.##########.']
5export const PROPELLER: Headwear = ['..###..###..', '....####....']
6export const CROWN: Headwear = ['..#..##..#..', '..########..']
7export const HALO: Headwear = ['...######...', '............']
8// Worn only by the types without a look of their own.
9export const EARS: Headwear = ['..#......#..', '..##....##..']
10export const HORNS: Headwear = ['.#........#.', '..#......#..']
11export const MOHAWK: Headwear = ['.....##.....', '....####....']
12export const SPROUT: Headwear = ['....##.##...', '......#.....']
13
14export const BOSS: Look = {
15  color: 0xd77757,
16  shadeColor: 0xb05a3e,
17  eyeColor: 0x1f1e1d,
18  headwear: null,
19  isBoss: true,
20}
21
22/** The built-in agent kinds' looks, with the words `/posse legend` uses for them. */
23export const LEGEND = [
24  { type: 'Explore', look: { color: 0x61afef, headwear: ANTENNAE, hatColor: 0xbcbcbc }, says: 'blue, with light gray antennae' },
25  { type: 'Plan', look: { color: 0x98c379, headwear: TOP_HAT, hatColor: 0xe06c75 }, says: 'green, with a red top hat' },
26  { type: 'general-purpose', look: { color: 0xc678dd, headwear: PROPELLER, hatColor: 0x56b6c2 }, says: 'purple, with a cyan propeller cap' },
27  { type: 'claude', look: { color: 0xe06c9f, headwear: CROWN, hatColor: 0xe5c07b }, says: 'pink, with a gold crown' },
28  { type: 'fork', look: { color: 0xf4a582, headwear: HALO, hatColor: 0xffe9a8 }, says: 'peach, with a pale yellow halo' },
29] as const satisfies readonly { type: string; look: Look; says: string }[]
30
31const BY_TYPE = new Map<string, Look>(LEGEND.map(({ type, look }) => [type, look]))
32
33// Bodies and hats for the other types, two lists that share no color.
34const COLORS = [0xb5cc5c, 0xb39ddb, 0xd19a66, 0xa0a8b7, 0x7ec699]
35const HAT_COLORS = [0xbcbcbc, 0xffd75f, 0x56b6c2, 0xe06c75]
36const HEADWEAR = [EARS, HORNS, MOHAWK, SPROUT]
37
38/** A subagent's look: its type's own, or one its type's name always picks. */
39export function lookFor(type: string): Look {
40  const known = BY_TYPE.get(type)
41  if (known !== undefined) {
42    return known
43  }
44
45  const h = hash(type)
46  return {
47    color: COLORS[h % COLORS.length] ?? BOSS.color,
48    headwear: HEADWEAR[(h >>> 8) % HEADWEAR.length] ?? EARS,
49    hatColor: HAT_COLORS[(h >>> 16) % HAT_COLORS.length] ?? 0xbcbcbc,
50  }
51}
52
53// Hats for a creature whose own hat color another one on the strip already
54// wears, so that two agents of a type, and their legend marks, tell apart.
55// Strong colors that read on a dark terminal and a light one; the first three
56// are no kind's own, so a spare rarely takes a hat a later agent wants.
57export const SPARE_HATS = [0xff8c42, 0xc678dd, 0x98c379, 0xe06c75, 0xe5c07b, 0x56b6c2]
58
59/** Whether two colors are close enough to pass for one another. */
60export function isNear(a: number, b: number): boolean {
61  const channel = (color: number, shift: number) => (color >> shift) & 255
62  const distance = Math.hypot(
63    ...[16, 8, 0].map(shift => channel(a, shift) - channel(b, shift)),
64  )
65
66  return distance < 60
67}
68
69/**
70 * A subagent's look, with a hat color unlike any of `worn`, the ones on the
71 * strip already: its type's own if that's free, otherwise the first spare
72 * that is free and unlike its body. With every spare taken, its own.
73 */
74export function hatFor(type: string, worn: readonly number[]): Look {
75  const look = lookFor(type)
76  const own = look.hatColor ?? look.color
77  const isFree = (color: number) => !worn.some(other => isNear(color, other))
78  if (isFree(own)) {
79    return look
80  }
81  const spare = SPARE_HATS.find(color => isFree(color) && !isNear(color, look.color))
82
83  return spare === undefined ? look : { ...look, hatColor: spare }
84}
85
86/** FNV-1a: the same text always gives the same number. */
87export function hash(text: string): number {
88  let h = 0x811c9dc5
89  for (let i = 0; i < text.length; i++) {
90    h ^= text.charCodeAt(i)
91    h = Math.imul(h, 0x01000193)
92  }
93
94  return h >>> 0
95}
96
97/** Which creature is which, as `/posse legend` prints it. */
98export function legendText(): string {
99  return [
100    "Who's who in the posse:",
101    '- **The boss** is Claude itself: orange, bareheaded, and bigger than the rest',
102    ...LEGEND.map(({ type, says }) => `- \`${type}\` agents: ${says}`),
103    '- **Any other agent**: ears, horns, a mohawk or a sprout, in colors that stay the same for each kind of agent',
104    '- **Two or more of one kind** at once: the later ones wear hats in other colors, so each can be told apart',
105  ].join('\n')
106}
107
hooks/sprite.ts 219 lines
1// The posse in pixels, drawn two by two in quadrant block characters, three
2// terminal rows tall. A subagent's creature is 12 pixels wide (6 columns, 7
3// at an odd pixel offset) with headwear on its top row; the boss is 16 wide
4// and fills all three rows, the biggest of them. A cell shows two colors at
5// most: its glyph in one, and the other behind it where the cell has no hole.
6
7export const SPRITE_WIDTH = 12
8export const BOSS_WIDTH = 16
9export const SPRITE_ROWS = 3
10
11export type Pose = {
12  facing: -1 | 0 | 1
13  /** 0 stands on all four legs; 1 and 2 lift alternate legs. */
14  step: 0 | 1 | 2
15  isBlinking: boolean
16}
17
18/** Two rows of pixels worn above the head, `#` filled. */
19export type Headwear = readonly [string, string]
20
21export type Look = {
22  color: number
23  headwear: Headwear | null
24  /** The headwear's own color, so it stands out from the body. */
25  hatColor?: number
26  /** The shaded side's color; without one, a darker shade of the body's. */
27  shadeColor?: number
28  /** The eyes' color; without one, near black. */
29  eyeColor?: number
30  isBoss?: boolean
31}
32
33/** One creature where it stands: its left edge in pixels, two per column. */
34export type Figure = { x: number; pose: Pose; look: Look }
35
36export const spriteWidth = (look: Look) => (look.isBoss ? BOSS_WIDTH : SPRITE_WIDTH)
37
38// A subagent's creature, drawn walking left with its shaded side (`%`) at
39// its back on the right, and dark eyes (`o`); walking right it's mirrored.
40// Facing you, it stands square on, with no side showing. As on the boss,
41// each eye keeps body on both sides at either pixel offset.
42const BARE: Headwear = ['............', '............']
43const BODY = '..######%%..'
44const ARMS = '########%%%%'
45const EYES = '..#o##o#%%..'
46const LEGS = {
47  0: '..#.#..#.%..',
48  1: '..#....#....',
49  2: '....#....%..',
50} as const
51const FRONT_BODY = '..########..'
52const FRONT_ARMS = '############'
53const FRONT_EYES = '..##o##o##..'
54const FRONT_LEGS = {
55  0: '..#.#..#.#..',
56  1: '..#....#....',
57  2: '....#....#..',
58} as const
59
60const EYE_COLOR = 0x1f1e1d
61
62// The boss, after Claude Code's mascot: a block with a shaded side (`%`),
63// dark eyes (`o`) and four short legs. Drawn here walking left, its shaded
64// side behind it on the right; walking right it's mirrored, so the shade
65// stays at its back. Facing you, it stands as the mascot does, shaded on
66// the right.
67const BOSS_BODY = '..##########%%..'
68const BOSS_ARMS = '############%%%%'
69// Each eye keeps body on both sides, so a cell never has to choose between
70// an eye and a hole or the shade, at either pixel offset.
71const BOSS_EYES = {
72  [-1]: '..#o#####o##%%..',
73  0: '..##o#####o#%%..',
74} as const
75const BOSS_LEGS = {
76  0: '...#..#..#..%...',
77  1: '...#.....#......',
78  2: '......#.....%...',
79} as const
80
81const mirror = (row: string) => [...row].reverse().join('')
82
83// Indexed by the filled quadrants: top-left 1, top-right 2, bottom-left 4,
84// bottom-right 8.
85const QUADRANTS = [...' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█']
86
87const PIXEL_ROWS = SPRITE_ROWS * 2
88const EMPTY = -1
89const TERMINAL_DEFAULT = 0x01000000
90
91type Cell = { glyph: string; color: number; background: number }
92
93/**
94 * A creature's rows of pixels: `#` its body, `+` its headwear, `%` its shaded
95 * side, `o` its eyes, `.` none of them.
96 */
97export function spritePixels(pose: Pose, look: Look): readonly string[] {
98  if (look.isBoss) {
99    const eyes = pose.isBlinking ? BOSS_BODY : BOSS_EYES[pose.facing === 1 ? -1 : pose.facing]
100    const rows = [BOSS_BODY, eyes, BOSS_ARMS, BOSS_BODY, BOSS_BODY, BOSS_LEGS[pose.step]]
101    return pose.facing === 1 ? rows.map(mirror) : rows
102  }
103
104  const [crown = '', brim = ''] = (look.headwear ?? BARE).map(row => row.replaceAll('#', '+'))
105  if (pose.facing === 0) {
106    const eyes = pose.isBlinking ? FRONT_BODY : FRONT_EYES
107    return [crown, brim, FRONT_BODY, eyes, FRONT_ARMS, FRONT_LEGS[pose.step]]
108  }
109  const body = [BODY, pose.isBlinking ? BODY : EYES, ARMS, LEGS[pose.step]]
110  return [crown, brim, ...(pose.facing === 1 ? body.map(mirror) : body)]
111}
112
113/** A color 22% darker, channel by channel. */
114const darker = (color: number) =>
115  [16, 8, 0].reduce((out, shift) => out | (Math.round(((color >> shift) & 255) * 0.78) << shift), 0)
116
117/** The color a sprite pixel paints in, or none. */
118export function pixelColor(pixel: string | undefined, look: Look): number | null {
119  if (pixel === '#') return look.color
120  if (pixel === '+') return look.hatColor ?? look.color
121  if (pixel === '%') return look.shadeColor ?? darker(look.color)
122  if (pixel === 'o') return look.eyeColor ?? EYE_COLOR
123  return null
124}
125
126function paint(columns: number, figures: readonly Figure[]): Cell[][] {
127  // Each pixel holds the color of the last figure to fill it.
128  const width = columns * 2
129  const canvas = Array.from({ length: PIXEL_ROWS }, () =>
130    new Array<number>(width).fill(EMPTY),
131  )
132  for (const { x, pose, look } of figures) {
133    spritePixels(pose, look).forEach((row, py) => {
134      for (let i = 0; i < row.length; i++) {
135        const px = x + i
136        const color = pixelColor(row[i], look)
137        if (color !== null && px >= 0 && px < width) {
138          canvas[py]![px] = color
139        }
140      }
141    })
142  }
143
144  const cells: Cell[][] = []
145  for (let cy = 0; cy < SPRITE_ROWS; cy++) {
146    const top = canvas[cy * 2]!
147    const bottom = canvas[cy * 2 + 1]!
148    const row: Cell[] = []
149    for (let cx = 0; cx < columns; cx++) {
150      const quadrants = [top[cx * 2], top[cx * 2 + 1], bottom[cx * 2], bottom[cx * 2 + 1]]
151      // The glyph takes the color most of the cell is. A cell with no hole
152      // shows its next color behind the glyph; one with a hole shows the
153      // terminal there instead, and paints all its pixels in the glyph's
154      // color. A cell can't show a third color.
155      const counts = new Map<number, number>()
156      for (const color of quadrants) {
157        if (color !== undefined && color !== EMPTY) {
158          counts.set(color, (counts.get(color) ?? 0) + 1)
159        }
160      }
161      const [color = TERMINAL_DEFAULT, behind] = [...counts.keys()].sort(
162        (a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0),
163      )
164      const hasHole = quadrants.includes(EMPTY)
165      const background = hasHole || behind === undefined ? TERMINAL_DEFAULT : behind
166      let bits = 0
167      quadrants.forEach((pixel, i) => {
168        if (pixel !== EMPTY && (hasHole || pixel === color)) {
169          bits |= 1 << i
170        }
171      })
172      row.push({ glyph: QUADRANTS[bits] ?? ' ', color, background })
173    }
174    cells.push(row)
175  }
176
177  return cells
178}
179
180/** The band's rows as text, each figure drawn where it stands. */
181export function glyphRows(columns: number, figures: readonly Figure[]): string[] {
182  return paint(columns, figures).map(row => row.map(cell => cell.glyph).join(''))
183}
184
185/** The figures packed as a Raster's `cells`, on the terminal's own background where they leave a hole. */
186export function rasterCells(columns: number, figures: readonly Figure[]): string {
187  const cells = paint(columns, figures).flat()
188  const view = new DataView(new ArrayBuffer(cells.length * 12))
189
190  cells.forEach(({ glyph, color, background }, i) => {
191    view.setUint32(i * 12, glyph.codePointAt(0) ?? 0x20, true)
192    view.setUint32(i * 12 + 4, color, true)
193    view.setUint32(i * 12 + 8, background, true)
194  })
195
196  return toBase64(new Uint8Array(view.buffer))
197}
198
199const BASE64 =
200  'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
201
202export function toBase64(bytes: Uint8Array): string {
203  const out: string[] = []
204  for (let i = 0; i < bytes.length; i += 3) {
205    const b0 = bytes[i] ?? 0
206    const b1 = bytes[i + 1]
207    const b2 = bytes[i + 2]
208    const n = (b0 << 16) | ((b1 ?? 0) << 8) | (b2 ?? 0)
209    out.push(
210      BASE64[(n >> 18) & 63] ?? '',
211      BASE64[(n >> 12) & 63] ?? '',
212      b1 === undefined ? '=' : (BASE64[(n >> 6) & 63] ?? ''),
213      b2 === undefined ? '=' : (BASE64[n & 63] ?? ''),
214    )
215  }
216
217  return out.join('')
218}
219
hooks/svg.ts 195 lines
1// The desktop draws the posse as one SVG that animates itself: the same
2// sprites, each pixel a rect twice as tall as it is wide (a terminal
3// quadrant's shape), walking on SMIL animations the desktop plays on its
4// own, so nothing is sent per frame. Their phase comes from how long each
5// creature has been walking, so a redraw picks up where the last one was.
6
7import { SPRITE_ROWS, pixelColor, spriteWidth, spritePixels } from './sprite'
8import type { Look, Pose } from './sprite'
9import { BLINK_EVERY, BLINK_TICKS, PAUSE_TICKS, TICK_MS, lastX } from './walker'
10
11/** One creature's walk: where it started, which way, how fast, and for how long. */
12export type Stride = {
13  x: number
14  heading: 1 | -1
15  /** Pixels a tick, as the terminal's walker counts them. */
16  speed: number
17  /** How long it has been walking, in milliseconds. */
18  elapsedMs: number
19  look: Look
20}
21
22const PIXEL_HEIGHT = 2
23const GROUND_Y = SPRITE_ROWS * 2 * PIXEL_HEIGHT
24const HEIGHT = GROUND_Y + 0.5
25const STANDING: Pose = { facing: 0, step: 0, isBlinking: false }
26const FACINGS = [-1, 0, 1] as const
27const STEPS = [0, 1, 2] as const
28
29const seconds = (ms: number) => `${(ms / 1000).toFixed(3)}s`
30const keyTimes = (...times: number[]) => times.map(t => t.toFixed(4)).join(';')
31const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
32
33/** A row's `pixel`s as rects, each run of them one rect. */
34function rects(row: string, py: number, pixel: string): string {
35  let out = ''
36  for (let i = 0; i < row.length; ) {
37    if (row[i] !== pixel) {
38      i += 1
39      continue
40    }
41    let j = i
42    while (row[j] === pixel) {
43      j += 1
44    }
45    out += `<rect x="${i}" y="${py * PIXEL_HEIGHT}" width="${j - i}" height="${PIXEL_HEIGHT}"/>`
46    i = j
47  }
48  return out
49}
50
51function toggle(values: string, times: string, dur: string, begin: string) {
52  return `<animate attributeName="opacity" calcMode="discrete" values="${values}" keyTimes="${times}" dur="${dur}" begin="${begin}" repeatCount="indefinite"/>`
53}
54
55const layer = (content: string, opacity: 0 | 1, animation = '') =>
56  `<g opacity="${opacity}">${animation}${content}</g>`
57
58function creature(columns: number, stride: Stride): string {
59  const { look } = stride
60  const rowsFor = (pose: Partial<Pose>) => spritePixels({ ...STANDING, ...pose }, look)
61  const standing = rowsFor({})
62
63  // Body pixels in the creature's fill; headwear, shade and eyes in their own colors.
64  const draw = (rows: readonly (readonly [string, number])[]) => {
65    const body = rows.map(([row, py]) => rects(row, py, '#')).join('')
66    const rest = ['+', '%', 'o'].map(pixel => {
67      const drawn = rows.map(([row, py]) => rects(row, py, pixel)).join('')
68      const color = pixelColor(pixel, look)
69      return drawn === '' || color === null ? '' : `<g fill="${hex(color)}">${drawn}</g>`
70    })
71    return body + rest.join('')
72  }
73
74  // Which rows change with the legs, and which other rows with the eyes or
75  // the way it faces; the rest stay put. The legs too may differ by facing.
76  const legRows = standing.flatMap((row, py) =>
77    STEPS.some(step => rowsFor({ step })[py] !== row) ? [py] : [],
78  )
79  const eyeRows = standing.flatMap((row, py) =>
80    !legRows.includes(py) &&
81    (FACINGS.some(facing => rowsFor({ facing })[py] !== row) ||
82      rowsFor({ isBlinking: true })[py] !== row)
83      ? [py]
84      : [],
85  )
86  const fixed = draw(
87    standing.flatMap((row, py) =>
88      eyeRows.includes(py) || legRows.includes(py) ? [] : [[row, py] as const],
89    ),
90  )
91  const legs = (step: Pose['step'], facing: Pose['facing'] = 0) =>
92    draw(legRows.map(py => [rowsFor({ step, facing })[py] ?? '', py] as const))
93
94  // Eyes are holes in the body, or the boss's dark pixels; a blink covers them for a moment.
95  const blinkDur = seconds(BLINK_EVERY * TICK_MS)
96  const blinkAt = keyTimes(0, (BLINK_EVERY - BLINK_TICKS) / BLINK_EVERY)
97  const eyes = (facing: Pose['facing']) => {
98    const open = eyeRows.map(py => [rowsFor({ facing })[py] ?? '', py] as const)
99    const lids = eyeRows.map(py => {
100      const shut = rowsFor({ facing, isBlinking: true })[py] ?? ''
101      const row = rowsFor({ facing })[py] ?? ''
102      return [[...shut].map((p, i) => (p === '#' && row[i] !== '#' ? '#' : '.')).join(''), py] as const
103    })
104    const blink = toggle('0;1', blinkAt, blinkDur, seconds(-stride.elapsedMs))
105    return draw(open) + layer(draw(lids), 0, blink)
106  }
107
108  const end = lastX(columns, spriteWidth(look))
109  const fill = hex(look.color)
110  if (end === 0) {
111    return `<g fill="${fill}">${fixed}${eyes(0)}${legs(0)}</g>`
112  }
113
114  // Right across, a pause facing out, back left, a pause: one period.
115  const travelMs = (end / stride.speed) * TICK_MS
116  const pauseMs = PAUSE_TICKS * TICK_MS
117  const periodMs = 2 * (travelMs + pauseMs)
118  const a = travelMs / periodMs
119  const b = (travelMs + pauseMs) / periodMs
120  const c = (2 * travelMs + pauseMs) / periodMs
121  const startMs =
122    stride.heading === 1
123      ? (stride.x / end) * travelMs
124      : travelMs + pauseMs + ((end - stride.x) / end) * travelMs
125  const dur = seconds(periodMs)
126  const begin = seconds(-(startMs + stride.elapsedMs))
127  const pausing = toggle('0;1;0;1', keyTimes(0, a, b, c), dur, begin)
128  const goingRight = toggle('1;0', keyTimes(0, a), dur, begin)
129  const goingLeft = toggle('0;1;0', keyTimes(0, b, c), dur, begin)
130
131  const move = `<animateTransform attributeName="transform" type="translate" values="0 0;${end} 0;${end} 0;0 0;0 0" keyTimes="${keyTimes(0, a, b, c, 1)}" dur="${dur}" begin="${begin}" repeatCount="indefinite"/>`
132  const facing =
133    layer(eyes(1), 1, goingRight) + layer(eyes(0), 0, pausing) + layer(eyes(-1), 0, goingLeft)
134
135  // Two pixels a step, as on the terminal.
136  const stepDur = seconds((4 / stride.speed) * TICK_MS)
137  const stepBegin = seconds(-stride.elapsedMs)
138  const stepping = (facing: 1 | -1) =>
139    layer(legs(1, facing), 1, toggle('1;0', '0;0.5', stepDur, stepBegin)) +
140    layer(legs(2, facing), 0, toggle('0;1', '0;0.5', stepDur, stepBegin))
141  const feet =
142    layer(legs(0), 0, pausing) +
143    layer(stepping(1), 1, goingRight) +
144    layer(stepping(-1), 0, goingLeft)
145
146  return `<g fill="${fill}">${move}${fixed}${facing}${feet}</g>`
147}
148
149/**
150 * Where a creature walking `stride` is once `stride.elapsedMs` have gone by,
151 * as the SVG draws it: its left edge in pixels, which way it heads, and
152 * whether it is pausing at an edge.
153 */
154export function strideAt(
155  columns: number,
156  stride: Stride,
157): { x: number; heading: 1 | -1; isPausing: boolean } {
158  const end = lastX(columns, spriteWidth(stride.look))
159  if (end === 0) {
160    return { x: 0, heading: stride.heading, isPausing: false }
161  }
162
163  const travelMs = (end / stride.speed) * TICK_MS
164  const pauseMs = PAUSE_TICKS * TICK_MS
165  const periodMs = 2 * (travelMs + pauseMs)
166  const startMs =
167    stride.heading === 1
168      ? (stride.x / end) * travelMs
169      : travelMs + pauseMs + ((end - stride.x) / end) * travelMs
170  const t = (startMs + stride.elapsedMs) % periodMs
171  if (t < travelMs) {
172    return { x: (t / travelMs) * end, heading: 1, isPausing: false }
173  }
174  if (t < travelMs + pauseMs) {
175    return { x: end, heading: -1, isPausing: true }
176  }
177  if (t < 2 * travelMs + pauseMs) {
178    return { x: end - ((t - travelMs - pauseMs) / travelMs) * end, heading: -1, isPausing: false }
179  }
180
181  return { x: 0, heading: 1, isPausing: true }
182}
183
184/** The posse walking on a ground line, `columns` wide at two pixels a column. */
185export function posseSvg(columns: number, strides: readonly Stride[]): string {
186  const width = columns * 2
187
188  return (
189    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${HEIGHT}" width="100%" preserveAspectRatio="xMinYMax meet" shape-rendering="crispEdges">` +
190    `<rect x="0" y="${GROUND_Y}" width="${width}" height="0.5" fill="#8a8a8a" opacity="0.6"/>` +
191    strides.map(stride => creature(columns, stride)).join('') +
192    '</svg>'
193  )
194}
195
hooks/walker.ts 135 lines
1import { SPRITE_WIDTH } from './sprite'
2import type { Pose } from './sprite'
3
4export const TICK_MS = 50
5// Ticks spent facing the person at each edge before turning around.
6export const PAUSE_TICKS = Math.round(800 / TICK_MS)
7// Ticks spent standing still after bumping into another creature.
8export const BUMP_TICKS = Math.round(200 / TICK_MS)
9export const BLINK_EVERY = Math.round(3200 / TICK_MS)
10export const BLINK_TICKS = Math.round(160 / TICK_MS)
11// The fewest free pixels between two creatures side by side: a column, so
12// no cell holds both.
13export const MIN_GAP = 2
14
15export type Walker = {
16  /** The sprite's left edge, in pixels: two per terminal column. */
17  x: number
18  heading: 1 | -1
19  /** Ticks left standing still. */
20  pause: number
21  /** Pixels walked so far; drives the legs. */
22  travelled: number
23  ticks: number
24  /** Pixels a tick, on average. */
25  speed: number
26  /** The part of a pixel walked toward the next. */
27  progress: number
28  /** The sprite's width, in pixels. */
29  width: number
30}
31
32export const createWalker = (
33  x = 0,
34  heading: 1 | -1 = 1,
35  speed = 1,
36  width = SPRITE_WIDTH,
37): Walker => ({ x, heading, pause: 0, travelled: 0, ticks: 0, speed, progress: 0, width })
38
39/** The furthest left edge that keeps a sprite `width` wide in `columns`. */
40export const lastX = (columns: number, width = SPRITE_WIDTH) =>
41  Math.max(0, columns * 2 - width)
42
43/**
44 * Free pixels between a sprite at `x`, `width` wide, and another; below 0
45 * they overlap, by less the closer they are to pulling apart either way.
46 */
47const gapTo = (x: number, width: number, other: Walker) =>
48  Math.max(other.x - (x + width), x - (other.x + other.width))
49
50/** One tick: `speed` pixels along, stopping to turn at an edge or a bump. */
51export function step(
52  walker: Walker,
53  columns: number,
54  others: readonly Walker[] = [],
55): void {
56  walker.ticks += 1
57
58  if (walker.pause > 0) {
59    walker.pause -= 1
60    if (walker.pause === 0) {
61      walker.heading = walker.heading === 1 ? -1 : 1
62    }
63    return
64  }
65
66  // A strip too crowded for everyone to keep their distance lets them walk
67  // through each other, as on the desktop, rather than stand stuck.
68  const needed = others.reduce(
69    (sum, other) => sum + other.width + MIN_GAP,
70    walker.width + MIN_GAP,
71  )
72  const blocking = needed > columns * 2 ? [] : others
73
74  walker.progress += walker.speed
75  while (walker.progress >= 1 && walker.pause === 0) {
76    walker.progress -= 1
77    advance(walker, columns, blocking)
78  }
79}
80
81function advance(walker: Walker, columns: number, others: readonly Walker[]) {
82  const end = lastX(columns, walker.width)
83  const x = Math.min(Math.max(walker.x + walker.heading, 0), end)
84
85  // Only a step that closes in on someone too near is a bump, so two
86  // creatures that start out overlapping can still walk apart.
87  const isBump = others.some(other => {
88    const gap = gapTo(x, walker.width, other)
89    return gap < MIN_GAP && gap < gapTo(walker.x, walker.width, other)
90  })
91  if (isBump) {
92    walker.pause = BUMP_TICKS
93    walker.progress = 0
94    return
95  }
96
97  walker.x = x
98  walker.travelled += 1
99
100  const isAtEdge = walker.heading === 1 ? x >= end : x <= 0
101  if (isAtEdge) {
102    walker.pause = PAUSE_TICKS
103    walker.progress = 0
104  }
105}
106
107/** Where a newcomer `width` wide starts: as far from everyone already walking as it can. */
108export function freeSpot(
109  columns: number,
110  width: number,
111  others: readonly Walker[],
112): number {
113  let best = 0
114  let bestGap = -Infinity
115  for (let x = 0; x <= lastX(columns, width); x++) {
116    const gap = Math.min(...others.map(other => gapTo(x, width, other)))
117    if (gap > bestGap) {
118      best = x
119      bestGap = gap
120    }
121  }
122
123  return best
124}
125
126export function pose(walker: Walker): Pose {
127  const isStanding = walker.pause > 0
128
129  return {
130    facing: isStanding ? 0 : walker.heading,
131    step: isStanding ? 0 : Math.floor(walker.travelled / 2) % 2 === 0 ? 1 : 2,
132    isBlinking: walker.ticks % BLINK_EVERY >= BLINK_EVERY - BLINK_TICKS,
133  }
134}
135