A small Claude avatar that runs back and forth above the prompt

A small Claude avatar that lives above your prompt and reacts to what Claude is doing: it runs while Claude works, shows the current tool, flinches when a tool fails, celebrates when the turn ends, chimes after long turns, and plays tennis when things are quiet.
claude plugin marketplace add MarkMerk/markmerk-mods
claude plugin install runner-avatar@markmerk-mods
Commands: /avatar (hide/show), /avatar mute|unmute, /avatar tennis, /avatar jump.
It makes no network calls and reads or writes no files. Full docs, demo and privacy notes: <https://github.com/MarkMerk/markmerk-mods#runner-avatar>
hooks/register.js 653 lines1// The avatar: body rows stay the same, the legs alternate to look like running
2const BODY = ['▐▛███▜▌', '▝▜█████▛▘']
3const LEGS = ['▘▘ ▝▝', '▝▝ ▘▘']
4// Left offset of each row inside the sprite, and the sprite's full width
5const OFFSETS = [1, 0, 2]
6const WIDTH = 9
7const COLOR = '#D97757'
8// The avatar's color once the context window is nearly full, and from which percent
9const HOT_COLOR = '#FF0000'
10const HOT_PERCENT = 85
11// The second player in a game of tennis, and a subagent's small avatar
12const RIVAL_COLOR = '#6A9BCC'
13// Two rows: a head with eyes, then arms and legs that alternate like the big one's
14const MINI_HEAD = '▐▜▛▌'
15const MINI_LEGS = ['▀▛▜▀', '▜▀▀▛']
16const MINI_MAX = 3
17// The widest and narrowest tennis scene, in cells: both players and the court between them
18const SCENE_MAX = 2 * WIDTH + 64
19const SCENE_MIN = 2 * WIDTH + 30
20// How far a player runs in from the baseline, and how far the ball flies each tick
21const MOVE_MAX = 4
22const BALL_SPEED = 3
23// Ticks between a point and the next serve
24const PAUSE_TICKS = 12
25const POINTS = ['0', '15', '30', '40']
26// Ticks of strolling before a game of tennis starts (about 20 seconds), and how long it lasts
27const TENNIS_AFTER_TICKS = 160
28const TENNIS_TICKS = 300
29// A turn at least this long ends with a chime
30const CHIME_MS = 15000
31// Milliseconds between ticks, and how many ticks each short mood lasts
32const TICK_MS = 120
33const ALERT_TICKS = 8
34const OOPS_TICKS = 12
35const DONE_TICKS = 40
36// Ticks of strolling before the avatar falls asleep (about a minute)
37const SLEEPY_TICKS = 500
38// Ticks the band may say no turn is running before a stuck "working" is dropped
39const STALE_TICKS = 25
40const BUBBLE_MAX = 28
41// Ticks a jump lasts, and ticks its "hop!" stays
42const JUMP_TICKS = 5
43const HOP_TICKS = 8
44
45// The desktop app draws the block characters in a font whose glyphs don't fill a cell, so there
46// the avatar is a vector drawing: one cell is this many pixels
47const CELL_W = 8
48const CELL_H = 16
49// Which quarters of its cell each block character fills: upper left, upper right, lower left, lower right
50const QUARTERS = {
51 '█': [1, 1, 1, 1],
52 '▐': [0, 1, 0, 1],
53 '▌': [1, 0, 1, 0],
54 '▛': [1, 1, 1, 0],
55 '▜': [1, 1, 0, 1],
56 '▘': [1, 0, 0, 0],
57 '▝': [0, 1, 0, 0],
58 '▟': [0, 1, 1, 1],
59 '▙': [1, 0, 1, 1],
60}
61// The named colors of the terminal, and the color of a cell that names none, as the drawing spells them
62const PAINT = { green: '#3FB950', yellow: '#E3B341', red: '#E5484D' }
63const PLAIN = '#8B8B8B'
64
65// Where the avatar is, which way it runs, and which leg frame shows
66let x = 0
67let direction = 1
68let frame = 0
69let ticks = 0
70// The band's width and whether a turn is running, as the last draw saw them
71let columns = 80
72let isWorking = false
73
74// What the avatar is doing: idle, tennis, hop, alert, working, oops, done, stopped or asleep
75let mood = 'idle'
76// Ticks left in a short mood, and the mood that follows it
77let moodTicks = 0
78let moodAfter = 'idle'
79// What the bubble says in the short moods
80let note = ''
81// The tool calls running now, newest last, as bubble labels
82let tools = []
83let idleTicks = 0
84let staleTicks = 0
85// How full the context window is, in percent, once known
86let contextPercent = null
87// What /avatar set: saved in the store, so it lasts between sessions
88let isHidden = false
89let isMuted = false
90// The subagents working now, by id
91let agents = new Set()
92// The game of tennis. The ball: its column in the scene, which way it flies,
93// where this shot started and where it lands, and whether it ends in the net
94let ball = 0
95let ballDirection = 1
96let flightFrom = 0
97let flightTo = 0
98let isFault = false
99// How far each player stands in from the baseline, where each is running to, and the leg frames
100let playerAt = 0
101let rivalAt = 0
102let playerTarget = 0
103let rivalTarget = 0
104let playerLegs = 0
105let rivalLegs = 0
106// The points of this game, yours then the rival's, and the shots of this rally
107let score = [0, 0]
108let rally = 0
109// Ticks left before the next serve, who serves it, and what the scoreboard says meanwhile
110let pause = 0
111let serveDirection = 1
112let banner = ''
113// A small generator of its own, so a game plays the same way in a test
114let seed = 7
115// Ticks left in the air after a click
116let jump = 0
117// True once a game was played in this quiet spell, so the avatar gets to sleep
118let hasPlayed = false
119
120// The scene's width for a band this wide, or 0 when there is no room for a court
121function sceneWidth() {
122 const scene = Math.min(columns, SCENE_MAX)
123 return scene >= SCENE_MIN ? scene : 0
124}
125
126function roll(below) {
127 seed = (seed * 1103515245 + 12345) % 2147483648
128 return Math.floor(seed / 65536) % below
129}
130
131// One shot: the ball leaves the hitter's racket for a spot the other player runs to,
132// or, now and then in a long rally, for the net
133function hit(scene, way) {
134 ballDirection = way
135 isFault = rally >= 2 && roll(100) < 30
136 flightFrom = way > 0 ? playerAt + WIDTH + 2 : scene - WIDTH - rivalAt - 3
137 if (isFault) {
138 flightTo = Math.floor(scene / 2) - way
139 } else if (way > 0) {
140 rivalTarget = roll(MOVE_MAX + 1)
141 flightTo = scene - WIDTH - rivalTarget - 3
142 } else {
143 playerTarget = roll(MOVE_MAX + 1)
144 flightTo = playerTarget + WIDTH + 2
145 }
146 ball = flightFrom
147}
148
149function startTennis(forTicks) {
150 setMood('tennis', forTicks, 'idle')
151 hasPlayed = true
152 playerAt = rivalAt = playerTarget = rivalTarget = 0
153 score = [0, 0]
154 rally = 0
155 pause = 0
156 banner = ''
157 hit(sceneWidth(), 1)
158}
159
160// One tick of the game: the players run, the ball flies, and a shot into the net is a point
161function playTennis() {
162 const scene = sceneWidth()
163 if (pause > 0) {
164 pause -= 1
165 if (pause === 0) {
166 banner = ''
167 rally = 0
168 hit(scene, serveDirection)
169 }
170 return
171 }
172 if (playerAt !== playerTarget) {
173 playerAt += Math.sign(playerTarget - playerAt)
174 playerLegs = 1 - playerLegs
175 }
176 if (rivalAt !== rivalTarget) {
177 rivalAt += Math.sign(rivalTarget - rivalAt)
178 rivalLegs = 1 - rivalLegs
179 }
180 ball += ballDirection * BALL_SPEED
181 const hasLanded = ballDirection > 0 ? ball >= flightTo : ball <= flightTo
182 if (!hasLanded) return
183 ball = flightTo
184 if (!isFault) {
185 rally += 1
186 hit(scene, -ballDirection)
187 return
188 }
189 // Into the net: the point goes to the player who didn't hit it, who serves next
190 const winner = ballDirection > 0 ? 1 : 0
191 if (score[winner] === POINTS.length - 1) {
192 banner = 'game'
193 score = [0, 0]
194 } else {
195 score = score.map((points, i) => (i === winner ? points + 1 : points))
196 }
197 serveDirection = winner === 0 ? 1 : -1
198 pause = PAUSE_TICKS
199}
200
201// The row the ball is on: it rises over the net, comes down, bounces once and comes up to the racket
202function ballRow() {
203 if (isFault) return ball === flightTo ? 2 : 1
204 const flown = Math.abs(ball - flightFrom) / Math.max(1, Math.abs(flightTo - flightFrom))
205 if (flown < 0.12) return 1
206 if (flown < 0.62) return 0
207 if (flown < 0.72) return 1
208 if (flown < 0.84) return 2
209 return 1
210}
211
212// A row of text as cells of one style, shifted right by its offset
213function cellsOf(text, offset, style) {
214 return [...Array.from({ length: offset }, () => null), ...[...text].map((char) => (char === ' ' ? null : { char, ...style }))]
215}
216
217// A grid of cells as an SVG document: block characters become rectangles, the court's lines
218// and the ball become shapes, and anything else stays a letter
219function drawingOf(grid, width) {
220 const shapes = []
221 // The block characters' quarters, gathered into one path per paint: separate rectangles that
222 // touch show hairline seams when the drawing is scaled, a single path doesn't
223 const blocks = new Map()
224 for (const [row, cells] of grid.entries()) {
225 for (const [col, cell] of cells.entries()) {
226 if (cell === null) continue
227 const left = col * CELL_W
228 const top = row * CELL_H
229 const midX = left + CELL_W / 2
230 const midY = top + CELL_H / 2
231 const paint = `fill="${PAINT[cell.color] ?? cell.color ?? PLAIN}"${cell.dimColor ? ' opacity="0.55"' : ''}`
232 const quarters = QUARTERS[cell.char]
233 if (quarters !== undefined) {
234 for (const [i, isFilled] of quarters.entries()) {
235 if (!isFilled) continue
236 const x = left + (i % 2) * (CELL_W / 2)
237 const y = top + Math.floor(i / 2) * (CELL_H / 2)
238 blocks.set(paint, [...(blocks.get(paint) ?? []), `M${x} ${y}h${CELL_W / 2}v${CELL_H / 2}h${-CELL_W / 2}z`])
239 }
240 } else if (cell.char === '▁') {
241 shapes.push(`<rect x="${left}" y="${top + CELL_H - 2}" width="${CELL_W}" height="2" ${paint}/>`)
242 } else if (cell.char === '┃' || cell.char === '╻') {
243 const from = cell.char === '┃' ? top : midY
244 shapes.push(`<rect x="${midX - 1}" y="${from}" width="2" height="${top + CELL_H - from}" ${paint}/>`)
245 } else if (cell.char === '─') {
246 shapes.push(`<rect x="${left}" y="${midY - 1}" width="${CELL_W}" height="2" ${paint}/>`)
247 } else if (cell.char === '●' || cell.char === 'o') {
248 shapes.push(`<circle cx="${midX}" cy="${midY}" r="${cell.char === '●' ? 3.5 : 3}" ${paint}/>`)
249 } else {
250 const letter = cell.char.replace(/&/g, '&').replace(/</g, '<')
251 shapes.push(
252 `<text x="${midX}" y="${top + CELL_H - 4}" font-family="ui-monospace, Menlo, monospace" font-size="12" text-anchor="middle" ${paint}>${letter}</text>`,
253 )
254 }
255 }
256 }
257 // The blocks go underneath, so the ball and the lines stay on top
258 const paths = [...blocks].map(([paint, parts]) => `<path d="${parts.join('')}" ${paint}/>`)
259 shapes.unshift(...paths)
260 const pixels = { width: width * CELL_W, height: grid.length * CELL_H }
261 return {
262 ...pixels,
263 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${pixels.width}" height="${pixels.height}" viewBox="0 0 ${pixels.width} ${pixels.height}" shape-rendering="crispEdges">${shapes.join('')}</svg>`,
264 }
265}
266
267// A click on the avatar, or /avatar jump: it hops, and a sleeper wakes up
268function poke() {
269 jump = JUMP_TICKS
270 if (mood === 'asleep' || mood === 'idle') setMood('hop', HOP_TICKS, 'idle', 'hop!')
271}
272
273// Read how full the context window is
274async function measure($) {
275 try {
276 const { context } = await $.session.usage()
277 contextPercent = context.percent ?? null
278 } catch {
279 // Keep the last reading
280 }
281}
282
283function withContext(text) {
284 return contextPercent === null ? text : text + ' · ' + contextPercent + '%'
285}
286
287function setMood(name, forTicks = 0, after = 'idle', text = '') {
288 mood = name
289 moodTicks = forTicks
290 moodAfter = after
291 note = text
292 idleTicks = 0
293}
294
295function basename(path) {
296 return String(path ?? '').split('/').pop()
297}
298
299function clip(text) {
300 const line = String(text).replace(/\s+/g, ' ').trim()
301 return line.length > BUBBLE_MAX ? line.slice(0, BUBBLE_MAX - 1) + '…' : line
302}
303
304// The bubble's words for a tool call
305function labelFor(e) {
306 const tool = String(e.tool)
307 if (tool === 'Bash') return clip('$ ' + (e.command ?? ''))
308 if (tool === 'Read') return clip('reading ' + basename(e.file_path))
309 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
310 return clip('editing ' + basename(e.file_path ?? e.notebook_path))
311 }
312 if (tool === 'Grep' || tool === 'Glob') return 'searching'
313 if (tool === 'WebFetch' || tool === 'WebSearch') return 'browsing'
314 if (tool === 'Agent' || tool === 'Task') return 'delegating'
315 if (tool.startsWith('mcp__')) return clip('using ' + tool.split('__').pop())
316 return clip(tool)
317}
318
319// What the bubble says now, and in which color
320function bubble() {
321 if (mood === 'alert') return { text: '!', color: COLOR }
322 if (mood === 'working') {
323 return { text: withContext(tools.length > 0 ? tools[tools.length - 1] : 'thinking…'), dim: true }
324 }
325 if (mood === 'oops') return { text: note, color: 'red' }
326 if (mood === 'done') return { text: withContext(note), color: 'green' }
327 if (mood === 'stopped' || mood === 'hop') return { text: note, dim: true }
328 if (mood === 'asleep') return { text: ['z', 'zZ', 'zZz'][Math.floor(ticks / 6) % 3], dim: true }
329 return { text: '' }
330}
331
332function step() {
333 const max = Math.max(0, columns - WIDTH)
334 x += direction
335 // Turn around at either edge
336 if (x >= max) {
337 x = max
338 direction = -1
339 } else if (x <= 0) {
340 x = 0
341 direction = 1
342 }
343 frame = (frame + 1) % LEGS.length
344}
345
346// One tick of the clock: move the avatar as its mood says
347function tick() {
348 ticks += 1
349 if (jump > 0) jump -= 1
350 if (moodTicks > 0) {
351 moodTicks -= 1
352 if (moodTicks === 0) setMood(moodAfter)
353 }
354
355 // Follow the band when an event was missed, such as after a reload mid-turn
356 if (isWorking && (mood === 'idle' || mood === 'asleep' || mood === 'tennis')) setMood('working')
357 staleTicks = mood === 'working' && !isWorking ? staleTicks + 1 : 0
358 if (staleTicks > STALE_TICKS) setMood('idle')
359
360 if (mood === 'working') {
361 step()
362 } else if (mood === 'idle') {
363 idleTicks += 1
364 // A slow stroll
365 if (ticks % 3 === 0) step()
366 if (!hasPlayed && idleTicks > TENNIS_AFTER_TICKS && sceneWidth() > 0) startTennis(TENNIS_TICKS)
367 else if (idleTicks > SLEEPY_TICKS) {
368 setMood('asleep')
369 // A subagent whose end was missed doesn't run beside a sleeper
370 agents = new Set()
371 }
372 } else if (mood === 'tennis') {
373 if (sceneWidth() === 0) setMood('idle')
374 else playTennis()
375 } else if (mood === 'done') {
376 // A little dance on the spot
377 if (ticks % 2 === 0) frame = (frame + 1) % LEGS.length
378 }
379}
380
381export function register(on) {
382 // Runs before your first prompt, and again after a reload
383 on('session.start', async ($, e, next) => {
384 await $.command.register({
385 name: 'avatar',
386 description: 'Hide or show the avatar, mute its chime, start a game of tennis, or make it jump',
387 argumentHint: '[hide|show|mute|unmute|tennis|jump]',
388 immediate: true,
389 })
390 isHidden = (await $.store.get('isHidden')) === true
391 isMuted = (await $.store.get('isMuted')) === true
392 $.clock.every(TICK_MS, () => {
393 tick()
394 if (isHidden) return
395 // Asleep, only the bubble changes, and slowly
396 if (mood !== 'asleep' || ticks % 6 === 0) $.ui.invalidate('ui.render')
397 })
398 return next(e)
399 })
400
401 // Runs when you type /avatar: with no argument it hides or shows the avatar
402 on('command.run', { command: 'avatar' }, async ($, e) => {
403 const arg = e.args.trim().toLowerCase()
404 if (arg === 'mute' || arg === 'unmute') {
405 isMuted = arg === 'mute'
406 await $.store.set('isMuted', isMuted)
407 return { text: isMuted ? 'chime muted' : 'chime on' }
408 }
409 if (arg === 'jump') {
410 poke()
411 $.ui.invalidate('ui.render')
412 return {}
413 }
414 if (arg === 'tennis') {
415 if (sceneWidth() === 0) return { text: 'the terminal is too narrow for a court' }
416 if (mood !== 'idle' && mood !== 'asleep' && mood !== 'tennis') return { text: 'busy now, ask again after the turn' }
417 if (mood === 'tennis') setMood('idle')
418 else startTennis(TENNIS_TICKS)
419 return { text: mood === 'tennis' ? 'game on' : 'game over' }
420 }
421 if (arg !== '' && arg !== 'hide' && arg !== 'show') {
422 return { text: 'usage: /avatar [hide|show|mute|unmute|tennis|jump]' }
423 }
424 isHidden = arg === '' ? !isHidden : arg === 'hide'
425 await $.store.set('isHidden', isHidden)
426 $.ui.invalidate('ui.render')
427 return { text: isHidden ? 'avatar hidden' : 'avatar shown' }
428 })
429
430 // Runs when the avatar's own region reports a click
431 on('ui.message', async ($, e, next) => {
432 if (e.data?.poke === true) {
433 poke()
434 $.ui.invalidate('ui.render')
435 }
436 return next(e)
437 })
438
439 // Runs when you send a prompt: the avatar stops and looks up
440 on('prompt.submit', async ($, e, next) => {
441 hasPlayed = false
442 setMood('alert', ALERT_TICKS, 'working')
443 return next(e)
444 })
445
446 // Runs when Claude starts answering: the avatar runs
447 on('turn.start', async ($, e, next) => {
448 if (mood !== 'alert') setMood('working')
449 return next(e)
450 })
451
452 // Runs around each tool call: the bubble names it, and a failure makes the avatar flinch
453 on('tool.call', async ($, e, next) => {
454 const label = labelFor(e)
455 // A subagent's tool call: its small avatar joins the run
456 if (e.agentId !== undefined) agents = new Set([...agents, e.agentId])
457 tools = [...tools, label]
458 if (mood !== 'alert') setMood('working')
459 try {
460 const answer = await next(e)
461 if (answer.deny !== undefined || answer.isError) {
462 setMood('oops', OOPS_TICKS, 'working', answer.deny !== undefined ? 'blocked' : 'oops')
463 }
464 return answer
465 } finally {
466 const at = tools.lastIndexOf(label)
467 tools = tools.filter((_, i) => i !== at)
468 await measure($)
469 }
470 })
471
472 // Runs when a turn ends: the avatar celebrates, or shrugs
473 on('turn.complete', async ($, e, next) => {
474 // A subagent's turn isn't the end of yours: only its small avatar leaves
475 if (e.agentId !== undefined) {
476 agents = new Set([...agents].filter((id) => id !== e.agentId))
477 return next(e)
478 }
479 tools = []
480 await measure($)
481 // A chime for a long turn, so you can look away while Claude works
482 if (!isMuted && e.reason === 'answer' && e.durationMs >= CHIME_MS) {
483 $.audio.play({ asset: 'sounds/done.aiff' }).catch(() => {})
484 }
485 const seconds = Math.round(e.durationMs / 1000)
486 if (e.reason === 'answer') setMood('done', DONE_TICKS, 'idle', 'done ✓ ' + seconds + 's')
487 else if (e.reason === 'aborted') setMood('stopped', DONE_TICKS, 'idle', 'stopped')
488 else setMood('oops', DONE_TICKS, 'idle', e.reason === 'refusal' ? 'refused' : 'error')
489 return next(e)
490 })
491
492 // Runs each time Claude Code draws the band above the prompt
493 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
494 // Leave the band to a survey, and to a terminal too small for the avatar
495 if (isHidden || e.props.hasSurvey || e.props.maxRows < 3 || e.props.bodyColumns < WIDTH) return next(e)
496 columns = e.props.bodyColumns
497 isWorking = e.props.isWorking
498 const { Box, Text, Client, Svg } = $.ui.resolve(e)
499 // The desktop app gets drawings, the terminal block characters
500 const isDrawn = e.surface === 'desktop'
501
502 const spriteColor = contextPercent !== null && contextPercent >= HOT_PERCENT ? HOT_COLOR : COLOR
503 // A game of tennis: a court as wide as the band allows, a net in the middle, a player at each
504 // baseline. Drawn as a grid of cells, each row then cut into runs of one style.
505 const scene = sceneWidth()
506 if (mood === 'tennis' && scene > 0) {
507 const grid = [0, 1, 2].map(() => Array.from({ length: scene }, () => null))
508 const put = (row, col, text, style) => {
509 for (const [i, char] of [...text].entries()) {
510 if (char !== ' ' && col + i >= 0 && col + i < scene) grid[row][col + i] = { char, ...style }
511 }
512 }
513 const net = Math.floor(scene / 2)
514 const player = { color: spriteColor }
515 const rival = { color: RIVAL_COLOR }
516 const line = { dimColor: true }
517 const rivalLeft = scene - WIDTH - rivalAt
518
519 // The court's surface, the net, and the score over it
520 put(2, 0, '▁'.repeat(scene), { color: 'green', dimColor: true })
521 put(1, net, '╻', line)
522 put(2, net, '┃', line)
523 const board = banner !== '' ? banner : POINTS[score[0]] + ' : ' + POINTS[score[1]]
524 for (const [i, char] of [...board].entries()) grid[0][net - Math.floor(board.length / 2) + i] = { char, ...line }
525 // The players, each with a racket held toward the net
526 put(0, playerAt + OFFSETS[0], BODY[0], player)
527 put(1, playerAt + OFFSETS[1], BODY[1], player)
528 put(2, playerAt + OFFSETS[2], LEGS[playerLegs], player)
529 put(1, playerAt + WIDTH, '─o', line)
530 put(0, rivalLeft + OFFSETS[0], BODY[0], rival)
531 put(1, rivalLeft + OFFSETS[1], BODY[1], rival)
532 put(2, rivalLeft + OFFSETS[2], LEGS[rivalLegs], rival)
533 put(1, rivalLeft - 2, 'o─', line)
534 put(ballRow(), ball, '●', { color: 'yellow' })
535
536 // Cut a row into runs of cells that share a style: one Text each, and a Box for a gap
537 const runs = (cells) => {
538 const children = []
539 let from = 0
540 while (from < cells.length) {
541 const first = cells[from]
542 let to = from + 1
543 while (
544 to < cells.length &&
545 (cells[to] === null) === (first === null) &&
546 cells[to]?.color === first?.color &&
547 cells[to]?.dimColor === first?.dimColor
548 ) {
549 to += 1
550 }
551 children.push(
552 first === null
553 ? Box({ width: to - from, flexShrink: 0, children: [] })
554 : Text({
555 color: first.color,
556 dimColor: first.dimColor === true,
557 children: [cells.slice(from, to).map((cell) => cell.char).join('')],
558 }),
559 )
560 from = to
561 }
562 return Box({ flexDirection: 'row', children })
563 }
564 if (isDrawn) {
565 return Box({
566 key: 'tennis',
567 marginLeft: Math.floor((columns - scene) / 2),
568 children: [Svg({ ...drawingOf(grid, scene), alt: 'A game of tennis, ' + board })],
569 })
570 }
571 return Box({
572 key: 'tennis',
573 flexDirection: 'column',
574 marginLeft: Math.floor((columns - scene) / 2),
575 children: grid.map(runs),
576 })
577 }
578
579 const left = Math.max(0, Math.min(x, columns - WIDTH))
580 const rows = [...BODY, LEGS[frame]]
581 // The avatar is drawn by sprite.js in a region of its own, which reports clicks;
582 // on the desktop it is a drawing, which doesn't
583 const sprite = isDrawn
584 ? Svg({
585 ...drawingOf(
586 rows.map((row, i) => cellsOf(row, OFFSETS[i], { color: spriteColor, dimColor: mood === 'asleep' })),
587 WIDTH,
588 ),
589 alt: 'Claude avatar',
590 })
591 : Client({
592 key: 'avatar',
593 module: './sprite.js',
594 width: WIDTH,
595 height: 3,
596 props: {
597 rows,
598 offsets: OFFSETS,
599 color: spriteColor,
600 isDim: mood === 'asleep',
601 },
602 })
603 // In the air the avatar is a row higher, over its shadow
604 const lift = (tree) =>
605 jump > 0 && e.props.maxRows >= 4
606 ? Box({
607 flexDirection: 'column',
608 children: [
609 tree,
610 Box({ key: 'shadow', marginLeft: left + 2, children: [Text({ dimColor: true, children: ['▔▔▔▔▔'] })] }),
611 ],
612 })
613 : tree
614
615 const { text, color, dim } = bubble()
616 // The subagents' small avatars run beside the big one, at ground level
617 const count = Math.min(agents.size, MINI_MAX)
618 const minis = [MINI_HEAD, MINI_LEGS[frame]].map((row) => Array.from({ length: count }, () => row).join(' '))
619 const sideWidth = Math.max(text.length, minis[0].length)
620 if (sideWidth === 0) return lift(Box({ marginLeft: left, children: [sprite] }))
621
622 // The bubble goes to the right of the avatar, or to its left near the right edge
623 const roomRight = columns - (left + WIDTH + 1)
624 const isOnRight = sideWidth <= roomRight || left < sideWidth + 1
625 const cut = (line) => (isOnRight ? line.slice(0, Math.max(0, roomRight)) : line)
626 const blank = Text({ children: [' '] })
627 const side = Box({
628 flexDirection: 'column',
629 alignItems: isOnRight ? 'flex-start' : 'flex-end',
630 children: [
631 text === ''
632 ? blank
633 : Box({ key: 'bubble', children: [Text({ color, dimColor: dim === true, children: [cut(text)] })] }),
634 count === 0
635 ? Box({ flexDirection: 'column', children: [blank, blank] })
636 : Box({
637 key: 'minis',
638 flexDirection: 'column',
639 children: minis.map((row) => Text({ color: spriteColor, children: [cut(row)] })),
640 }),
641 ],
642 })
643 return lift(
644 Box({
645 flexDirection: 'row',
646 columnGap: 1,
647 marginLeft: isOnRight ? left : left - sideWidth - 1,
648 children: isOnRight ? [sprite, side] : [side, sprite],
649 }),
650 )
651 })
652}
653hooks/sprite.js 18 lines1// Draws one avatar in a region of its own, and tells the hooks module when it is clicked.
2// This file runs on the drawing side: it has no mods API, only `surface`.
3export default function Sprite(props, surface) {
4 const { Box, Text } = surface.elements
5 surface.onPointer((event) => {
6 if (event.type === 'down') surface.post({ poke: true })
7 })
8 return Box({
9 flexDirection: 'column',
10 children: props.rows.map((row, i) =>
11 Box({
12 paddingLeft: props.offsets[i],
13 children: [Text({ color: props.color, dimColor: props.isDim, children: [row] })],
14 }),
15 ),
16 })
17}
18