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

Clawd, the Claude Code mascot, above the prompt. It reacts to what Claude is doing:
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).
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
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.
hooks/register.tsx 184 lines1import { 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}
184hooks/clawd.ts 183 lines1// 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}
183hooks/desks.ts 434 lines1// 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}
434types/index.d.ts 8 lines1export type Tick = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'clawd-buddy': { tick: Tick }
6 }
7}
8