SLOPSHOPPER

agent-theater

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.

newbandguardcommandmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-theater
› 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 › /theater ⎿ agent-theater: Agent theater is off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

agent-theater

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.

agent-theater demo

Promo (40 s, sound on)

Watch the 40 s promo

SceneClaudeMonitor
thinkthought bubble over his head, then a light bulb when the idea lands> _ thinking...
readflips the pages of a book, eyes scanning$ cat <file> with code scrolling
edittypes, keys lighting up$ vim <file> with the edit typing out
testthumps the monitora progress bar filling
runhits Enter while a cog spins over his head$ <command> with a spinner
errorscowls, flushes red, arms flailing, steam puffingthe screen flashes red with the real error
donearms 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.

Install

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.

Use

CommandWhat it does
/theaterTurn it on or off (remembered across sessions)
/theater on / /theater offSet it explicitly
/theater demoPlay all eight scenes, three seconds each

The theater follows the main agent only; subagents are left out.

Cost

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.

Develop

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

Uninstall

/plugin uninstall agent-theater@agent-theater

License

MIT

Source 3 files
hooks/register.tsx 297 lines
1import { 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}
297
hooks/stage.ts 571 lines
1import 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}
571
types/index.d.ts 23 lines
1export 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