SLOPSHOPPER

Runner Avatar

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

newbandguardcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · runner-avatar
› 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 › /avatar ⎿ runner-avatar: avatar hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

runner-avatar

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>

Source 2 files
hooks/register.js 653 lines
1// 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, '&amp;').replace(/</g, '&lt;')
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}
653
hooks/sprite.js 18 lines
1// 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