SLOPSHOPPER

pixel-agents

Animated pixel characters for running subagents, drawn under the prompt

newspinnerguardprompttimeragents
v0.1.0MITupdated 2026-10-08dimivelev/pixel-agents
A shopper browsing a rack in a slop shop
README

pixel agents

A Claude Code mod that draws each running subagent as an animated pixel character, in the terminal, under the prompt's hint line.

Five agents at their desks under the prompt: the crowned main agent, three subagents running Bash or thinking, and one finished with a green check

The main agent wears a crown. Each subagent gets its own desk, shirt color and label:

  • It drops in when it starts.
  • Its hands type while it runs Bash, Edit or Write.
  • Its eyes scan while it runs Read, Grep or Glob.
  • A thought bubble grows between tool calls.
  • It raises its arms with a green check when it finishes, then fades out after 8 seconds.

The strip appears only while subagents are running. Otherwise the hint line is unchanged.

Install

/plugin install pixel-agents --marketplace dimivelev/pixel-agents

Answer y to add the marketplace, then pick a scope.

How it works

The mod uses Claude Code's function hooks:

  • agent.spawn adds a character when a subagent starts.
  • tool.call tracks which tool each agent is running. Each call carries the agentId of the loop it runs in.
  • turn.complete marks a subagent done.
  • ui.render on PromptHint draws the engine's own line, then a Raster of half-block cells below it, about five times a second.
FileContents
hooks/register.tsxThe hooks and per-agent state.
hooks/sprites.tsSprites, poses and cell encoding.
tests/strip.test.tsxBehavior tests. Run them with claude plugin test ..

Previews

previews/ holds two standalone terminal scripts that animate demo agents. Use them to compare looks before changing the sprites.

node previews/preview.mjs      # six styles: office, robots, cats, slimes, mini, parade
node previews/preview-hd.mjs   # shaded, anti-aliased characters at 16, 24, 32 and 44 px

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 80 lines
1import type { Register } from 'claude-code'
2
3import { type Agent, DONE_LINGER_MS, drawCards, toBase64 } from './sprites'
4
5const FRAME_MS = 200
6const PER_LINE = 6
7
8export const register: Register = (on) => {
9  const agents = new Map<string, Agent>()
10  const main: Agent = { id: 'main', name: 'main', type: 'session', boss: true, calls: 0, born: 0 }
11
12  const visible = (now: number) => {
13    for (const [id, a] of agents) if (a.endedAt !== undefined && now - a.endedAt > DONE_LINGER_MS) agents.delete(id)
14    return agents.size ? [main, ...agents.values()] : []
15  }
16
17  on('session.start', async ($, e, next) => {
18    main.born = await $.clock.now()
19    let wasShown = false
20    $.clock.every(FRAME_MS, () => {
21      if (agents.size || wasShown) $.ui.invalidate('ui.render')
22      wasShown = agents.size > 0
23    })
24    return next(e)
25  })
26
27  on('prompt.submit', ($, e, next) => {
28    main.idle = false
29    return next(e)
30  }).catch(($, e, next) => (next.called ? undefined : next(e)))
31
32  on('agent.spawn', async ($, e, next) => {
33    const result = await next(e)
34    if ('agentId' in result && result.agentId) {
35      agents.set(result.agentId, {
36        id: result.agentId,
37        name: e.description || e.subagentType,
38        type: e.subagentType,
39        model: result.model,
40        calls: 0,
41        born: await $.clock.now(),
42      })
43    }
44    return result
45  }).catch(($, e, next) => (next.called ? undefined : next(e)))
46
47  on('tool.call', async ($, e, next) => {
48    const actor = e.agentId ? agents.get(e.agentId) : main
49    if (!actor || actor.endedAt !== undefined) return next(e)
50    actor.tool = e.tool
51    actor.calls += 1
52    try {
53      return await next(e)
54    } finally {
55      if (actor.tool === e.tool) actor.tool = undefined
56    }
57  }).catch(($, e, next) => (next.called ? undefined : next(e)))
58
59  on('turn.complete', async ($, e, next) => {
60    if (!e.agentId) main.idle = true
61    const agent = e.agentId ? agents.get(e.agentId) : undefined
62    if (agent) Object.assign(agent, { endedAt: await $.clock.now(), tool: undefined })
63    return next(e)
64  })
65
66  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
67    const now = await $.clock.now()
68    const shown = visible(now)
69    if (!shown.length || e.surface !== 'terminal') return next(e)
70    const { columns, rows, words } = drawCards(shown, now, Math.floor(now / FRAME_MS), PER_LINE)
71    const { Box, Raster } = $.ui.resolve(e)
72    return (
73      <Box flexDirection="column">
74        {await next(e)}
75        <Raster key="pixel-agents" columns={columns} rows={rows} cells={toBase64(words)} />
76      </Box>
77    )
78  })
79}
80
hooks/sprites.ts 202 lines
1export type Activity = 'spawn' | 'type' | 'read' | 'think' | 'delegate' | 'ask' | 'done' | 'sleep'
2
3export type Agent = {
4  id: string
5  name: string
6  type: string
7  model?: string
8  boss?: boolean
9  tool?: string
10  calls: number
11  born: number
12  endedAt?: number
13  idle?: boolean
14}
15
16export const SPAWN_MS = 1_200
17export const DONE_LINGER_MS = 8_000
18export const CARD_COLS = 16
19const SPRITE_W = 14
20
21const BODY = [
22  '....HHHHHH....',
23  '...HHHHHHHH...',
24  '...HSSSSSSH...',
25  '...SSSSSSSS...',
26  '...SSSSSSSS...',
27  '....SSSSSS....',
28  '..BBBBBBBBBB..',
29  '.BBBBBBBBBBBB.',
30  '.BBBKKKKKKBBB.',
31  '..DDKKGGKKDD..',
32  'DDDDDDDDDDDDDD',
33  '.LL........LL.',
34]
35const CROWN = ['....Y.YY.Y....', '....YYYYYY....']
36const EYES_ROW = 3
37const HANDS_ROW = 9
38
39type Pixel = [key: string, row: number, col: number]
40type Bubble = 'think' | 'done' | 'sleep' | 'alert'
41const BUBBLES: Record<Bubble, (f: number) => Pixel[]> = {
42  think: (f) => [['W', 0, 11], ['W', 0, 12], ['W', 0, 13]].slice(0, 1 + Math.min(2, f % 4)) as Pixel[],
43  done: () => [['C', 2, 11], ['C', 3, 12], ['C', 2, 13], ['C', 1, 13], ['C', 0, 13]],
44  sleep: (f) => (f % 6 < 3 ? [['W', 0, 11], ['W', 0, 12], ['W', 1, 12], ['W', 2, 11], ['W', 2, 12]] : []),
45  alert: (f) => (f % 2 ? [['R', 0, 12], ['R', 1, 12], ['R', 3, 12]] : []),
46}
47
48const ACTIVITY_BY_TOOL: Record<string, Activity> = {
49  Edit: 'type', Write: 'type', MultiEdit: 'type', NotebookEdit: 'type', Bash: 'type', PowerShell: 'type',
50  Read: 'read', Grep: 'read', Glob: 'read', WebFetch: 'read', WebSearch: 'read', ToolSearch: 'read', Skill: 'read',
51  Agent: 'delegate', Task: 'delegate', AskUserQuestion: 'ask',
52}
53
54const BUBBLE_BY_ACTIVITY: Partial<Record<Activity, Bubble>> = {
55  think: 'think', delegate: 'think', done: 'done', sleep: 'sleep', ask: 'alert',
56}
57
58export class Grid {
59  private readonly keys: string[]
60  constructor(readonly rows: number, readonly cols: number, fill = '.') {
61    this.keys = Array<string>(rows * cols).fill(fill)
62  }
63  static from(lines: readonly string[]): Grid {
64    const grid = new Grid(lines.length, SPRITE_W)
65    lines.forEach((line, r) => [...line].forEach((k, c) => grid.set(r, c, k)))
66    return grid
67  }
68  get(r: number, c: number): string {
69    return this.keys[r * this.cols + c] ?? '.'
70  }
71  set(r: number, c: number, k: string): void {
72    if (r >= 0 && r < this.rows && c >= 0 && c < this.cols) this.keys[r * this.cols + c] = k
73  }
74  paint(lines: readonly string[], top = 0): void {
75    lines.forEach((line, r) => [...line].forEach((k, c) => { if (k !== '.') this.set(top + r, c, k) }))
76  }
77}
78
79const SHIRTS = [0xe5534b, 0x57ab5a, 0x539bf5, 0xc69026, 0xb083f0, 0x39c5cf, 0xf69d50, 0xe275ad]
80const PALETTE: Record<string, number> = {
81  H: 0x3b2a1e, S: 0xf1c27d, K: 0x9aa4b2, G: 0x7ee787, D: 0x8b5a2b, L: 0x5a3a1a,
82  Y: 0xffd33d, W: 0xf0f6fc, C: 0x3fb950, R: 0xf85149, E: 0x1f2328,
83}
84const DEFAULT = 0x01000000
85const TEXT = 0xd0d7de
86const DIM = 0x7d8590
87
88export const ACTIVITY_LABEL: Record<Activity, string> = {
89  spawn: 'arriving', type: 'working', read: 'reading', think: 'thinking',
90  delegate: 'delegating', ask: 'asking you', done: 'done', sleep: 'idle',
91}
92
93export function activityOf(agent: Agent, now: number): Activity {
94  if (agent.endedAt !== undefined) return 'done'
95  if (agent.idle) return 'sleep'
96  if (now - agent.born < SPAWN_MS) return 'spawn'
97  if (!agent.tool) return 'think'
98  return ACTIVITY_BY_TOOL[agent.tool] ?? 'type'
99}
100
101export function composeSprite(agent: Agent, activity: Activity, frame: number, now: number): Grid {
102  const grid = Grid.from(BODY)
103  if (agent.boss) grid.paint(CROWN)
104  const eyeShift = activity === 'read' ? [-1, 0, 1, 0][frame % 4] ?? 0 : 0
105  const eyesClosed = activity === 'sleep' || (activity !== 'read' && frame % 17 === 0)
106  for (const col of [5, 8]) grid.set(EYES_ROW, col + eyeShift, eyesClosed ? 'H' : 'E')
107  if (activity === 'type' || activity === 'delegate') {
108    const left = frame % 2 === 0
109    grid.set(HANDS_ROW - (left ? 1 : 0), 3, 'S')
110    grid.set(HANDS_ROW - (left ? 0 : 1), 10, 'S')
111    if (activity === 'type' && frame % 2) grid.set(HANDS_ROW, 6, 'W')
112  } else if (activity === 'done') {
113    for (const r of [4, 5, 6]) for (const c of [1, 12]) grid.set(r, c, 'S')
114  } else {
115    grid.set(HANDS_ROW, 3, 'S')
116    grid.set(HANDS_ROW, 10, 'S')
117  }
118  const bubble = BUBBLE_BY_ACTIVITY[activity]
119  if (bubble) for (const [k, r, c] of BUBBLES[bubble](frame)) grid.set(r, c, k)
120  if (activity !== 'spawn') return grid
121  const drop = Math.max(0, BODY.length - Math.floor(((now - agent.born) / SPAWN_MS) * BODY.length * 1.5))
122  const dropped = new Grid(BODY.length, SPRITE_W)
123  for (let r = 0; r + drop < BODY.length; r++) for (let c = 0; c < SPRITE_W; c++) dropped.set(r + drop, c, grid.get(r, c))
124  dropped.paint(BODY.slice(-2), BODY.length - 2)
125  return dropped
126}
127
128function shirtOf(agent: Agent): number {
129  if (agent.boss) return 0x8957e5
130  let h = 0
131  for (const ch of agent.id) h = (h * 31 + ch.charCodeAt(0)) >>> 0
132  return SHIRTS[h % SHIRTS.length] ?? 0x539bf5
133}
134
135function fade(c: number): number {
136  const m = (v: number) => Math.round(v * 0.35 + 30)
137  return (m((c >> 16) & 0xff) << 16) | (m((c >> 8) & 0xff) << 8) | m(c & 0xff)
138}
139
140const clip = (s: string, w: number) => (s.length > w ? s.slice(0, w - 1) + '…' : s)
141const fmtDur = (ms: number) => {
142  const s = Math.max(0, Math.floor(ms / 1000))
143  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
144}
145
146const SPRITE_ROWS = BODY.length / 2
147const CARD_ROWS = SPRITE_ROWS + 2
148
149export type Cells = { columns: number; rows: number; words: Uint32Array }
150
151export function drawCards(agents: Agent[], now: number, frame: number, perLine: number): Cells {
152  const lines = Math.max(1, Math.ceil(agents.length / perLine))
153  const columns = Math.max(1, Math.min(agents.length, perLine)) * CARD_COLS
154  const rows = lines * CARD_ROWS
155  const words = new Uint32Array(columns * rows * 3)
156  const put = (x: number, y: number, ch: string, fg: number, bg: number) => {
157    const i = (y * columns + x) * 3
158    words[i] = ch.charCodeAt(0)
159    words[i + 1] = fg
160    words[i + 2] = bg
161  }
162  for (let y = 0; y < rows; y++) for (let x = 0; x < columns; x++) put(x, y, ' ', DEFAULT, DEFAULT)
163
164  agents.forEach((agent, n) => {
165    const ox = (n % perLine) * CARD_COLS
166    const oy = Math.floor(n / perLine) * CARD_ROWS
167    const activity = activityOf(agent, now)
168    const faded = activity === 'done' && now - (agent.endedAt ?? now) > DONE_LINGER_MS / 2
169    const grid = composeSprite(agent, activity, frame, now)
170    const color = (k: string): number | undefined => {
171      if (k === '.') return undefined
172      const c = k === 'B' ? shirtOf(agent) : PALETTE[k] ?? 0xff00ff
173      return faded ? fade(c) : c
174    }
175    for (let r = 0; r < BODY.length; r += 2) {
176      for (let c = 0; c < SPRITE_W; c++) {
177        const top = color(grid.get(r, c))
178        const bot = color(grid.get(r + 1, c))
179        const x = ox + 1 + c
180        const y = oy + r / 2
181        if (top !== undefined && bot !== undefined) put(x, y, '▀', top, bot)
182        else if (top !== undefined) put(x, y, '▀', top, DEFAULT)
183        else if (bot !== undefined) put(x, y, '▄', bot, DEFAULT)
184      }
185    }
186    const status = agent.tool ? `▸${agent.tool}` : ACTIVITY_LABEL[activity]
187    const labels: [string, number][] = [
188      [clip(agent.name, CARD_COLS - 1), faded ? DIM : TEXT],
189      [clip(`${status} ${agent.calls}× ${fmtDur((agent.endedAt ?? now) - agent.born)}`, CARD_COLS - 1), DIM],
190    ]
191    labels.forEach(([text, fg], i) => [...text].forEach((ch, x) => put(ox + x, oy + SPRITE_ROWS + i, ch, fg, DEFAULT)))
192  })
193  return { columns, rows, words }
194}
195
196export function toBase64(words: Uint32Array): string {
197  const bytes = new Uint8Array(words.buffer)
198  let bin = ''
199  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
200  return btoa(bin)
201}
202