Animated pixel characters for running subagents, drawn under the prompt

A Claude Code mod that draws each running subagent as an animated pixel character, in the terminal, under the prompt's hint line.
![]()
The main agent wears a crown. Each subagent gets its own desk, shirt color and label:
Bash, Edit or Write.Read, Grep or Glob.The strip appears only while subagents are running. Otherwise the hint line is unchanged.
/plugin install pixel-agents --marketplace dimivelev/pixel-agents
Answer y to add the marketplace, then pick a scope.
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.| File | Contents |
|---|---|
hooks/register.tsx | The hooks and per-agent state. |
hooks/sprites.ts | Sprites, poses and cell encoding. |
tests/strip.test.tsx | Behavior tests. Run them with claude plugin test .. |
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
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT. See LICENSE.
hooks/register.tsx 80 lines1import 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}
80hooks/sprites.ts 202 lines1export 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