A pixel theater above the prompt: little orange Claude acts out each tool call at a desk while a monitor shows the command and Haiku narrates it.

A tiny pixel theater above your Claude Code prompt. Little orange Claude sits at a desk and acts out whatever the agent is doing, while the monitor beside him shows the real command or file and a one-line narration.

| Scene | Claude | Monitor |
|---|---|---|
| think | thought bubble over his head, then a light bulb when the idea lands | > _ thinking... |
| read | flips the pages of a book, eyes scanning | $ cat <file> with code scrolling |
| edit | types, keys lighting up | $ vim <file> with the edit typing out |
| test | thumps the monitor | a progress bar filling |
| run | hits Enter while a cog spins over his head | $ <command> with a spinner |
| error | scowls, flushes red, arms flailing, steam puffing | the screen flashes red with the real error |
| done | arms up, confetti | ✓ OKAY and a summary of the turn |
The narration is written by Haiku in the voice of a calm senior engineer, in the language of your request. When a step drifts away from what you asked, the narration and the monitor's title turn yellow and say off track.
Needs Claude Code 2.1.287 or later (claude --version). Mods run in the terminal and the desktop Code tab.
Inside Claude Code:
/plugin marketplace add afu-it/agent-theater
/plugin install agent-theater@agent-theater
/reload-plugins
Or from your shell:
claude plugin marketplace add afu-it/agent-theater
claude plugin install agent-theater@agent-theater --scope user
If the theater does not show up, restart Claude Code.
| Command | What it does |
|---|---|
/theater | Turn it on or off (remembered across sessions) |
/theater on / /theater off | Set it explicitly |
/theater demo | Play all eight scenes, three seconds each |
The theater follows the main agent only; subagents are left out.
Each narration is one small Haiku call on your own account: at most one every four seconds while tools run, plus one summary when a turn ends. Turn it off with /theater off and no calls are made.
git clone https://github.com/afu-it/agent-theater
claude --plugin-dir ./agent-theater/plugins/agent-theater
claude plugin validate ./agent-theater/plugins/agent-theater
claude plugin test ./agent-theater/plugins/agent-theater
The folder is watched, so every save reloads the mod. The art lives in hooks/stage.ts; the hooks, timers and narration in hooks/register.tsx.
The promo is a Remotion project in videos/. It draws the theater with the mod's own stage.ts, so the film matches the terminal frame for frame. Music and sound effects are not in the repo; add your own under videos/public/bgm/chiptune.wav and videos/public/sfx/, then:
cd videos && npm install
npx remotion render src/index.ts TheaterWide out/agent-theater-wide.mp4
npx remotion render src/index.ts TheaterTall out/agent-theater-tall.mp4
/plugin uninstall agent-theater@agent-theater
MIT
hooks/register.tsx 297 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Action, Scene } from '../types'
5import {
6 EMPTY_SCENE,
7 INK,
8 ROWS,
9 SYSTEM,
10 classify,
11 clipWords,
12 columnsFor,
13 hex,
14 paint,
15 parseReply,
16 screenLines,
17 screenWidthFor,
18 stepPrompt,
19 summaryPrompt,
20 targetOf,
21} from './stage'
22
23const sceneAtom = atom({ plugin: 'agent-theater', key: 'scene' } as const, null)
24const isOnAtom = atom({ plugin: 'agent-theater', key: 'isOn' } as const, true)
25const requestAtom = atom({ plugin: 'agent-theater', key: 'request' } as const, '')
26const stepsAtom = atom({ plugin: 'agent-theater', key: 'steps' } as const, [])
27
28const FRAME_MS = 125
29const NARRATE_TICK_MS = 500
30// At most one narration every NARRATE_GAP_TICKS * NARRATE_TICK_MS (4s).
31const NARRATE_GAP_TICKS = 8
32const OK_FLASH_FRAMES = 24
33// The light bulb shows this many frames after thinking ends.
34const IDEA_FRAMES = 16
35
36// `/theater demo` plays every scene in turn, DEMO_FRAMES frames (3s) each.
37const DEMO_FRAMES = 24
38// The narration types out this many characters per frame (24 a second).
39const TYPE_SPEED = 3
40const DEMO: ReadonlyArray<Partial<Scene> & { action: Action }> = [
41 { action: 'think', line: 'Kopi dulu. Faham masalah sebelum sentuh kod.' },
42 { action: 'read', target: 'worker/index.ts', line: 'Baca worker/index.ts dulu, faham router sebelum ubah.' },
43 { action: 'edit', target: 'src/App.tsx', line: 'Edit src/App.tsx: ubah sikit, jangan rewrite.' },
44 { action: 'test', target: 'npm test', line: 'npm test. Biar suite yang cakap, bukan perasaan.' },
45 { action: 'run', target: 'git status', line: 'git status: tengok apa yang betul-betul berubah.' },
46 { action: 'error', target: 'npm test', detail: 'TypeError: x is undefined', line: 'TypeError lagi. Klasik: undefined masuk dari API.' },
47 { action: 'read', target: 'resipi-nasi-lemak.md', line: 'Baca resipi nasi lemak? Ini dah lari dari tiket.', isOffTrack: true },
48 { action: 'ok', line: 'Test hijau. Baru layak minum kopi.', isDone: true },
49]
50
51type Job =
52 | { kind: 'step'; action: Action; tool: string; target: string; failure: string }
53 | { kind: 'summary'; answer: string; isAborted: boolean }
54
55// Animation and narration bookkeeping. A reload re-runs this module and resets
56// these, which costs at most one skipped frame or narration; what the band
57// draws lives in $.state.
58const live = {
59 frame: 0,
60 flashUntil: 0,
61 isWorking: false,
62 bandId: undefined as string | undefined,
63 scene: null as Scene | null,
64 isOn: true,
65 pending: null as Job | null,
66 lastStepKey: '',
67 isNarrating: false,
68 ticksSinceNarration: NARRATE_GAP_TICKS,
69 demo: null as number | null,
70 demoFrames: 0,
71 ideaUntil: 0,
72 lineFrame: 0,
73 changeFrame: 0,
74 steps: 0,
75 turnFrame: 0,
76 doneSeconds: null as number | null,
77 screenWidth: screenWidthFor(0),
78}
79
80const isFlashOn = () => live.frame >= live.flashUntil || Math.floor(live.frame / 2) % 2 === 0
81
82const revealed = () => (live.frame - live.lineFrame) * TYPE_SPEED
83
84const isTyping = (scene: Scene) => revealed() < scene.line.length
85
86function status(): string {
87 if (live.demo !== null) return `demo ${live.demo + 1}/${DEMO.length}`
88 if (live.steps === 0 && live.doneSeconds === null) return ''
89 const seconds = live.doneSeconds ?? Math.round(((live.frame - live.turnFrame) * FRAME_MS) / 1000)
90 return `${live.steps} ${live.steps === 1 ? 'step' : 'steps'}, ${seconds}s`
91}
92
93const paintNow = (scene: Scene) =>
94 paint(scene, live.frame, {
95 isFlashOn: isFlashOn(),
96 isIdea: live.frame < live.ideaUntil,
97 screenWidth: live.screenWidth,
98 revealed: revealed(),
99 status: status(),
100 sinceChange: live.frame - live.changeFrame,
101 isCelebrating: live.frame < live.flashUntil,
102 })
103
104async function setScene($: EngineInterface, patch: Partial<Scene>): Promise<void> {
105 // Done thinking: the thought bubble turns into a light bulb for a moment.
106 if (live.scene?.action === 'think' && patch.action !== undefined && patch.action !== 'think') {
107 live.ideaUntil = live.frame + IDEA_FRAMES
108 }
109 if (patch.action !== undefined && patch.action !== live.scene?.action) live.changeFrame = live.frame
110 // New narration types itself out from here.
111 if (patch.line !== undefined && patch.line !== live.scene?.line) live.lineFrame = live.frame
112 const next = { ...(live.scene ?? EMPTY_SCENE), ...patch }
113 live.scene = next
114 await update($, sceneAtom, () => next)
115}
116
117async function showDemo($: EngineInterface, index: number): Promise<void> {
118 const step = DEMO[index]
119 if (!step) return
120 if (step.action === 'ok') live.flashUntil = live.frame + OK_FLASH_FRAMES
121 await setScene($, { target: '', detail: '', isOffTrack: false, isDone: false, ...step })
122}
123
124function queueStep(job: Extract<Job, { kind: 'step' }>): void {
125 const key = `${job.action}:${job.target}`
126 if (key === live.lastStepKey && !job.failure) return
127 live.lastStepKey = key
128 // A pending summary outranks any step that arrives after it.
129 if (live.pending?.kind !== 'summary') live.pending = job
130}
131
132async function narrate($: EngineInterface, job: Job): Promise<void> {
133 const request = await read($, requestAtom)
134 const steps = await read($, stepsAtom)
135 const prompt =
136 job.kind === 'step'
137 ? stepPrompt({ request, steps, tool: job.tool, target: job.target, action: job.action, failure: job.failure })
138 : summaryPrompt({ request, steps, answer: job.answer, isAborted: job.isAborted })
139 const reply = await $.model.complete({
140 model: 'haiku',
141 system: SYSTEM,
142 prompt,
143 maxTokens: 160,
144 effort: 'low',
145 timeoutMs: 10_000,
146 })
147 if (!reply.isAnswered || !live.isOn) return
148 const parsed = parseReply(reply.text)
149 if (!parsed) return
150 // A step narration that lands after the turn ended must not cover the summary.
151 if (job.kind === 'step' && live.scene?.isDone) return
152 await setScene($, {
153 line: clipWords(parsed.line, job.kind === 'step' ? 12 : 16),
154 isOffTrack: job.kind === 'step' && !parsed.fits,
155 })
156}
157
158export const register: Register = on => {
159 on('session.start', async ($, e, next) => {
160 await $.command.register({
161 name: 'theater',
162 description: 'Turn the agent theater on or off',
163 argumentHint: '[on|off|demo]',
164 immediate: true,
165 })
166 const stored = await $.store.get('isOn')
167 if (typeof stored === 'boolean') await update($, isOnAtom, () => stored)
168 live.scene = await read($, sceneAtom)
169 live.isOn = await read($, isOnAtom)
170
171 $.clock.every(FRAME_MS, () => {
172 const scene = live.scene
173 if (!live.isOn || !scene || live.bandId === undefined) return
174 if (live.demo !== null) {
175 live.demoFrames += 1
176 if (live.demoFrames >= DEMO_FRAMES) {
177 live.demoFrames = 0
178 live.demo = live.demo + 1 < DEMO.length ? live.demo + 1 : null
179 if (live.demo !== null) void showDemo($, live.demo)
180 }
181 }
182 const isFlashing = (scene.action === 'ok' && live.frame <= live.flashUntil) || live.frame <= live.ideaUntil
183 if (!live.isWorking && !isFlashing && !isTyping(scene) && live.demo === null) return
184 live.frame += 1
185 void $.ui.blit({ requestId: live.bandId, key: 'stage', cells: paintNow(scene) })
186 })
187
188 $.clock.every(NARRATE_TICK_MS, () => {
189 live.ticksSinceNarration += 1
190 if (!live.pending || live.isNarrating || !live.isOn) return
191 if (live.pending.kind === 'step' && live.ticksSinceNarration < NARRATE_GAP_TICKS) return
192 const job = live.pending
193 live.pending = null
194 live.isNarrating = true
195 live.ticksSinceNarration = 0
196 void narrate($, job).finally(() => {
197 live.isNarrating = false
198 })
199 })
200
201 return next(e)
202 })
203
204 on('command.run', { command: 'theater' }, async ($, e) => {
205 const arg = e.args.trim().toLowerCase()
206 if (arg === 'demo') {
207 live.isOn = true
208 live.pending = null
209 live.demo = 0
210 live.demoFrames = 0
211 await update($, isOnAtom, () => true)
212 await showDemo($, 0)
213 return { text: `Agent theater demo: ${DEMO.length} scenes, 3s each.` }
214 }
215 const isOn = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOnAtom))
216 live.isOn = isOn
217 await update($, isOnAtom, () => isOn)
218 await $.store.set('isOn', isOn)
219 return { text: isOn ? 'Agent theater is on.' : 'Agent theater is off.' }
220 })
221
222 on('turn.start', async ($, e, next) => {
223 if (live.isOn) {
224 live.isWorking = true
225 live.pending = null
226 live.lastStepKey = ''
227 live.steps = 0
228 live.turnFrame = live.frame
229 live.doneSeconds = null
230 if (e.text.trim()) await update($, requestAtom, () => e.text.slice(0, 800))
231 await update($, stepsAtom, () => [])
232 await setScene($, { action: 'think', target: '', detail: '', line: '...', isOffTrack: false, isDone: false })
233 }
234 return next(e)
235 })
236
237 on('tool.call', async ($, e, next) => {
238 // Subagents run their own loops; the theater follows the main one.
239 if (e.agentId !== undefined || !live.isOn) return next(e)
240
241 const input = e as unknown as Record<string, unknown>
242 const tool = String(e.tool)
243 const action = classify(tool, input)
244 const target = targetOf(input)
245 live.steps += 1
246 await setScene($, { action, target, detail: '', isDone: false })
247
248 const ran = await next(e)
249
250 const hasFailed = ran.deny !== undefined || ran.isError === true
251 const failure = hasFailed ? (ran.deny ?? ran.text ?? '').slice(0, 600) : ''
252 if (hasFailed) await setScene($, { action: 'error', detail: failure.split('\n')[0] ?? '' })
253 await update($, stepsAtom, steps =>
254 [...steps, `${tool} ${target}${hasFailed ? ' (failed)' : ''}`].slice(-30),
255 )
256 queueStep({ kind: 'step', action: hasFailed ? 'error' : action, tool, target, failure })
257
258 return ran
259 })
260
261 on('turn.complete', async ($, e, next) => {
262 const done = await next(e)
263 if (e.agentId === undefined && live.isOn) {
264 live.isWorking = false
265 live.doneSeconds = Math.round(e.durationMs / 1000)
266 live.flashUntil = live.frame + OK_FLASH_FRAMES
267 live.pending = { kind: 'summary', answer: e.answer.slice(0, 1500), isAborted: e.isAborted }
268 await setScene($, { action: 'ok', target: '', detail: '', isOffTrack: false, isDone: true })
269 }
270 return done
271 })
272
273 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
274 const isOn = await read($, isOnAtom)
275 const scene = await read($, sceneAtom)
276 if (!isOn || scene === null || e.props.hasSurvey) return next(e)
277
278 live.bandId = e.requestId
279 live.screenWidth = screenWidthFor(e.props.bodyColumns)
280
281 if (e.surface === 'terminal') {
282 const { Raster } = $.ui.resolve(e)
283 return <Raster key="stage" columns={columnsFor(live.screenWidth)} rows={ROWS} cells={paintNow(scene)} />
284 }
285
286 // Surfaces without a Raster get the monitor's text alone.
287 const { Box, Text } = $.ui.resolve(e)
288 return (
289 <Box key="monitor" flexDirection="column" borderStyle="round" borderColor={hex(scene.isOffTrack ? INK.offTrack : INK.frame)} paddingX={1}>
290 {screenLines(scene, live.frame, isFlashOn(), Number.POSITIVE_INFINITY, live.screenWidth).map(line => (
291 <Text color={hex(line.color)}>{line.text || ' '}</Text>
292 ))}
293 </Box>
294 )
295 })
296}
297hooks/stage.ts 571 lines1import type { Action, Scene } from '../types'
2
3// One Raster, ROWS cells tall. The left PIXEL_COLUMNS cells are pixel art
4// (two pixels per cell, stacked with half blocks); after a gap comes the
5// monitor, drawn with real characters: a command, an animation, narration.
6export const ROWS = 6
7const PIXEL_COLUMNS = 18
8const PIXEL_ROWS = ROWS * 2
9const GAP = 1
10const MIN_SCREEN = 26
11const MAX_SCREEN = 72
12
13export const columnsFor = (screenWidth: number) => PIXEL_COLUMNS + GAP + screenWidth + 2
14
15export const screenWidthFor = (bodyColumns: number) =>
16 Math.max(MIN_SCREEN, Math.min(MAX_SCREEN, bodyColumns - PIXEL_COLUMNS - GAP - 2))
17
18const TERMINAL_DEFAULT = 0x01000000
19const UPPER_HALF = 0x2580
20const LOWER_HALF = 0x2584
21
22const PALETTE: Record<string, number> = {
23 O: 0xd97757, // Claude
24 U: 0x6a9bcc, // confetti
25 K: 0x2b1a12, // eyes, thinking dots
26 W: 0xf2efe6, // thought bubble, the turning page, mug
27 p: 0xcfc8b8, // the book's resting pages
28 w: 0x6b7280, // steam, speed lines
29 D: 0x8b5a2b, // desk
30 r: 0xa23b2a, // book cover
31 k: 0x9ca3af, // keyboard, bulb base
32 Y: 0xffd75e, // lit keys, the bulb, sparks
33 S: 0x5fa8ff, // a drop of sweat
34 F: 0xe5533d, // Claude, flushed with frustration
35 A: 0xf85149, // the anger mark
36}
37
38export const INK = {
39 frame: 0x6b7280,
40 offTrack: 0xe3b341,
41 screen: 0x0d1117,
42 sweep: 0x1f2937,
43 alarm: 0xb62324,
44 text: 0xc9d1d9,
45 white: 0xffffff,
46 prompt: 0x3fb950,
47 code: 0x58a6ff,
48 comment: 0x8b949e,
49 error: 0xf85149,
50 cursor: 0xffd75e,
51}
52
53type Canvas = number[][]
54
55const blank = (): Canvas =>
56 Array.from({ length: PIXEL_ROWS }, () => new Array<number>(PIXEL_COLUMNS).fill(0))
57
58// The art is drawn on a 10-pixel-high stage; the pixel area is taller to
59// match the monitor, so everything sits LIFT pixels lower, on the desk.
60const LIFT = PIXEL_ROWS - 10
61
62function stamp(canvas: Canvas, x: number, y: number, rows: readonly string[]): void {
63 rows.forEach((row, dy) => {
64 ;[...row].forEach((glyph, dx) => {
65 const color = PALETTE[glyph]
66 const line = canvas[y + dy + LIFT]
67 if (color === undefined || line === undefined) return
68 if (x + dx >= 0 && x + dx < PIXEL_COLUMNS) line[x + dx] = color
69 })
70 })
71}
72
73// Claude stands at (GUY_X, GUY_Y), behind the desk on the bottom row.
74const GUY_X = 8
75const GUY_Y = 4
76
77function guy(
78 canvas: Canvas,
79 x: number,
80 { isBlinking = false, isAngry = false, look = 0, tint = 'O' } = {},
81): void {
82 // `look` slides both pupils right, for eyes that scan a line of text.
83 const eyes = [...'.OOOOOO.']
84 eyes[2 + look] = 'K'
85 eyes[5 + look] = 'K'
86 const face = isAngry
87 ? ['.OKOOKO.', '.OOKKOO.'] // brows slanting down to a V: a scowl
88 : ['.OOOOOO.', isBlinking ? '.OOOOOO.' : eyes.join('')]
89 const rows = [...face, 'OOOOOOOO', '.OOOOOO.', 'O.O..O.O']
90 stamp(canvas, x, GUY_Y, rows.map(row => row.replaceAll('O', tint)))
91}
92
93function desk(canvas: Canvas, frame: number, { isTyping = false, hasProps = true } = {}): void {
94 stamp(canvas, 0, PIXEL_ROWS - 1 - LIFT, ['D'.repeat(PIXEL_COLUMNS)])
95 if (!hasProps) return
96 stamp(canvas, 16, 8, [isTyping ? (frame % 2 === 0 ? 'Yk' : 'kY') : 'kk'])
97 stamp(canvas, 1, 7, ['WW', 'WW'])
98 stamp(canvas, 1, 5, frame % 2 === 0 ? ['.w', 'w.'] : ['w.', '.w'])
99}
100
101// A cloud 9 pixels wide over his head, three thinking dots filling in.
102function thoughtBubble(canvas: Canvas, frame: number): void {
103 stamp(canvas, 9, 0, ['.WWWWWWW.', 'WWWWWWWWW', '.WWWWWWW.'])
104 stamp(canvas, 9, 3, ['W'])
105 ;[11, 13, 15].slice(0, frame % 4).forEach(x => stamp(canvas, x, 1, ['K']))
106}
107
108function bulb(canvas: Canvas, frame: number): void {
109 stamp(canvas, 11, 0, ['.YYY.', 'YYYYY', '.YYY.', '..k..'])
110 if (frame % 2 === 0) stamp(canvas, 9, 0, ['Y.......Y', '.........', 'Y.......Y'])
111}
112
113export type PaintOptions = {
114 isFlashOn?: boolean
115 isIdea?: boolean
116 screenWidth?: number
117 // How many characters of the narration have been typed out so far.
118 revealed?: number
119 // Ticks since the scene changed; the monitor sweeps a refresh line.
120 sinceChange?: number
121 // True while the turn's end is celebrated: confetti falls.
122 isCelebrating?: boolean
123 // Shown at the right of the bottom edge: steps and time.
124 status?: string
125}
126
127// Frames tick every 125ms. Most motion steps on every second tick (4 fps);
128// the runners and the cursor keep their own tempo.
129const beat = (tick: number) => Math.floor(tick / 2)
130
131// A page turning right to left: lies on the right, lifts, stands over the
132// spine, falls to the left, lands; one step a beat.
133const PAGE_TURN: ReadonlyArray<readonly [number, number, readonly string[]]> = [
134 [13, 6, ['WW', 'WW']],
135 [13, 6, ['WW', 'WW']],
136 [12, 5, ['WW', 'WW']],
137 [12, 4, ['W', 'W', 'W']],
138 [10, 5, ['WW', 'WW']],
139 [9, 6, ['WW', 'WW']],
140]
141
142// A cog over his head while a command runs: two poses a beat apart spin it.
143const GEAR = [
144 ['..k..', '.kkk.', 'kkKkk', '.kkk.', '..k..'],
145 ['k...k', '.kkk.', '.kKk.', '.kkk.', 'k...k'],
146]
147
148const CONFETTI = ['Y', 'U', 'S', 'W', 'Y', 'U']
149
150function confetti(canvas: Canvas, tick: number): void {
151 CONFETTI.forEach((glyph, i) => {
152 const x = (i * 7 + 3 + (tick >> 2) * (i % 2 === 0 ? 1 : -1) + 18 * 4) % PIXEL_COLUMNS
153 const y = ((tick + i * 3) % (PIXEL_ROWS - 1)) - LIFT
154 stamp(canvas, x, y, [glyph])
155 })
156}
157
158function drawPixels(
159 canvas: Canvas,
160 action: Action,
161 tick: number,
162 { isFlashOn = true, isIdea = false, isCelebrating = false }: PaintOptions,
163): void {
164 const frame = beat(tick)
165 const isBlinking = frame % 8 === 7
166 switch (action) {
167 case 'think':
168 desk(canvas, frame)
169 guy(canvas, GUY_X, { isBlinking })
170 thoughtBubble(canvas, frame)
171 break
172 case 'read':
173 desk(canvas, frame)
174 guy(canvas, GUY_X, { look: frame % 4 === 3 ? 0 : 1 })
175 stamp(canvas, 9, 6, ['pprrpp', 'pprrpp', 'rrrrrr'])
176 {
177 const step = frame % PAGE_TURN.length
178 const [x, y, page] = PAGE_TURN[step]!
179 if (step === 2 || step === 3) stamp(canvas, 11, 6, ['WW', 'WW'])
180 stamp(canvas, x, y, page)
181 }
182 break
183 case 'edit':
184 desk(canvas, frame, { isTyping: true })
185 guy(canvas, GUY_X)
186 stamp(canvas, 16, frame % 2 === 0 ? 6 : 7, ['O'])
187 break
188 case 'test':
189 desk(canvas, frame)
190 guy(canvas, GUY_X)
191 // Even frames wind up, odd frames thump the monitor.
192 if (frame % 2 === 0) stamp(canvas, 16, 4, ['.O', 'O.'])
193 else stamp(canvas, 16, 5, ['OO', '.Y', '.Y'])
194 break
195 case 'run':
196 // Types the command, hits Enter, and the cog spins while it runs.
197 desk(canvas, frame, { isTyping: frame % 3 === 0 })
198 stamp(canvas, 13, 0, GEAR[frame % 2]!)
199 guy(canvas, GUY_X, { isBlinking })
200 stamp(canvas, 16, frame % 3 === 0 ? 7 : 6, ['O'])
201 break
202 case 'error': {
203 // Frustrated: scowling, shaking, red in the face, arms flailing,
204 // steam puffing off his head and an anger mark flashing.
205 desk(canvas, frame)
206 const x = GUY_X + (tick % 2)
207 const tint = frame % 2 === 0 ? 'F' : 'O'
208 guy(canvas, x, { isAngry: true, tint })
209 if (frame % 2 === 0) stamp(canvas, x - 2, GUY_Y - 1, [`${tint}.........${tint}`, `.${tint}.......${tint}.`])
210 else stamp(canvas, x - 2, GUY_Y + 2, [`.${tint}.......${tint}.`, `${tint}.........${tint}`])
211 const puff = frame % 3
212 stamp(canvas, x, GUY_Y - 1 - puff, ['w......w'])
213 if (frame % 2 === 1) stamp(canvas, x + 5, 0, ['A.A', '.A.', 'A.A'])
214 break
215 }
216 case 'ok':
217 desk(canvas, frame)
218 guy(canvas, GUY_X)
219 if (isFlashOn) stamp(canvas, 6, 3, ['O.........O', '.O.......O.'])
220 if (isCelebrating) confetti(canvas, tick)
221 break
222 case 'idle':
223 desk(canvas, frame)
224 guy(canvas, GUY_X, { isBlinking })
225 break
226 }
227 if (isIdea) bulb(canvas, frame)
228}
229
230// What the monitor says: three lines, each with its color.
231export type ScreenLine = { text: string; color: number }
232
233const SNIPPETS = [
234 'export function main() {',
235 ' const rows = await db',
236 ' if (!rows) return null',
237 ' return json(rows)',
238 '}',
239]
240const TYPED = 'fix: handle empty rows'
241
242const ascii = (value: string) => value.replace(/[^\x20-\x7e]/g, '?')
243
244// Word-wraps the narration into two lines of `width` characters, the first
245// led by `// ` and the second indented to match, then types it out.
246function narrationLines(said: string, width: number, revealed: number): [string, string] {
247 const room = Math.max(8, width - 3)
248 const words = said.split(' ')
249 const lines: [string, string] = ['', '']
250 let row = 0
251 for (const word of words) {
252 const next = lines[row] ? `${lines[row]} ${word}` : word
253 if (next.length <= room || !lines[row]) lines[row] = next.slice(0, room)
254 else if (row === 0) {
255 row = 1
256 lines[1] = word.slice(0, room)
257 } else {
258 lines[1] = `${lines[1]}`.slice(0, room - 1) + '…'
259 break
260 }
261 }
262 const first = lines[0].slice(0, revealed)
263 const second = lines[1].slice(0, Math.max(0, revealed - lines[0].length - 1))
264 const isTyping = revealed < lines[0].length + 1 + lines[1].length
265 const caret = isTyping ? '▌' : ''
266 const onSecond = revealed > lines[0].length
267 return [`// ${first}${onSecond ? '' : caret}`, lines[1] || onSecond ? ` ${second}${onSecond ? caret : ''}` : '']
268}
269
270// What the monitor says: one line of work, then two lines of narration.
271// The file or command sits once, in the bezel's title bar.
272export function screenLines(
273 scene: Scene,
274 tick: number,
275 isFlashOn = true,
276 revealed = Number.POSITIVE_INFINITY,
277 width = MIN_SCREEN,
278): [ScreenLine, ScreenLine, ScreenLine] {
279 const frame = beat(tick)
280 const cursor = Math.floor(tick / 4) % 2 === 0 ? '_' : ' '
281 const spinner = '|/-\\'[tick % 4]
282 const said = ascii(scene.line)
283 const [first, second] = said ? narrationLines(said, width - 1, revealed) : ['', '']
284 const color = scene.isOffTrack ? INK.offTrack : INK.comment
285 const narration = (text: string): ScreenLine => ({ text, color })
286 const work = (): ScreenLine => {
287 switch (scene.action) {
288 case 'think':
289 return { text: `thinking${'.'.repeat(frame % 4)}`, color: INK.text }
290 case 'read':
291 return { text: SNIPPETS[frame % SNIPPETS.length]!, color: INK.code }
292 case 'edit':
293 return { text: `+ ${TYPED.slice(0, frame % (TYPED.length + 1))}${cursor}`, color: INK.prompt }
294 case 'test': {
295 const done = frame % 11
296 return { text: `[${'#'.repeat(done)}${'.'.repeat(10 - done)}] ${done * 10}% ${spinner}`, color: INK.prompt }
297 }
298 case 'run':
299 return { text: `running ${spinner}`, color: INK.cursor }
300 case 'error':
301 return { text: `${frame % 2 === 0 ? '✗' : ' '} ${ascii(scene.detail) || 'exit 1'}`, color: INK.error }
302 case 'ok':
303 return { text: isFlashOn ? '✓ OKAY' : '', color: INK.prompt }
304 case 'idle':
305 return { text: `$ ${cursor}`, color: INK.prompt }
306 }
307 }
308 return [work(), narration(first), narration(second)]
309}
310
311type Cell = [number, number, number]
312
313function pixelCells(canvas: Canvas, row: number): Cell[] {
314 return Array.from({ length: PIXEL_COLUMNS }, (_, x): Cell => {
315 const top = canvas[row * 2]?.[x] ?? 0
316 const bottom = canvas[row * 2 + 1]?.[x] ?? 0
317 if (top === 0 && bottom === 0) return [0x20, TERMINAL_DEFAULT, TERMINAL_DEFAULT]
318 if (bottom === 0) return [UPPER_HALF, top, TERMINAL_DEFAULT]
319 if (top === 0) return [LOWER_HALF, bottom, TERMINAL_DEFAULT]
320 return [UPPER_HALF, top, bottom]
321 })
322}
323
324function textCells(text: string, color: number, background: number): Cell[] {
325 return [...text].map((glyph): Cell => [glyph.codePointAt(0) ?? 0x20, color, background])
326}
327
328// Each scene's label and accent, shown as a chip in the monitor's top edge.
329export const CHIP: Record<Action, { label: string; color: number }> = {
330 idle: { label: 'IDLE', color: INK.comment },
331 think: { label: 'THINK', color: 0xbc8cff },
332 read: { label: 'READ', color: INK.code },
333 edit: { label: 'EDIT', color: INK.prompt },
334 test: { label: 'TEST', color: INK.cursor },
335 run: { label: 'RUN', color: 0x39c5cf },
336 error: { label: 'ERROR', color: INK.error },
337 ok: { label: 'DONE', color: INK.prompt },
338}
339
340type Run = { text: string; color: number }
341
342const BEZEL = 0x374151
343const BEZEL_THUMP = 0x6b7280
344const STAND = 0x4b5563
345const DESK = 0x8b5a2b
346
347// A row of the bezel: text runs on the bezel's own grey, left and right.
348function bezelRow(left: readonly Run[], right: readonly Run[], bezel: number, width: number): Cell[] {
349 const fit = (runs: readonly Run[], room: number) => {
350 const cells: Cell[] = []
351 for (const run of runs) cells.push(...textCells(run.text.slice(0, room - cells.length), run.color, bezel))
352 return cells
353 }
354 const head = fit(left, width)
355 const tail = fit(right, Math.max(0, width - head.length))
356 const fill = textCells(' '.repeat(Math.max(0, width - head.length - tail.length)), INK.text, bezel)
357 return [...head, ...fill, ...tail]
358}
359
360// The desk runs along the bottom half of the last row; the stand's base sits on it.
361function deskRow(width: number, base?: { from: number; to: number }): Cell[] {
362 return Array.from({ length: width }, (_, x): Cell =>
363 base && x >= base.from && x < base.to ? [UPPER_HALF, STAND, DESK] : [LOWER_HALF, DESK, TERMINAL_DEFAULT],
364 )
365}
366
367function monitorCells(scene: Scene, tick: number, options: PaintOptions, screenWidth: number, row: number): Cell[] {
368 const { isFlashOn = true, revealed, status = '', sinceChange = Number.POSITIVE_INFINITY } = options
369 const width = screenWidth + 2
370 // A thump from the test scene flashes the bezel lighter.
371 const bezel = scene.action === 'test' && beat(tick) % 2 === 1 ? BEZEL_THUMP : BEZEL
372 const chip = CHIP[scene.action]
373
374 if (row === 0) {
375 const title: Run[] = [{ text: ` ${chip.label} `, color: chip.color }]
376 const target = ascii(scene.target)
377 if (target) title.push({ text: ` ${target}`, color: INK.text })
378 const flag: Run[] = scene.isOffTrack ? [{ text: 'off track ', color: INK.offTrack }] : []
379 return bezelRow(title, flag, bezel, width)
380 }
381 if (row === ROWS - 2) {
382 const led = scene.action === 'error' ? INK.error : INK.prompt
383 return [
384 ...bezelRow([], status ? [{ text: `${status} `, color: 0x9ca3af }] : [], bezel, width - 2),
385 [LOWER_HALF, led, bezel],
386 [0x20, INK.text, bezel],
387 ]
388 }
389 if (row === ROWS - 1) {
390 const middle = Math.floor(width / 2)
391 return deskRow(width, { from: middle - 4, to: middle + 4 })
392 }
393
394 const content = screenLines(scene, tick, isFlashOn, revealed, screenWidth)[row - 1]!
395 // The error screen pulses red twice a second.
396 const isAlarm = scene.action === 'error' && Math.floor(tick / 4) % 2 === 0
397 // A new scene sweeps a lighter refresh line down the screen, one row a tick.
398 const isSweep = sinceChange === row - 1
399 const background = isAlarm ? INK.alarm : isSweep ? INK.sweep : INK.screen
400 const shown = ` ${content.text}`.slice(0, screenWidth).padEnd(screenWidth, ' ')
401 return [
402 [0x20, INK.text, bezel],
403 ...textCells(shown, isAlarm ? INK.white : content.color, background),
404 [0x20, INK.text, bezel],
405 ]
406}
407
408const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
409
410function toBase64(bytes: readonly number[]): string {
411 let out = ''
412 for (let i = 0; i < bytes.length; i += 3) {
413 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
414 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
415 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
416 out += i + 2 < bytes.length ? B64[n & 63]! : '='
417 }
418 return out
419}
420
421// RasterProps.cells: row-major little-endian u32 triplets [codePoint, fg, bg].
422function encode(rows: readonly Cell[][]): string {
423 const bytes: number[] = []
424 for (const cell of rows.flat()) {
425 for (const word of cell) bytes.push(word & 255, (word >>> 8) & 255, (word >>> 16) & 255, (word >>> 24) & 255)
426 }
427 return toBase64(bytes)
428}
429
430// The pixel grid of one frame: PIXEL_COLUMNS x (ROWS * 2) colors, 0 for empty.
431export function pixels(action: Action, frame: number, options: PaintOptions = {}): number[][] {
432 const canvas = blank()
433 drawPixels(canvas, action, frame, options)
434 return canvas
435}
436
437export function cellRows(scene: Scene, frame: number, options: PaintOptions = {}): Cell[][] {
438 const { screenWidth = MIN_SCREEN } = options
439 const canvas = pixels(scene.action, frame, options)
440 const gap: Cell = [0x20, TERMINAL_DEFAULT, TERMINAL_DEFAULT]
441 return Array.from({ length: ROWS }, (_, row) => [
442 ...pixelCells(canvas, row),
443 ...(row === ROWS - 1 ? deskRow(GAP) : Array.from({ length: GAP }, () => gap)),
444 ...monitorCells(scene, frame, options, screenWidth, row),
445 ])
446}
447
448export const paint = (scene: Scene, frame: number, options: PaintOptions = {}): string =>
449 encode(cellRows(scene, frame, options))
450
451export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
452
453const TEST_COMMAND =
454 /\b(vitest|jest|pytest|mocha|playwright test|(npm|pnpm|yarn|bun)( run)? (test|check|lint)|go test|cargo test|tsc|oxlint|eslint)\b/
455const READ_TOOL = /^(Read|Grep|Glob|WebFetch|WebSearch|LSP|ToolSearch)$|(read|search|get|list|query|explore|fetch)/i
456const EDIT_TOOL = /^(Edit|Write|MultiEdit|NotebookEdit)$/
457
458const text = (value: unknown): string => (typeof value === 'string' ? value : '')
459
460export function classify(tool: string, input: Record<string, unknown>): Action {
461 if (EDIT_TOOL.test(tool)) return 'edit'
462 if (tool === 'Bash') return TEST_COMMAND.test(text(input.command)) ? 'test' : 'run'
463 if (READ_TOOL.test(tool)) return 'read'
464 return 'run'
465}
466
467const shortPath = (path: string): string => path.split('/').filter(Boolean).slice(-2).join('/')
468
469export function targetOf(input: Record<string, unknown>): string {
470 const path = text(input.file_path) || text(input.notebook_path) || text(input.path)
471 if (path) return shortPath(path)
472 const command = text(input.command).split('\n')[0]?.trim() ?? ''
473 if (command) return command.slice(0, 60)
474 return (text(input.pattern) || text(input.url) || text(input.query) || text(input.description)).slice(0, 60)
475}
476
477export const SCENE_WORDS: Record<Action, string> = {
478 idle: 'sitting at his desk',
479 think: 'thinking, a thought bubble over his head',
480 read: 'pretending to read a book',
481 edit: 'typing on the keyboard',
482 test: 'thumping the side of the monitor while tests run',
483 run: 'hitting Enter while a cog spins over his head',
484 error: 'fuming, red in the face, arms flailing, while the monitor flashes red',
485 ok: 'cheering, OKAY on the monitor',
486}
487
488export function clipWords(line: string, max: number): string {
489 const words = line
490 .replace(/\s*[—–]\s*/g, ', ')
491 .replace(/^["'`]+|["'`]+$/g, '')
492 .split(/\s+/)
493 .filter(Boolean)
494 return words.slice(0, max).join(' ')
495}
496
497export type Reply = { line: string; fits: boolean }
498
499export function parseReply(raw: string): Reply | null {
500 const json = raw.match(/\{[\s\S]*\}/)?.[0]
501 if (json) {
502 try {
503 const value = JSON.parse(json) as { line?: unknown; fits?: unknown }
504 if (typeof value.line === 'string' && value.line.trim()) {
505 return { line: value.line.trim(), fits: value.fits !== false }
506 }
507 } catch {
508 // fall through to the plain-text reading
509 }
510 }
511 const first = raw.split('\n').map(l => l.trim()).find(Boolean)
512 return first ? { line: first, fits: true } : null
513}
514
515export const SYSTEM =
516 'You narrate a tiny pixel theater in a coding tool: the little orange Claude mascot sits at a desk and acts out what the coding agent is doing. ' +
517 'You speak as him, a seasoned senior engineer thinking out loud to a junior: terse, technical, precise, a little dry humour. ' +
518 'You always answer with one line of JSON and nothing else.'
519
520const RULES =
521 'Write in the same language as the user request. Plain words, no emoji, no em dashes. ' +
522 'Reply exactly as {"line": "...", "fits": true} or {"line": "...", "fits": false}.'
523
524export function stepPrompt(args: {
525 request: string
526 steps: readonly string[]
527 tool: string
528 target: string
529 action: Action
530 failure: string
531}): string {
532 const failure = args.failure
533 ? `\nThe step failed with:\n"""${args.failure}"""\nNarrate it like a veteran who has seen this exact bug before: name the likely cause in a few words.`
534 : '\nIf the step looks like it found a bug, sound like a veteran naming the likely cause.'
535 return [
536 `User request:\n"""${args.request || '(none yet)'}"""`,
537 `Earlier steps this turn: ${args.steps.slice(-8).join('; ') || 'none'}`,
538 `Step now: ${args.tool} on ${args.target || 'nothing in particular'}. The guy is ${SCENE_WORDS[args.action]}.${failure}`,
539 'Write "line": one narration line of 12 words or fewer. Stay in the senior engineer voice, but say plainly what the agent is doing and to which file or command.',
540 'Write "fits": does this step plausibly serve the user request? Looking around, reading and checking for the task count as fitting. false only when the step wanders off the request.',
541 RULES,
542 ].join('\n\n')
543}
544
545export function summaryPrompt(args: {
546 request: string
547 steps: readonly string[]
548 answer: string
549 isAborted: boolean
550}): string {
551 return [
552 `User request:\n"""${args.request || '(none)'}"""`,
553 `Steps this turn: ${args.steps.join('; ') || 'none'}`,
554 `The agent's reply (start):\n"""${args.answer || '(empty)'}"""`,
555 args.isAborted ? 'The user stopped this turn early.' : '',
556 'Write "line": a one-line summary of this turn, 15 words or fewer, saying what got done. Set "fits" to true.',
557 RULES,
558 ]
559 .filter(Boolean)
560 .join('\n\n')
561}
562
563export const EMPTY_SCENE: Scene = {
564 action: 'idle',
565 target: '',
566 detail: '',
567 line: '',
568 isOffTrack: false,
569 isDone: false,
570}
571types/index.d.ts 23 lines1export type Action = 'idle' | 'think' | 'read' | 'edit' | 'test' | 'run' | 'error' | 'ok'
2
3export type Scene = {
4 action: Action
5 target: string
6 // The first line of a failure, shown on the monitor.
7 detail: string
8 line: string
9 isOffTrack: boolean
10 isDone: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'agent-theater': {
16 scene: Scene | null
17 isOn: boolean
18 request: string
19 steps: string[]
20 }
21 }
22}
23