SLOPSHOPPER

clawd-buddy

Clawd, the Claude Code mascot, dancing above the prompt, with a small buddy at a laptop for each subagent, saying what it is doing

newbandguardtoastprompttimer
v0.6.1no licenseupdated 2026-10-15zGreenCat/pet-claudy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-buddy
› 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 ✦ + ▄ ▄ ▄ ▁ ▁ ▄ ▄ ¡listo! ▄ ▝▝ ▘▘ ▄ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✦ + ▄ ▄ ▄ ▁ ▁ ▄ ▄ ¡listo! ▄ ▝▝ ▘▘ ▄
README

clawd-buddy

Clawd, the Claude Code mascot, above the prompt. It reacts to what Claude is doing:

  • quieto: waits, blinks and sways.
  • baila: dances while a turn runs.
  • cocina: tosses code in a pan ("cocinando código").
  • fuma: takes a smoke break now and then when idle.
  • duerme: falls asleep after 3 minutes idle.
  • celebra: cheers with confetti when a turn ends.
  • agentes: each subagent walks out of Clawd with its own laptop, shows what it is doing in a bubble, and walks back in when it finishes.

Install

In a Claude Code terminal session:

/plugin install clawd-buddy --marketplace zGreenCat/pet-claudy

Answer y to add the marketplace, then pick a scope (user, to have it in every session).

Preview

See every mood without running a session (Node 23.6 or newer):

node scripts/preview.ts ?        # list the moods
node scripts/preview.ts cocina   # play one
node scripts/preview.ts todos    # play them in turn

Develop

claude plugin validate .
claude plugin test .

The hooks module is hooks/register.tsx; the poses are in hooks/clawd.ts and the subagent scene in hooks/desks.ts.

Source 4 files
hooks/register.tsx 184 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, Register } from 'claude-code'
3
4import type { Tick } from '../types'
5import { CELEBRATE_TICKS, TICK_MS, WIDTH, view, viewKey } from './clawd'
6import type { Frame } from './clawd'
7import { START_X, describe, oneLine, scene, solo, summary, walk } from './desks'
8import type { Agent, Segment } from './desks'
9
10// The view key last drawn. The timer only writes it when what Clawd shows
11// changes, so a still Clawd costs no redraws.
12const tick = atom({ plugin: 'clawd-buddy', key: 'tick' } as const, 0 as Tick)
13
14// What a finished agent's buddy says when its loop ended without a SubagentStop
15const ENDED: Partial<Record<AgentStatus, string>> = { completed: 'listo', failed: '✗ falló', killed: '✗ detenido' }
16// Ticks between checks of the agent list, and how old an agent must be to be checked
17const RECONCILE_TICKS = 8
18// Failed ticks before Clawd says so, once a session: one alone is let pass
19const FAILURES_TO_REPORT = 5
20
21export const register: Register = on => {
22  // Module state: the clock, and what the last draw saw of the band
23  let t = 0
24  let idleTicks = 0
25  let isWorking = false
26  let bodyColumns = 80
27  let lastKey = ''
28  // The tick the main loop's last turn ended; far back, so none to celebrate
29  let finishedAt = -CELEBRATE_TICKS
30  // The subagents at their desks, in seat order
31  let agents: Agent[] = []
32  let failures = 0
33
34  const isBusy = () => isWorking || agents.some(a => a.doneAt === undefined)
35
36  function drawKey(): string {
37    if (agents.length === 0) return viewKey(view(t, idleTicks, isWorking, bodyColumns, t - finishedAt))
38    return `T${JSON.stringify(desksScene())}`
39  }
40
41  function soloScene(): Segment[][] {
42    const v = view(t, idleTicks, isWorking, bodyColumns, t - finishedAt)
43    return solo(v.frame, v.marks, v.x, v.line, v.isWorking, bodyColumns)
44  }
45
46  function desksScene() {
47    // Clawd alone, not what it holds: the desks start right beside it
48    const frame = view(t, idleTicks, isBusy(), bodyColumns).frame.map(r => r.slice(0, WIDTH)) as Frame
49    return scene(t, agents, frame, isWorking ? 'dirigiendo' : 'esperando', bodyColumns)
50  }
51
52  function seat(id: string, type: string, task: string) {
53    if (!agents.some(a => a.id === id)) {
54      agents = [...agents, { id, type, activity: task, x: START_X, bornAt: t }]
55    }
56  }
57
58  function finish(id: string, result: string) {
59    agents = agents.map(a => (a.id === id && a.doneAt === undefined ? { ...a, doneAt: t, result } : a))
60  }
61
62  on('session.start', async ($, e, next) => {
63    // The clock must outlive a bad tick: a period that fails would end the
64    // interval, and Clawd would freeze on whatever it last drew
65    $.clock.every(TICK_MS, async () => {
66      try {
67        t += 1
68        idleTicks = isBusy() ? 0 : idleTicks + 1
69        agents = walk(agents, t, bodyColumns)
70
71        // An agent whose loop ended without a SubagentStop still leaves its desk
72        const waiting = agents.filter(a => a.doneAt === undefined && t - a.bornAt >= RECONCILE_TICKS)
73        if (waiting.length > 0 && t % RECONCILE_TICKS === 0) {
74          const list = await $.agent.list()
75          for (const a of waiting) {
76            const info = list.find(i => i.id === a.id)
77            if (!info) finish(a.id, 'listo')
78            else if (ENDED[info.status]) finish(a.id, ENDED[info.status]!)
79          }
80        }
81
82        const key = drawKey()
83        if (key !== lastKey) {
84          lastKey = key
85          await update($, tick, n => n + 1)
86        }
87      } catch (err) {
88        // The next tick draws again; a run of failures is said, not hidden
89        failures += 1
90        if (failures === FAILURES_TO_REPORT) {
91          const reason = err instanceof Error ? err.message : String(err)
92          $.ui.toast(`clawd-buddy: el reloj está fallando (${reason})`)
93        }
94      }
95    })
96
97    return next(e)
98  })
99
100  // These hooks only watch: one that fails passes the event on untouched
101  // (a `next` already called replays its answer)
102
103  // A subagent sits down when it starts, its task as its first words. Only the
104  // spawn seats one: SubagentStart can fire for loops no agent list names
105  // (a workflow's), which the check of the list would send off as done
106  on('agent.spawn', async ($, e, next) => {
107    const started = await next(e)
108    if (started.agentId && !e.workflow) seat(started.agentId, e.subagentType, oneLine(e.description))
109    return started
110  }).catch(($, e, next) => next(e))
111
112  // Its bubble follows its tool calls
113  on('tool.call', ($, e, next) => {
114    const a = e.agentId === undefined ? undefined : agents.find(x => x.id === e.agentId)
115    if (a && a.doneAt === undefined) {
116      const activity = describe(e.tool, e as unknown as Record<string, unknown>)
117      agents = agents.map(x => (x === a ? { ...x, activity } : x))
118    }
119    return next(e)
120  }).catch(($, e, next) => next(e))
121
122  // A turn of the main loop that answered: Clawd cheers; a new prompt cuts it short
123  on('turn.complete', ($, e, next) => {
124    if (e.agentId === undefined && e.reason === 'answer') {
125      finishedAt = t
126      isWorking = false
127      idleTicks = 0
128    }
129    return next(e)
130  }).catch(($, e, next) => next(e))
131
132  on('prompt.submit', ($, e, next) => {
133    finishedAt = -CELEBRATE_TICKS
134    return next(e)
135  }).catch(($, e, next) => next(e))
136
137  on('classic.SubagentStop', ($, e, next) => {
138    finish(e.agent_id, summary(e.last_assistant_message))
139    return next(e)
140  }).catch(($, e, next) => next(e))
141
142  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143    if (e.props.hasSurvey || e.props.maxRows < 4) {
144      return next(e)
145    }
146
147    // Read so a write of the key redraws this band
148    await read($, tick)
149    if (e.props.isWorking) idleTicks = 0
150    isWorking = e.props.isWorking
151    bodyColumns = e.props.bodyColumns
152
153    const { Box, Text } = $.ui.resolve(e)
154    lastKey = drawKey()
155
156    const rows = agents.length > 0 ? desksScene() : soloScene()
157
158    return (
159      <Box flexDirection="column">
160        {rows.map((row, i) => (
161          <Text key={`r${i}`} wrap="truncate">
162            {row.map(({ text, style }, j) =>
163              Object.keys(style).length === 0 ? (
164                text
165              ) : (
166                <Text
167                  key={`s${j}`}
168                  {...(style.bg ? { backgroundColor: style.bg } : {})}
169                  {...(style.fg ? { color: style.fg } : {})}
170                  {...(style.dim ? { dimColor: true } : {})}
171                  {...(style.italic ? { italic: true } : {})}
172                  {...(style.bold ? { bold: true } : {})}
173                >
174                  {text}
175                </Text>
176              ),
177            )}
178          </Text>
179        ))}
180      </Box>
181    )
182  })
183}
184
hooks/clawd.ts 183 lines
1// Clawd's poses: dancing, cooking code in a pan, a smoke break, asleep.
2// Pure functions of the tick, so the timer can tell whether the drawing
3// changed and the tests can drive it.
4
5import type { Mark } from './desks'
6
7export const TICK_MS = 250
8export const WIDTH = 8
9// After this long idle Clawd falls asleep and stops animating
10export const SLEEP_AFTER_TICKS = (3 * 60 * 1000) / TICK_MS
11// Idle this long, Clawd takes a smoke break now and then: the last
12// SMOKE_TICKS of every SMOKE_EVERY
13const SMOKE_FROM_TICKS = 60
14const SMOKE_EVERY = 240
15const SMOKE_TICKS = 40
16
17// Clawd as the Claude Code logo draws it: a square body, an eye in each upper
18// corner, short arms at the chest and two pairs of legs at its sides. Three
19// rows, each WIDTH cells; the body is painted with the cell's background
20// rather than block glyphs, which leave a dark seam between rows in fonts
21// whose glyphs don't fill the line height. Only the thin parts (arms, legs)
22// are glyphs, half a cell or a quarter.
23//   O  orange cell
24//   L  orange cell, a dark eye in its lower left quarter; R the lower right
25//   -  orange cell with a closed eye (▁)
26//   ^  an arm at rest, the cell's upper half; v an arm raised, its lower half
27//   a  a leg in the cell's upper left quarter; b the upper right
28//   .  empty
29// What it holds runs on past WIDTH: h a pan's handle, P the pan, c a
30// cigarette, C its ember, Y the ember drawn on.
31const HEAD = '.OLOORO.'
32const HEAD_BLINK = '.O-OO-O.'
33const HEAD_ARMS_UP = 'vOLOOROv'
34const BODY = '^OOOOOO^'
35const BODY_ARMS_UP = '.OOOOOO.'
36const LEGS = '.aa..bb.'
37// The legs shuffled inward, still under the body
38const LEGS_STEP = '.bb..aa.'
39// An arm out to a pan
40const BODY_PAN = '^OOOOOOOhhPPPP'
41// A thin cigarette at the mouth, its ember dim, then drawn on with the eyes shut
42const HEAD_SMOKE = '.OLOOROcccC'
43const HEAD_DRAW = '.O-OO-OcccY'
44// Cheering: arms up, eyes shut with joy
45const HEAD_CHEER = 'vO-OO-Ov'
46
47export type Frame = [string, string, string]
48
49const DANCE: Frame[] = [
50  [HEAD, BODY, LEGS],
51  [HEAD_ARMS_UP, BODY_ARMS_UP, LEGS_STEP],
52  [HEAD, BODY, LEGS_STEP],
53  [HEAD_ARMS_UP, BODY_ARMS_UP, LEGS],
54  [HEAD_ARMS_UP, BODY_ARMS_UP, LEGS_STEP],
55  [HEAD, BODY, LEGS],
56]
57
58const IDLE: Frame = [HEAD, BODY, LEGS]
59const BLINK: Frame = [HEAD_BLINK, BODY, LEGS]
60const COOK: Frame = [HEAD, BODY_PAN, LEGS]
61
62const FOOD = ['{}', '()', '<>', '[]']
63const FOOD_COLOR = '#F2C14E'
64const PAN_COLOR = '#6B7280'
65const SMOKE_COLOR = '#B8BCC4'
66// The food's row over a toss: in the pan, up in the air, back down
67const TOSS = [2, 2, 1, 0, 0, 1, 2, 2]
68// A puff's way up from the ember, a cell or two to the right each step
69const PUFF: Mark[] = [
70  { row: 1, x: 12, text: '·', fg: SMOKE_COLOR },
71  { row: 0, x: 11, text: '∘', fg: SMOKE_COLOR },
72  { row: 0, x: 12, text: 'o', fg: SMOKE_COLOR },
73  { row: 0, x: 14, text: '°', fg: SMOKE_COLOR },
74]
75
76const WORKING_LINES = ['pensando', 'trabajando', 'bailando', 'cocinando código']
77const IDLE_LINES = ['esperándote', 'listo', 'aquí estoy']
78
79// Back and forth across `span` cells
80function bounce(t: number, span: number): number {
81  if (span <= 0) return 0
82  const period = span * 2
83  const p = t % period
84  return p < span ? p : period - p
85}
86
87export type View = { frame: Frame; marks: Mark[]; x: number; line: string; isWorking: boolean }
88
89// Tossing code in a pan: the food flies up and lands, sizzling in between
90function cooking(t: number): { frame: Frame; marks: Mark[] } {
91  const row = TOSS[t % TOSS.length]!
92  const food = FOOD[Math.floor(t / TOSS.length) % FOOD.length]!
93  const marks: Mark[] = [{ row, x: 11, text: food, fg: FOOD_COLOR, ...(row === 2 ? { bg: PAN_COLOR } : {}) }]
94  if (row === 2) marks.push({ row: 1, x: t % 2 ? 10 : 13, text: '~', fg: SMOKE_COLOR })
95  return { frame: COOK, marks }
96}
97
98// A turn is done: Clawd cheers this many ticks, then settles back
99export const CELEBRATE_TICKS = 28
100const SPARKLE = '#F2C14E'
101const CONFETTI = [
102  { x: 19, ch: '▄', fg: '#E58FD0' },
103  { x: 22, ch: '▄', fg: '#F2C14E' },
104  { x: 25, ch: '▄', fg: '#7FD1E8' },
105  { x: 28, ch: '▄', fg: '#8DB360' },
106  { x: 31, ch: '▄', fg: '#E07A5F' },
107  { x: 34, ch: '▄', fg: '#6A9BCC' },
108]
109
110// Cheering at a finished turn: hopping with arms up, sparkles over its head and
111// confetti falling beside it, then standing still with the check
112function celebrating(since: number): { frame: Frame; marks: Mark[]; line: string } {
113  const isUp = since % 4 < 2
114  const isSettling = since >= CELEBRATE_TICKS - 8
115  if (isSettling) return { frame: IDLE, marks: [], line: '✓ listo' }
116  const marks: Mark[] = [
117    { row: 0, x: 0, text: isUp ? '✦' : '+', fg: SPARKLE },
118    { row: 0, x: 7, text: isUp ? '+' : '✦', fg: SPARKLE },
119  ]
120  // Each piece falls one row a tick and starts over from above the band
121  CONFETTI.forEach((c, k) => {
122    const row = (since + k * 3) % 5
123    if (row < 4) marks.push({ row, x: c.x, text: c.ch, fg: c.fg })
124  })
125  const frame: Frame = isUp ? [HEAD_CHEER, BODY_ARMS_UP, LEGS_STEP] : [HEAD_ARMS_UP, BODY_ARMS_UP, LEGS]
126  return { frame, marks, line: '¡listo!' }
127}
128
129// A smoke break: a draw every two seconds, two puffs on their way up
130function smoking(t: number): { frame: Frame; marks: Mark[] } {
131  const isDrawing = t % 8 < 2
132  const marks = [0, 4].map(k => PUFF[Math.floor(((t + k) % 8) / 2)]!)
133  return { frame: [isDrawing ? HEAD_DRAW : HEAD_SMOKE, BODY, LEGS], marks }
134}
135
136export function isSmoking(idleTicks: number): boolean {
137  return (
138    idleTicks >= SMOKE_FROM_TICKS &&
139    idleTicks < SLEEP_AFTER_TICKS &&
140    idleTicks % SMOKE_EVERY >= SMOKE_EVERY - SMOKE_TICKS
141  )
142}
143
144// What Clawd shows at tick `t`, given how long the session has been idle and,
145// when a turn just ended, how many ticks ago
146export function view(
147  t: number,
148  idleTicks: number,
149  isWorking: boolean,
150  bodyColumns: number,
151  since?: number,
152): View {
153  if (since !== undefined && since >= 0 && since < CELEBRATE_TICKS) {
154    return { ...celebrating(since), x: 0, isWorking: true }
155  }
156  if (isWorking) {
157    const line = WORKING_LINES[Math.floor(t / 24) % WORKING_LINES.length]!
158    const pose = line.startsWith('cocinando') ? cooking(t) : { frame: DANCE[t % DANCE.length]!, marks: [] }
159    // Walk one cell every other tick, within the band's width
160    const span = Math.min(40, bodyColumns - Math.max(...pose.frame.map(r => r.length)) - 22)
161    const dots = '.'.repeat((Math.floor(t / 2) % 3) + 1)
162    return { ...pose, x: bounce(Math.floor(t / 2), span), line: line + dots, isWorking }
163  }
164  if (idleTicks >= SLEEP_AFTER_TICKS) {
165    return { frame: BLINK, marks: [], x: 0, line: 'zzz', isWorking }
166  }
167  if (isSmoking(idleTicks)) {
168    return { ...smoking(t), x: 0, line: 'en mi break', isWorking }
169  }
170  // Blink once every ~4s, sway every second
171  return {
172    frame: t % 16 === 15 ? BLINK : IDLE,
173    marks: [],
174    x: Math.floor(t / 4) % 2,
175    line: IDLE_LINES[Math.floor(t / 40) % IDLE_LINES.length]!,
176    isWorking,
177  }
178}
179
180export function viewKey(v: View): string {
181  return `${v.frame.join('|')}#${v.x}#${v.line}#${JSON.stringify(v.marks)}`
182}
183
hooks/desks.ts 434 lines
1// The desks scene: while subagents run, each one shows up beside Clawd as a
2// small buddy typing at its own laptop, code marks floating over its head and
3// a bubble saying what that agent is doing. Pure functions of the tick and the
4// agent list, so the timer can tell whether the drawing changed and the tests
5// can drive it.
6
7export type Agent = {
8  id: string
9  type: string
10  // What its last tool call was doing; the Agent call's description until then
11  activity: string
12  // Where it stands now, cells from the first desk's left end
13  x: number
14  bornAt: number
15  // The tick its loop finished, and what it came back with
16  doneAt?: number
17  result?: string
18}
19
20export type Style = { bg?: string; fg?: string; dim?: boolean; italic?: boolean; bold?: boolean }
21export type Segment = { text: string; style: Style }
22
23export const ROWS = 4
24// Clawd's 8 cells and a gap
25const DESKS_X = 11
26// A buddy at its laptop, in cells
27const SPRITE = 10
28// Where the buddy's body starts inside that, so a walking one lines up with it
29const BODY_X = 5
30// Where a new buddy starts: stepping out of Clawd
31export const START_X = 4 - DESKS_X - BODY_X
32// A desk: the sprite and a small gap to the next
33const SLOT = 13
34// Cells a buddy walks per tick
35const STEP = 2
36// Each working buddy holds the floor this long
37const SPEAK_TICKS = 12
38// A finished buddy shows its result this long, then walks off
39const RESULT_TICKS = 16
40
41const EYE_COLOR = '#1A1A1A'
42const DONE_COLOR = '#8DB360'
43const PALETTE = ['#6A9BCC', '#8DB360', '#C77DBB', '#D4A23C', '#5FB3A9', '#E07A5F']
44const MARKS = ['=>', '++', '{}', '</>', '()', '&&', ';;', '#!']
45const MARK_COLORS = ['#E58FD0', '#F2C14E', '#7FD1E8']
46
47// Pixel colors: O Clawd, K eye, G laptop; . nothing
48const PIXEL: Record<string, string> = { O: 'claude', K: EYE_COLOR, G: '#6B7280' }
49
50// Sprites are four pixel rows of two pixels a cell, drawn in quarter blocks
51// as fine as the big Clawd: a small one with an eye in each upper corner,
52// short arms and its legs in pairs. The laptop leans on its left.
53// At the laptop, two frames of typing: the hand down on a key, then up.
54const TYPING = [
55  ['..G........OOOOOOOO.', '..G........OKOOOOKO.', '...G.....OOOOOOOOOOO', '...GGGGG...O.O..O.O.'],
56  ['..G........OOOOOOOO.', '..G......O.OKOOOOKO.', '...G......OOOOOOOOOO', '...GGGGG...O.O..O.O.'],
57]
58// Done: the laptop shut, the arm resting
59const DONE = ['...........OOOOOOOO.', '...........OKOOOOKO.', '..........OOOOOOOOOO', '...GGGGG...O.O..O.O.']
60// Walking, no laptop: two steps
61const WALKING = [
62  ['.OOOOOOOO.', '.OKOOOOKO.', 'OOOOOOOOOO', '.O.O..O.O.'],
63  ['.OOOOOOOO.', '.OKOOOOKO.', 'OOOOOOOOOO', '..O.O.O.O.'],
64]
65
66function blink(sprite: string[]): string[] {
67  return sprite.map(row => row.replace(/K/g, 'O'))
68}
69
70// One color per agent type, the same every session
71export function colorOf(type: string): string {
72  let h = 0
73  for (const c of type) h = (h * 31 + c.charCodeAt(0)) >>> 0
74  return PALETTE[h % PALETTE.length]!
75}
76
77export function oneLine(s: string): string {
78  return s.replace(/\s+/g, ' ').trim()
79}
80
81// A command's secrets masked before it shows on screen: a secret flag's value,
82// an auth header, a query parameter or variable named like a key, and long
83// runs of letters and digits that read as a token
84export function redact(command: string): string {
85  return command
86    .replace(/(--?(?:token|password|passwd|secret|api[-_]?key|auth)[= ]+)[^\s'"]+/gi, '$1***')
87    .replace(/(authorization:\s*(?:bearer|basic|token)?\s*)[^\s'"]+/gi, '$1***')
88    .replace(/([?&](?:token|access_token|api_key|key|secret|password)=)[^&\s'"]+/gi, '$1***')
89    .replace(/\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*=)[^\s'"]+/g, '$1***')
90    .replace(/[A-Za-z0-9_\-+=]{32,}/g, run => (/\d/.test(run) && /[A-Za-z]/.test(run) ? '***' : run))
91}
92
93function baseName(p: string): string {
94  return p.split(/[\\/]/).filter(Boolean).pop() ?? p
95}
96
97// A few words for a tool call, from its name and arguments
98export function describe(tool: string, input: Record<string, unknown>): string {
99  const s = (k: string) => (typeof input[k] === 'string' ? oneLine(input[k] as string) : '')
100  switch (tool) {
101    case 'Read':
102      return `leyendo ${baseName(s('file_path'))}`
103    case 'Edit':
104    case 'MultiEdit':
105      return `editando ${baseName(s('file_path'))}`
106    case 'NotebookEdit':
107      return `editando ${baseName(s('notebook_path'))}`
108    case 'Write':
109      return `escribiendo ${baseName(s('file_path'))}`
110    case 'Grep':
111      return `buscando "${s('pattern')}"`
112    case 'Glob':
113      return `buscando ${s('pattern')}`
114    case 'Bash':
115    case 'PowerShell':
116      return s('description') || `$ ${redact(s('command'))}`
117    case 'WebSearch':
118      return `googleando "${s('query')}"`
119    case 'WebFetch': {
120      const m = /^[a-z]+:\/\/([^/]+)/i.exec(s('url'))
121      return `leyendo ${m ? m[1] : 'la web'}`
122    }
123    case 'Agent':
124      return `delegando: ${s('description')}`
125    case 'TodoWrite':
126      return 'planeando'
127    default:
128      return tool.startsWith('mcp__') ? tool.split('__').pop()! : tool
129  }
130}
131
132// The first line of a subagent's last message, without markdown marks
133export function summary(message: string | undefined): string {
134  const line = (message ?? '').split('\n').map(l => l.replace(/^[\s#>*`-]+/, '').trim()).find(Boolean)
135  return line ? line.replace(/[*`]/g, '') : 'listo'
136}
137
138type Seats = { slot: number; shown: Agent[]; more: number }
139
140function seats(agents: Agent[], bodyColumns: number): Seats {
141  const room = Math.max(SLOT, bodyColumns - DESKS_X - 1)
142  // Too many for the room: keep four cells at the end for the count
143  const usable = agents.length > Math.floor(room / SLOT) ? room - 4 : room
144  const shown = agents.slice(0, Math.max(1, Math.floor(usable / SLOT)))
145  return { slot: SLOT, shown, more: agents.length - shown.length }
146}
147
148function isLeaving(a: Agent, t: number): boolean {
149  return a.doneAt !== undefined && t - a.doneAt >= RESULT_TICKS
150}
151
152// One tick of walking: each buddy steps toward its desk, a leaving one back
153// into Clawd, the way it came, where it is dropped
154export function walk(agents: Agent[], t: number, bodyColumns: number): Agent[] {
155  const { slot, shown } = seats(agents, bodyColumns)
156  const out: Agent[] = []
157  agents.forEach((a, i) => {
158    if (i >= shown.length) return out.push(a)
159    const target = isLeaving(a, t) ? START_X : i * slot
160    const x = a.x < target ? Math.min(target, a.x + STEP) : Math.max(target, a.x - STEP)
161    if (isLeaving(a, t) && x <= START_X) return
162    out.push(x === a.x ? a : { ...a, x })
163  })
164  return out
165}
166
167type Cell = { ch: string; style: Style }
168
169const NONE: Style = {}
170const BLANK: Cell = { ch: ' ', style: NONE }
171
172// Cells a code point takes in a terminal: none for joiners, variation
173// selectors, skin tones and combining marks; two for wide East Asian and emoji
174function cellWidth(cp: number): number {
175  if (
176    cp === 0x200d ||
177    (cp >= 0x0300 && cp <= 0x036f) ||
178    (cp >= 0xfe00 && cp <= 0xfe0f) ||
179    (cp >= 0x1f3fb && cp <= 0x1f3ff)
180  ) {
181    return 0
182  }
183  if (
184    (cp >= 0x1100 && cp <= 0x115f) ||
185    (cp >= 0x2e80 && cp <= 0xa4cf) ||
186    (cp >= 0xac00 && cp <= 0xd7a3) ||
187    (cp >= 0xf900 && cp <= 0xfaff) ||
188    (cp >= 0xfe30 && cp <= 0xfe4f) ||
189    (cp >= 0xff00 && cp <= 0xff60) ||
190    (cp >= 0xffe0 && cp <= 0xffe6) ||
191    (cp >= 0x1f300 && cp <= 0x1faff) ||
192    (cp >= 0x20000 && cp <= 0x3fffd)
193  ) {
194    return 2
195  }
196  return 1
197}
198
199// Each code point and the cells it takes; one after a joiner is drawn into
200// the glyph before it
201function glyphs(text: string): { ch: string; cells: number }[] {
202  const out: { ch: string; cells: number }[] = []
203  let isJoined = false
204  for (const ch of text) {
205    const cp = ch.codePointAt(0)!
206    out.push({ ch, cells: isJoined ? 0 : cellWidth(cp) })
207    isJoined = cp === 0x200d
208  }
209  return out
210}
211
212// How many terminal columns `text` takes
213export function columns(text: string): number {
214  return glyphs(text).reduce((n, g) => n + g.cells, 0)
215}
216
217// A wide glyph is its cell and an empty one after it. Clearing either half
218// clears the other, so a glyph drawn over it never leaves half a wide one
219function clear(row: Cell[], x: number) {
220  if (row[x]!.ch === '' && x > 0) row[x - 1] = BLANK
221  else if (row[x + 1]?.ch === '') row[x + 1] = BLANK
222  row[x] = BLANK
223}
224
225function put(row: Cell[], x: number, text: string, style: Style) {
226  let last = -1
227  for (const { ch, cells } of glyphs(text)) {
228    if (cells === 0) {
229      if (last >= 0) row[last] = { ch: row[last]!.ch + ch, style }
230      continue
231    }
232    if (x >= 0 && x + cells <= row.length) {
233      for (let k = 0; k < cells; k++) clear(row, x + k)
234      row[x] = { ch, style }
235      for (let k = 1; k < cells; k++) row[x + k] = { ch: '', style }
236      last = x
237    } else {
238      last = -1
239    }
240    x += cells
241  }
242}
243
244// Clawd's own sprite, a cell per pixel, and what it holds; . is empty
245const CELL: Record<string, Cell> = {
246  // Orange cell; one with a dark eye in a lower quarter; one with a closed eye
247  O: { ch: ' ', style: { bg: 'claude', fg: EYE_COLOR } },
248  L: { ch: '▖', style: { bg: 'claude', fg: EYE_COLOR } },
249  R: { ch: '▗', style: { bg: 'claude', fg: EYE_COLOR } },
250  '-': { ch: '▁', style: { bg: 'claude', fg: EYE_COLOR } },
251  // An arm at rest and raised, and a leg at either side of its cell
252  '^': { ch: '▀', style: { fg: 'claude' } },
253  v: { ch: '▄', style: { fg: 'claude' } },
254  a: { ch: '▘', style: { fg: 'claude' } },
255  b: { ch: '▝', style: { fg: 'claude' } },
256  // A pan's handle, low and thin, and its pan
257  h: { ch: '▄', style: { fg: '#8B5A2B' } },
258  P: { ch: ' ', style: { bg: '#6B7280' } },
259  // A cigarette, thin as a line, and its ember dim and drawn on
260  c: { ch: '▂', style: { fg: '#EDEDED' } },
261  C: { ch: '▂', style: { fg: '#C2410C' } },
262  Y: { ch: '▂', style: { fg: '#FFB347' } },
263}
264
265function paintCells(row: Cell[], x: number, sprite: string) {
266  ;[...sprite].forEach((kind, i) => {
267    const cell = CELL[kind]
268    if (cell && x + i >= 0 && x + i < row.length) row[x + i] = cell
269  })
270}
271
272// A cell's quarters as a glyph, by the quarters set: 1 upper left, 2 upper
273// right, 4 lower left, 8 lower right
274const QUARTERS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
275const PIXEL_ORDER = Object.values(PIXEL)
276
277// A sprite of two pixel rows and two pixel columns per cell. A cell all one
278// color is its background, so whole cells leave no seam; a full cell of two
279// is the commoner one behind a glyph of the other; a cell with empty pixels
280// is a glyph alone, in its rarest color
281function paintQuarters(rows: Cell[][], top: number, x: number, sprite: string[]) {
282  const width = Math.ceil(Math.max(...sprite.map(s => s.length)) / 2)
283  for (let r = 0; r * 2 < sprite.length; r++) {
284    const row = rows[top + r]!
285    const upper = sprite[r * 2]!
286    const lower = sprite[r * 2 + 1] ?? ''
287    for (let i = 0; i < width; i++) {
288      if (x + i < 0 || x + i >= row.length) continue
289      const px = [upper[i * 2], upper[i * 2 + 1], lower[i * 2], lower[i * 2 + 1]].map(p => PIXEL[p ?? '.'])
290      const mask = (c: string | undefined) => px.reduce((m, p, k) => (p === c ? m | (1 << k) : m), 0)
291      const count = (c: string) => px.filter(p => p === c).length
292      // Rarest first; a tie goes to the later color in PIXEL
293      const colors = PIXEL_ORDER.filter(c => count(c) > 0).sort(
294        (a, b) => count(a) - count(b) || PIXEL_ORDER.indexOf(b) - PIXEL_ORDER.indexOf(a),
295      )
296      if (colors.length === 0) continue
297      const isFull = px.every(p => p !== undefined)
298      if (colors.length === 1 && isFull) {
299        row[x + i] = { ch: ' ', style: { bg: colors[0]! } }
300      } else if (isFull) {
301        const bg = colors[colors.length - 1]!
302        row[x + i] = { ch: QUARTERS[15 - mask(bg)]!, style: { fg: colors[0]!, bg } }
303      } else {
304        row[x + i] = { ch: QUARTERS[15 - mask(undefined)]!, style: { fg: colors[0]! } }
305      }
306    }
307  }
308}
309
310function sameStyle(a: Style, b: Style): boolean {
311  return a.bg === b.bg && a.fg === b.fg && a.dim === b.dim && a.italic === b.italic && a.bold === b.bold
312}
313
314// Consecutive cells of one style as one segment, trailing blanks dropped
315function segments(row: Cell[]): Segment[] {
316  let end = row.length
317  while (end > 0 && row[end - 1]!.ch === ' ' && row[end - 1]!.style === NONE) end -= 1
318  const out: Segment[] = []
319  for (const cell of row.slice(0, end)) {
320    const last = out[out.length - 1]
321    if (last && sameStyle(last.style, cell.style)) last.text += cell.ch
322    else out.push({ text: cell.ch, style: cell.style })
323  }
324  return out
325}
326
327function fit(text: string, width: number): string {
328  if (columns(text) <= width) return text
329  let out = ''
330  let used = 0
331  for (const { ch, cells } of glyphs(text)) {
332    if (used + cells > width - 1) break
333    out += ch
334    used += cells
335  }
336  return out + '…'
337}
338
339// The drawing at tick `t`: ROWS rows of styled segments
340export function scene(
341  t: number,
342  agents: Agent[],
343  clawd: [string, string, string],
344  line: string,
345  bodyColumns: number,
346): Segment[][] {
347  const width = Math.max(DESKS_X + SLOT, bodyColumns)
348  const rows: Cell[][] = Array.from({ length: ROWS }, () =>
349    Array.from({ length: width }, () => ({ ch: ' ', style: NONE })),
350  )
351  const { slot, shown, more } = seats(agents, bodyColumns)
352
353  if (more > 0) put(rows[3]!, DESKS_X + shown.length * slot, `+${more}`, { dim: true })
354
355  // Working buddies take turns holding the floor
356  const working = shown.filter((a, i) => a.doneAt === undefined && a.x === i * slot)
357  const speaker = working.length ? working[Math.floor(t / SPEAK_TICKS) % working.length] : undefined
358
359  shown.forEach((a, i) => {
360    const x = DESKS_X + a.x
361    const isSeated = a.x === i * slot
362    const isDone = a.doneAt !== undefined
363    const blinks = (t + i * 5) % 16 === 15
364
365    if (!isSeated) {
366      const step = WALKING[(t + i) % 2]!
367      paintQuarters(rows, 2, x + BODY_X, blinks ? blink(step) : step)
368      return
369    }
370
371    if (isDone) {
372      paintQuarters(rows, 2, x, DONE)
373      put(rows[1]!, x + BODY_X + 2, '✓', { fg: DONE_COLOR, bold: true })
374    } else {
375      const typing = TYPING[(Math.floor(t / 2) + i) % 2]!
376      paintQuarters(rows, 2, x, blinks ? blink(typing) : typing)
377      // Code marks pop up over its head, two beats in three
378      const beat = Math.floor(t / 3) + i * 7
379      if (beat % 3 !== 2) {
380        const mark = MARKS[beat % MARKS.length]!
381        put(rows[1]!, x + BODY_X + (beat % 3) * 2, mark, { fg: MARK_COLORS[beat % MARK_COLORS.length]! })
382      }
383    }
384  })
385
386  // Bubbles: each fits its desk, but the speaker's runs on as far as the band
387  // goes, and the neighbors it runs over keep quiet meanwhile
388  let spokenTo = 0
389  if (speaker) {
390    const x = DESKS_X + speaker.x
391    const text = fit(speaker.activity, width - x)
392    put(rows[0]!, x, text, { fg: colorOf(speaker.type), bold: true })
393    spokenTo = x + columns(text) + 1
394  }
395  shown.forEach((a, i) => {
396    const x = DESKS_X + a.x
397    if (a === speaker || a.x !== i * slot || (x < spokenTo && x > DESKS_X + (speaker?.x ?? 0))) return
398    const isDone = a.doneAt !== undefined
399    const text = isDone ? `✓ ${a.result ?? 'listo'}` : a.activity
400    put(rows[0]!, x, fit(text, slot - 1), { fg: colorOf(a.type), dim: !isDone })
401  })
402
403  // Clawd on the left, its own line above it, in front of a buddy walking in
404  // or out of it
405  put(rows[0]!, 0, fit(line, DESKS_X - 1), { fg: 'claude', dim: true, italic: true })
406  clawd.forEach((sprite, i) => paintCells(rows[i + 1]!, 0, sprite))
407
408  return rows.map(segments)
409}
410
411// Something drawn over Clawd's scene as text: food in the air, smoke
412export type Mark = { row: number; x: number; text: string; fg: string; bg?: string }
413
414// Clawd alone: a row of air for marks, its three sprite rows `x` cells in,
415// and its line beside them
416export function solo(
417  frame: string[],
418  marks: Mark[],
419  x: number,
420  line: string,
421  isWorking: boolean,
422  bodyColumns: number,
423): Segment[][] {
424  const width = Math.max(1, bodyColumns)
425  const rows: Cell[][] = Array.from({ length: ROWS }, () =>
426    Array.from({ length: width }, () => ({ ch: ' ', style: NONE })),
427  )
428  frame.forEach((sprite, i) => paintCells(rows[i + 1]!, x, sprite))
429  for (const m of marks) put(rows[m.row]!, x + m.x, m.text, { fg: m.fg, ...(m.bg ? { bg: m.bg } : {}) })
430  const spriteWidth = Math.max(...frame.map(r => r.length))
431  put(rows[2]!, x + spriteWidth + 1, line, { fg: 'claude', dim: !isWorking, italic: true })
432  return rows.map(segments)
433}
434
types/index.d.ts 8 lines
1export type Tick = number
2
3declare module 'claude-code' {
4  interface PluginState {
5    'clawd-buddy': { tick: Tick }
6  }
7}
8