SLOPSHOPPER

progress-buddy

A progress band with a little coral friend for Claude Code: task and subtask lines, a gradient progress bar, subagent helpers and moods that follow what Claude…

newbandguardprompttooltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-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 (^‿^) ▣ client module ./band.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(^‿^) ▣ client module ./band.tsx
README

Claude Buddy

A progress band for Claude Code with a little coral friend. It sits above the prompt and shows what Claude is working on, how far along it is, and what it is doing right now.

Claude Buddy: the band while thinking, coding, in the console, using tools, running subagents and finished, plus the moods and outfits

The faces are the plugin's own SVG. The bar is drawn as text cells, as it is in the app.

What you get

  • Task and subtask. The bold line is the task ("Fix the login redirect"). The line under the bar is what Claude is doing at this moment ("Editing auth.ts").
  • A gradient progress bar that fills as steps finish, eases toward its new value, and flows pink → violet → cyan. With no step list it shows a sweeping loader instead.
  • Done state. When the work finishes the bar fills, turns green, pulses, and reads ✓ Done. It stays until your next prompt.
  • Always there. The band is visible from the start of a session. Idle, it says "Ready when you are".
  • A plain fallback. If the surface reports that the animated band failed (the desktop app drops it when a redraw goes unanswered for two seconds), the plugin draws a plain, unanimated bar with the same information, and tries the animated one again on your next prompt.
  • A buddy with moods. The face follows the work: thinking, looking (reading and searching), coding (edits and writes, at a laptop), console (shell commands, behind a little terminal), tool (everything else, with a wrench and spinning gears), oops (a tool failed), happy (done), sleepy. Between turns it cycles through faces.
  • Clothes. Hats and a jacket that sit on the body, so they follow every face and animation. A party hat when the work is done, a nightcap when asleep, a hard hat and hi-vis vest for tools; otherwise an everyday cap or beanie and a jacket or hoodie that change with each new task.
  • Subagents. Each running subagent gets its own line (↳ Explore · find the login code · Reading auth.ts) and a small working copy of the buddy beside the main one (up to three).

Install

claude plugin marketplace add karrot0/claude-buddy
claude plugin install progress-buddy@progress-buddy

Start a new session and send a prompt.

Requirements. This plugin uses Claude Code's function-hook plugin API (a hooks module that draws UI), which is early access and still moving between releases. It was built and tested against the Claude desktop app's Code tab (engine 2.1.289). The terminal draws text faces such as (•̀_•́)⌨ instead of the SVG. Older builds will ignore the plugin.

How it works

  • Hooks on tool.call, turn.complete, prompt.submit and session.start keep a few values in $.state (the step list, the title, the mood, the run state).
  • The band is a ui.render hook on AbovePrompt. The face is an SVG; the title, bar and subtask are drawn by a surface module (hooks/band.tsx) that animates on its own frame clock and asks the plugin for fresh text four times a second. That keeps the face from being redrawn on every animation frame.
  • Subagent events carry an agentId; they are tracked separately and never change the main status.
  • Steps come from TodoWrite / TaskCreate / TaskUpdate when the session has them. Sessions that don't (the desktop Code tab) get a set_tasks tool (mcp__progress-buddy__set_tasks) that the model calls with a title and the full step list.

Moods

MoodWhen
thinkingafter you send a prompt, before any tool runs
lookingRead, Grep, Glob, web fetch and search
consoleshell commands (Bash, PowerShell)
toolevery other tool: MCP tools, skills, agents
codingEdit, Write, NotebookEdit
oopsa tool call returned an error
happythe turn finished with every step done
sleepythe turn finished with steps still open

What the plugin does with your session

Everything runs locally inside Claude Code. The plugin makes no network requests, spawns no processes, reads and writes no files, and does not depend on or call any other plugin. It uses only these calls on the hook object: $.state.get/set (its own few values), $.clock.every (one timer that cycles the face while idle), $.tool.register (adds one tool, below), $.agent.list (read-only list of running subagents), $.ui.resolve (to draw), and $.ui.invalidate (to redraw the band).

It never submits a prompt of its own, runs a tool or command itself, changes a tool's input, or blocks another tool's call. Every hook below passes the event on unchanged, except that its own tool refuses malformed input.

HookWhat it doesChanges anything?
tool.call (every tool)Reads the tool name and a few input fields (file name, search pattern, description, URL, or the first words of a shell command, cut to 120 characters) to print a short "Editing auth.ts" line and pick a mood. Keeps it in memory only; it is shown in the band and never stored or sent. Calls from subagents are tracked separately. Marks the mood "oops" if the call returns an error. Calls next(e) and returns its result untouched.No
tool.call for TodoWrite, TaskCreate, TaskUpdateAfter the call succeeds, copies the step list into the band. Returns the real result untouched.No
tool.call for mcp__progress-buddy__set_tasksAnswers the plugin's own tool (see below). This is the only place the plugin stands in for a tool.Its own tool only
prompt.submitMarks the turn as running. If the previous work is finished and the prompt is one you typed, uses its first line (80 characters) as the new task title. Prompts you did not type (a background task notification, another session, an engine notice, or anything starting with a markup tag) keep the current title. Returns the prompt unchanged.No
turn.completeMarks the run done or idle and picks the happy or sleepy face.No
session.startRegisters set_tasks and starts the face-cycling timer.Adds a tool
ui.render on AbovePromptDraws the band.UI only
ui.messageAnswers the band's own heartbeat (the number 1, posted by the surface module hooks/band.tsx four times a second) with the text to display. Nothing else is sent anywhere.No
ui.faultHears that the animated band failed on the surface and switches to the plain bar. Observes only.No

The tool it adds

mcp__progress-buddy__set_tasks takes a title and a todos list (content, status, optional activeForm). The model calls it at the start of a multi-step task and again whenever a step starts or finishes. The plugin stores the list in session state and returns n/m done. It does nothing else, and it is only registered so sessions without TodoWrite still have a way to report steps.

Tests

The test suite is not part of this plugin. It lives in the repository's dev/tests/ folder, outside this folder, and runs against the test kit's mock engine, where it calls tools and prompt.submit to exercise the hooks. Nothing in this folder calls a tool, runs a command or submits a prompt.

Layout

hooks/register.tsx (hooks, state and the SVG buddy), hooks/band.tsx (the animated bar and text), types/index.d.ts (state contract), .claude-plugin/icon.png (listing icon).

License

MIT. Claude Buddy is an unofficial community project and is not affiliated with or endorsed by Anthropic.

Source 3 files
hooks/register.tsx 751 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hat, Jacket, Mood, Outfit, Run, Task } from '../types'
5
6const BODY = '#D97757'
7const SHADE = '#C4623F'
8const INK = '#2B1B14'
9const MARK = '#E9967A'
10
11const KAOMOJI: Record<Mood, string> = {
12  thinking: '(•_• )…',
13  looking: '(◉_◉)',
14  working: '(•̀_•́)',
15  coding: '(•̀_•́)⌨',
16  console: '(•_•)>_',
17  tool: '(•̀ᴗ•́)و',
18  oops: '(°□°)',
19  happy: '(^‿^)',
20  sleepy: '(-_-) z',
21}
22
23const EYE_W = 6.2
24const EYE_H = 12.6
25const EYE_Y = 30
26const LEFT_X = 23.5
27const RIGHT_X = 40.5
28
29// Every pose is plain transforms on the same two capsules, as blobatar does it: scale in the eye's own frame, tilt, then offset.
30const CSS = `
31svg{overflow:visible}
32:root{color-scheme:light dark}
33.sw,.bg{transform-box:view-box;transform-origin:32px 52px}
34.bg{animation:breathe 3.2s ease-in-out infinite alternate}
35.e,.al,.ar,.sh,.sp,.z,.drop{transform-box:fill-box;transform-origin:center}
36.al{transform-origin:100% 50%}
37.ar{transform-origin:0% 50%}
38.b{transform-box:fill-box;transform-origin:center;animation:blink 4.6s infinite}
39@keyframes breathe{to{transform:scale(1.025,.975)}}
40@keyframes blink{0%,94%,100%{transform:scaleY(1)}96.5%{transform:scaleY(.12)}}
41
42.m-thinking .el{transform:translate(0,2.7px) scale(1.18,.62);animation:seesawL .9s ease-in-out infinite alternate}
43.m-thinking .er{transform:translate(0,-2.7px) scale(1.18,.62);animation:seesawR .9s ease-in-out infinite alternate}
44.m-thinking .sw{animation:sway 2.6s ease-in-out infinite alternate}
45@keyframes seesawL{to{transform:translate(0,-2.7px) scale(1.18,.62)}}
46@keyframes seesawR{to{transform:translate(0,2.7px) scale(1.18,.62)}}
47@keyframes sway{from{transform:rotate(-2.5deg)}to{transform:rotate(2.5deg)}}
48
49.m-looking .e{transform:scale(1.05)}
50.m-looking .eyes{animation:glance 3.4s linear infinite}
51@keyframes glance{0%,14%{transform:translate(0,0)}17%,38%{transform:translate(-2.8px,-.5px)}41%,62%{transform:translate(2.8px,.3px)}65%,80%{transform:translate(1.2px,-1.6px)}83%,100%{transform:translate(0,0)}}
52
53.m-working .el{transform:translate(0,.8px) rotate(14deg) scale(1.45,.46)}
54.m-working .er{transform:translate(0,.8px) rotate(-14deg) scale(1.45,.46)}
55.m-working .bg{animation:type .36s ease-in-out infinite alternate}
56.m-working .al{animation:armL .36s ease-in-out infinite alternate}
57.m-working .ar{animation:armR .36s ease-in-out infinite alternate-reverse}
58@keyframes type{to{transform:translateY(-.9px)}}
59@keyframes armL{from{transform:translateY(1px)}to{transform:translateY(-1.6px) rotate(8deg)}}
60@keyframes armR{from{transform:translateY(1px)}to{transform:translateY(-1.6px) rotate(-8deg)}}
61
62.m-coding .el{transform:translate(0,1px) rotate(12deg) scale(1.38,.5)}
63.m-coding .er{transform:translate(0,1px) rotate(-12deg) scale(1.38,.5)}
64.m-coding .eyes{animation:read 2s steps(1,end) infinite}
65.m-coding .bg{animation:hunch .3s ease-in-out infinite alternate}
66.m-coding .al{transform:translate(7px,12px) rotate(-14deg);animation:keysL .17s ease-in-out infinite alternate}
67.m-coding .ar{transform:translate(-7px,12px) rotate(14deg);animation:keysR .17s ease-in-out infinite alternate-reverse}
68.glow{animation:screenlight 1.3s ease-in-out infinite alternate}
69.logo{animation:glow 1.3s ease-in-out infinite alternate;transform-box:fill-box;transform-origin:center}
70.code{opacity:0;animation:rise 2.4s ease-out infinite}
71.code2{animation-delay:.8s}
72.code3{animation-delay:1.6s}
73@keyframes read{0%{transform:translate(-1.5px,-.8px)}25%{transform:translate(1.5px,-.8px)}50%{transform:translate(-1.5px,.4px)}75%{transform:translate(1.5px,.4px)}}
74@keyframes hunch{from{transform:translateY(.4px)}to{transform:translateY(1px)}}
75@keyframes keysL{from{transform:translate(7px,12px) rotate(-14deg)}to{transform:translate(7px,10px) rotate(-14deg)}}
76@keyframes keysR{from{transform:translate(-7px,12px) rotate(14deg)}to{transform:translate(-7px,10px) rotate(14deg)}}
77@keyframes glow{from{opacity:.45}to{opacity:1}}
78@keyframes screenlight{from{opacity:.06}to{opacity:.2}}
79@keyframes rise{0%{opacity:0;transform:translateY(5px)}20%{opacity:1}100%{opacity:0;transform:translateY(-12px)}}
80
81.m-console .e{transform:translate(0,2.2px) scale(1,.9)}
82.m-console .eyes{animation:scan 2.6s ease-in-out infinite alternate}
83.m-console .al{transform:translate(6px,11px) rotate(-12deg)}
84.m-console .ar{transform:translate(-6px,11px) rotate(12deg)}
85.cur{animation:cursor 1s steps(1,end) infinite}
86.ln{transform-box:fill-box;transform-origin:0% 50%;animation:print 2.6s steps(6,end) infinite}
87.ln2{animation-delay:.9s}
88@keyframes scan{from{transform:translate(-1.8px,0)}to{transform:translate(1.8px,0)}}
89@keyframes cursor{0%{opacity:1}50%{opacity:0}}
90@keyframes print{0%{transform:scaleX(0)}55%,100%{transform:scaleX(1)}}
91
92.m-tool .el{transform:translate(0,.6px) rotate(9deg) scale(1.3,.58)}
93.m-tool .er{transform:translate(0,.6px) rotate(-9deg) scale(1.3,.58)}
94.m-tool .bg{animation:type .5s ease-in-out infinite alternate}
95.m-tool .ar{transform:rotate(-34deg)}
96.gear{transform-box:fill-box;transform-origin:center;animation:spin 2.8s linear infinite}
97.gear2{animation-duration:2s;animation-direction:reverse}
98.wrench{transform-box:view-box;transform-origin:56px 30px;animation:tighten .5s ease-in-out infinite alternate}
99@keyframes spin{to{transform:rotate(360deg)}}
100@keyframes tighten{from{transform:rotate(-8deg)}to{transform:rotate(8deg)}}
101
102.m-oops .el{transform:translate(1px,-1.6px) rotate(-12deg) scale(.88,1)}
103.m-oops .er{transform:translate(-1px,-1.6px) rotate(12deg) scale(.88,1)}
104.m-oops .sw{animation:shake .12s linear infinite}
105.m-oops .bg{animation:none}
106.m-oops .al{transform:rotate(26deg)}
107.m-oops .ar{transform:rotate(-26deg)}
108.drop{animation:fall 1.1s ease-in infinite}
109@keyframes shake{0%,100%{transform:translate(.7px,-.3px)}25%{transform:translate(-.8px,.2px)}50%{transform:translate(.4px,.6px)}75%{transform:translate(-.5px,-.6px)}}
110@keyframes fall{0%{transform:translateY(-1px);opacity:0}15%{opacity:1}100%{transform:translateY(9px);opacity:0}}
111
112.m-happy .sw{animation:bounce .66s ease-in-out infinite}
113.m-happy .bg{animation:none}
114.m-happy .sh{animation:land .66s ease-in-out infinite}
115.m-happy .al{animation:waveL .33s ease-in-out infinite alternate}
116.m-happy .ar{animation:waveR .33s ease-in-out infinite alternate}
117.m-happy .b{animation:none}
118.sp{animation:twinkle 1.1s ease-in-out infinite}
119.sp2{animation-delay:.55s}
120@keyframes bounce{0%,100%{transform:translateY(0) scale(1.05,.94)}45%{transform:translateY(-5.5px) scale(.97,1.04)}}
121@keyframes land{0%,100%{transform:scale(1)}45%{transform:scale(.8)}}
122@keyframes waveL{from{transform:rotate(40deg)}to{transform:rotate(14deg)}}
123@keyframes waveR{from{transform:rotate(-40deg)}to{transform:rotate(-14deg)}}
124@keyframes twinkle{0%,100%{opacity:0;transform:scale(.3) rotate(0deg)}50%{opacity:1;transform:scale(1) rotate(45deg)}}
125
126.m-sleepy .el,.m-sleepy .er{transform:translate(0,3.4px) scale(1.15,.2)}
127.m-sleepy .b{animation:none}
128.m-sleepy .bg{transform:translateY(1.4px);animation:snooze 3.6s ease-in-out infinite alternate}
129.m-sleepy .al{transform:rotate(-12deg)}
130.m-sleepy .ar{transform:rotate(12deg)}
131.z{opacity:0;animation:zz 3s ease-out infinite}
132.z2{animation-delay:1s}
133.z3{animation-delay:2s}
134@keyframes snooze{from{transform:translateY(1.4px) scale(1.03,.96)}to{transform:translateY(1.4px) scale(1,1)}}
135@keyframes zz{0%{opacity:0;transform:translate(0,4px)}25%{opacity:1}100%{opacity:0;transform:translate(5px,-9px)}}
136
137@media (prefers-reduced-motion:reduce){*{animation:none!important}}
138`.replace(/\n+/g, '\n')
139
140const eye = (cls: string, cx: number) =>
141  `<g class="e ${cls}"><rect class="b" x="${cx - EYE_W / 2}" y="${EYE_Y - EYE_H / 2}" width="${EYE_W}" height="${EYE_H}" rx="${EYE_W / 2}" fill="${INK}"/></g>`
142
143const arc = (cx: number) =>
144  `<path d="M${cx - 5.3} 33.6Q${cx} 25 ${cx + 5.3} 33.6" fill="none" stroke="${INK}" stroke-width="3.4" stroke-linecap="round"/>`
145
146const star = (cls: string, x: number, y: number, r: number) =>
147  `<path class="sp ${cls}" d="M${x} ${y - r}L${x + r * 0.28} ${y - r * 0.28}L${x + r} ${y}L${x + r * 0.28} ${y + r * 0.28}L${x} ${y + r}L${x - r * 0.28} ${y + r * 0.28}L${x - r} ${y}L${x - r * 0.28} ${y - r * 0.28}Z" fill="#FFD166"/>`
148
149const zed = (cls: string, x: number, y: number, s: number) =>
150  `<path class="z ${cls}" d="M${x} ${y}h${s}l${-s} ${s * 1.2}h${s}" fill="none" stroke="${MARK}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`
151
152const CODE = '#7CE7B0'
153
154const glyph = (cls: string, x: number, y: number) =>
155  `<path class="code ${cls}" d="M${x + 3} ${y}l-3 2.6l3 2.6M${x + 5} ${y + 5.6}l2 -6.2M${x + 9} ${y}l3 2.6l-3 2.6" fill="none" stroke="${CODE}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`
156
157// Seen from behind, the lid hides the lower body and feet; the arms reach round to the keys.
158const LAPTOP =
159  `<rect x="12" y="52.6" width="40" height="3.8" rx="1.9" fill="#26262C"/>` +
160  `<rect x="15" y="40" width="34" height="13.4" rx="3" fill="#34343C"/>` +
161  `<rect x="15" y="40" width="34" height="2.2" rx="1.1" fill="#4A4A55"/>` +
162  `<g class="logo" stroke="${BODY}" stroke-width="1.5" stroke-linecap="round"><path d="M32 43.8v5.8M29.1 45.4l5.8 2.6M34.9 45.4l-5.8 2.6"/></g>`
163
164// A terminal window facing us: a blinking prompt and two lines of output printing.
165const TERMINAL =
166  `<rect x="11" y="38.5" width="42" height="18.5" rx="3" fill="#0F1014" stroke="#3A3A44" stroke-width=".8"/>` +
167  `<path d="M11 43.4V41.5a3 3 0 0 1 3-3h36a3 3 0 0 1 3 3v1.9z" fill="#2A2A31"/>` +
168  `<circle cx="14.6" cy="41" r=".95" fill="#FF5F57"/><circle cx="17.6" cy="41" r=".95" fill="#FEBC2E"/><circle cx="20.6" cy="41" r=".95" fill="#28C840"/>` +
169  `<path d="M14.6 46.2l2.4 1.9l-2.4 1.9" fill="none" stroke="${CODE}" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>` +
170  `<rect class="cur" x="18.8" y="49.2" width="3.2" height="1.2" fill="${CODE}"/>` +
171  `<rect class="ln ln1" x="14.4" y="52" width="20" height="1.2" rx=".6" fill="#6B6B78"/>` +
172  `<rect class="ln ln2" x="14.4" y="54.3" width="13" height="1.2" rx=".6" fill="#55555F"/>`
173
174const STEEL = '#B9BEC9'
175
176const gear = (cls: string, cx: number, cy: number, r: number) =>
177  `<g class="gear ${cls}"><circle cx="${cx}" cy="${cy}" r="${r * 0.62}" fill="none" stroke="${STEEL}" stroke-width="${r * 0.5}"/>` +
178  Array.from(
179    { length: 8 },
180    (_, k) =>
181      `<rect x="${cx - r * 0.2}" y="${cy - r}" width="${r * 0.4}" height="${r * 0.42}" rx=".4" fill="${STEEL}" transform="rotate(${k * 45} ${cx} ${cy})"/>`,
182  ).join('') +
183  `</g>`
184
185const WRENCH =
186  `<g class="wrench" fill="none" stroke="${STEEL}" stroke-linecap="round"><path d="M58 27L57 14" stroke-width="2.6"/>` +
187  `<path d="M54.3 12.6a3.2 3.2 0 1 1 5 .7" stroke-width="2.4"/></g>`
188
189const NO_OUTFIT: Outfit = { hat: 'none', jacket: 'none' }
190
191// Clothes are drawn inside the body group, so they ride every pose and animation without knowing about it.
192const HATS: Record<Hat, string> = {
193  none: '',
194  cap:
195    `<path d="M18.5 17Q32 -5 45.5 17Z" fill="#3B82F6"/><path d="M41 15.6Q51 13.8 55.4 18.2Q48 18.6 41.4 17.6Z" fill="#2563EB"/>` +
196    `<circle cx="32" cy="6.6" r="1.3" fill="#2563EB"/>`,
197  beanie:
198    `<path d="M17.6 18Q32 -9 46.4 18Z" fill="#EF6F6C"/><path d="M16.8 15.2h30.4v3.6q-15.2 3-30.4 0z" fill="#D9534F"/>` +
199    `<circle cx="32" cy="3.6" r="3" fill="#FCE9E8"/>`,
200  party:
201    `<path d="M24.5 16L32 -5L39.5 16Z" fill="#8B5CF6"/><path d="M27.3 8.2l7.9 3.2M25.6 13l10.4 2.6" stroke="#FDE047" stroke-width="1.5" stroke-linecap="round"/>` +
202    `<circle cx="32" cy="-5.6" r="2.4" fill="#FDE047"/>`,
203  hardhat:
204    `<path d="M18 17Q32 -7 46 17Z" fill="#FACC15"/><rect x="14.5" y="15.4" width="35" height="3.4" rx="1.7" fill="#EAB308"/>` +
205    `<path d="M32 6.4V15" stroke="#EAB308" stroke-width="2.4" stroke-linecap="round"/>`,
206  nightcap:
207    `<path d="M18.4 17.4Q27 -6 43 5Q51.6 10 53.4 20Q47 14.6 44.6 17Z" fill="#60A5FA"/><path d="M17.4 15.2h28.4v3.4q-14.2 2.6-28.4 0z" fill="#DBEAFE"/>` +
208    `<circle cx="53.6" cy="21.4" r="2.7" fill="#F8FAFC"/>`,
209}
210
211const torso = (fill: string, details: string) =>
212  `<g clip-path="url(#bd)"><path d="M8 42.5Q32 38.5 56 42.5V54H8Z" fill="${fill}"/>${details}</g>`
213
214const JACKETS: Record<Jacket, { sleeve: string; front: string }> = {
215  none: { sleeve: SHADE, front: '' },
216  jacket: {
217    sleeve: '#2F4A7A',
218    front: torso(
219      '#3B5B92',
220      `<path d="M32 40.4V53" stroke="#22365C" stroke-width="1.2"/><path d="M32 40.4h-6.5l6.5 6.6zM32 40.4h6.5l-6.5 6.6z" fill="#587BB8"/>`,
221    ),
222  },
223  hoodie: {
224    sleeve: '#5B4BC4',
225    front: torso(
226      '#6D5BD0',
227      `<path d="M29 40.6v6M35 40.6v6" stroke="#EDE9FE" stroke-width="1.1" stroke-linecap="round"/><path d="M24 47.6h16v3.2h-16z" fill="#5B4BC4"/>`,
228    ),
229  },
230  vest: {
231    sleeve: SHADE,
232    front: torso(
233      '#F59E0B',
234      `<rect x="8" y="45" width="48" height="2.6" fill="#E5E7EB"/><path d="M32 40.4V53" stroke="#B45309" stroke-width="1.2"/>`,
235    ),
236  },
237}
238
239const EXTRAS: Record<Mood, string> = {
240  thinking: '',
241  looking: '',
242  working: '',
243  coding: LAPTOP + glyph('code1', 3, 22) + glyph('code2', 49, 20) + glyph('code3', 25, 12),
244  console: TERMINAL,
245  tool: gear('gear1', 9, 15, 6) + gear('gear2', 18.4, 5.4, 3.6),
246  oops: `<path class="drop" d="M51 8Q47.8 12.6 51 14.8Q54.2 12.6 51 8Z" fill="#7CC4FF"/>`,
247  happy: star('sp1', 53, 10, 4.6) + star('sp2', 10, 14, 3.2),
248  sleepy: zed('z1', 45, 15, 4.4) + zed('z2', 45, 15, 4.4) + zed('z3', 45, 15, 4.4),
249}
250
251const BUDDY = 26
252const HEADROOM = 16
253const FACE_SCALE = 0.75
254const FACE_HEIGHT = Math.round((64 + HEADROOM) * FACE_SCALE)
255const BODY_PATH = 'M32 12C46.3 12 55 19.6 55 32C55 44.4 46.3 52 32 52C17.7 52 9 44.4 9 32C9 19.6 17.7 12 32 12Z'
256
257function figure(mood: Mood, outfit: Outfit): string {
258  const isHappy = mood === 'happy'
259  const jacket = JACKETS[outfit.jacket]
260  const eyes = isHappy
261    ? arc(LEFT_X) + arc(RIGHT_X)
262    : eye('el', LEFT_X) + eye('er', RIGHT_X)
263  const cheeks = isHappy
264    ? `<ellipse cx="14.6" cy="38.2" rx="4" ry="2.4" fill="#FF8FA3" opacity=".6"/><ellipse cx="49.4" cy="38.2" rx="4" ry="2.4" fill="#FF8FA3" opacity=".6"/>`
265    : mood === 'coding'
266      ? `<ellipse class="glow" cx="32" cy="37" rx="15" ry="6.5" fill="#7CE7FF" opacity=".2"/>`
267      : ''
268
269  return (
270    `<g class="m-${mood}">` +
271    `<ellipse class="sh" cx="32" cy="57.6" rx="15" ry="2.4" fill="#000" opacity=".28"/>` +
272    `<g class="sw">` +
273    `<rect x="19" y="47" width="9" height="9.6" rx="3.8" fill="${SHADE}"/><rect x="36" y="47" width="9" height="9.6" rx="3.8" fill="${SHADE}"/>` +
274    `<g class="bg">` +
275    `<rect class="al" x="3.4" y="28" width="9" height="7.4" rx="3.7" fill="${jacket.sleeve}"/><rect class="ar" x="51.6" y="28" width="9" height="7.4" rx="3.7" fill="${jacket.sleeve}"/>` +
276    `<path d="${BODY_PATH}" fill="${BODY}"/>` +
277    `<ellipse cx="22.5" cy="19.6" rx="9" ry="3.6" transform="rotate(-18 22.5 19.6)" fill="#fff" opacity=".17"/>` +
278    jacket.front +
279    cheeks +
280    `<g class="eyes">${eyes}</g>` +
281    HATS[outfit.hat] +
282    (mood === 'tool' ? WRENCH : '') +
283    `</g></g>` +
284    EXTRAS[mood] +
285    `</g>`
286  )
287}
288
289// One small working copy beside the main blob per running subagent. The box has headroom above for hats and the hop.
290function face(mood: Mood, buddies = 0, outfit: Outfit = NO_OUTFIT): string {
291  const n = Math.max(0, Math.min(3, buddies))
292  const width = 64 + n * BUDDY
293  const minis = Array.from(
294    { length: n },
295    (_, i) => `<g transform="translate(${66 + i * BUDDY} 35) scale(.42)">${figure('working', NO_OUTFIT)}</g>`,
296  ).join('')
297
298  return (
299    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${-HEADROOM} ${width} ${64 + HEADROOM}" width="${Math.round(width * FACE_SCALE)}" height="${FACE_HEIGHT}">` +
300    `<style>${CSS}</style>` +
301    `<defs><clipPath id="bd"><path d="${BODY_PATH}"/></clipPath></defs>` +
302    figure(mood, outfit) +
303    minis +
304    `</svg>`
305  )
306}
307
308const tasks = atom({ plugin: 'progress-buddy', key: 'tasks' } as const, [])
309const title = atom({ plugin: 'progress-buddy', key: 'title' } as const, '')
310const mood = atom({ plugin: 'progress-buddy', key: 'mood' } as const, 'thinking')
311const run = atom({ plugin: 'progress-buddy', key: 'run' } as const, 'idle')
312
313// ponytail: kept out of $.state so a tool call never redraws the band; the Client pulls it on its heartbeat.
314let activityNow = ''
315const agentActivity: Record<string, string> = {}
316let cycleAt = 0
317
318// Party hat when done, nightcap when asleep, hard hat and hi-vis vest for tools; otherwise an everyday
319// outfit that changes with each new task. The start point comes from the folder the session runs in.
320const EVERYDAY_HATS: Hat[] = ['cap', 'beanie', 'none']
321const EVERYDAY_JACKETS: Jacket[] = ['jacket', 'hoodie', 'none']
322let wardrobe = 0
323
324function outfitFor(feeling: Mood): Outfit {
325  const everydayHat = EVERYDAY_HATS[wardrobe % EVERYDAY_HATS.length]!
326  const everydayJacket = EVERYDAY_JACKETS[Math.floor(wardrobe / EVERYDAY_HATS.length) % EVERYDAY_JACKETS.length]!
327
328  return {
329    hat: feeling === 'happy' ? 'party' : feeling === 'sleepy' ? 'nightcap' : feeling === 'tool' ? 'hardhat' : everydayHat,
330    jacket: feeling === 'tool' ? 'vest' : everydayJacket,
331  }
332}
333
334const CYCLE: Mood[] = ['happy', 'looking', 'sleepy', 'thinking']
335const CYCLE_MS = 4500
336
337// The surface disposes the animated band when it faults (the desktop does so if a render goes unanswered
338// for two seconds). ui.fault says so, and the hooks module then draws a plain bar itself. Each new prompt
339// tries the animated band again, until it has faulted MAX_FAULTS times in the session.
340const PLAIN_CELLS = 30
341const MAX_FAULTS = 3
342let faults = 0
343let isPlain = false
344// What the band was last handed. Redraws hand it the same value again, so a redraw alone never makes the
345// surface render the band; fresh values reach it through its own heartbeat instead.
346let lastSent: Awaited<ReturnType<typeof bandProps>> | undefined
347
348function write($: Parameters<typeof update>[0], fn: (list: Task[]) => Task[]) {
349  return update($, tasks, list => fn(list ?? []))
350}
351
352async function setMood($: Parameters<typeof update>[0], next: Mood) {
353  if ((await read($, mood)) !== next) {
354    await update($, mood, () => next)
355  }
356}
357
358async function setRun($: Parameters<typeof update>[0], next: Run) {
359  if ((await read($, run)) !== next) {
360    await update($, run, () => next)
361  }
362}
363
364async function cycle($: Parameters<typeof update>[0]) {
365  if ((await read($, run)) === 'running') {
366    return
367  }
368
369  cycleAt += 1
370  await setMood($, CYCLE[cycleAt % CYCLE.length]!)
371}
372
373async function runningAgents($: EngineInterface) {
374  const all = await $.agent.list().catch(() => [])
375
376  return all
377    .filter(a => a.teammateId === undefined && a.status === 'running')
378    .map(a => ({ label: (a.description || a.type).slice(0, 60), activity: agentActivity[a.id] ?? 'Starting' }))
379}
380
381async function bandProps($: EngineInterface) {
382  const list = await read($, tasks)
383  const name = await read($, title)
384  const state = await read($, run)
385  const step = list.find(t => t.status === 'in_progress')?.active ?? 'Waiting for the next step'
386  const done = list.filter(t => t.status === 'completed').length
387  const complete = list.length > 0 ? done === list.length : state === 'done'
388
389  return {
390    task: list.length === 0 ? name || (state === 'running' ? 'Working' : 'Ready') : name || step,
391    subtask: activityNow || (list.length > 0 && name ? step : state === 'running' ? 'Thinking' : 'Ready when you are'),
392    done,
393    total: list.length,
394    complete,
395    state,
396    agents: await runningAgents($),
397  }
398}
399
400type Todo = { content: string; status: Task['status']; activeForm?: string }
401
402const STATUSES = ['pending', 'in_progress', 'completed']
403
404function isTodo(t: unknown): t is Todo {
405  const todo = t as Todo | null
406
407  return typeof todo?.content === 'string' && STATUSES.includes(todo.status)
408}
409
410function fromTodos(todos: readonly Todo[]): Task[] {
411  return todos.map((t, i) => ({
412    id: String(i),
413    label: t.content,
414    active: t.activeForm ?? t.content,
415    status: t.status,
416  }))
417}
418
419const OWN_TOOL = 'mcp__progress-buddy__set_tasks'
420const VERBS: Record<string, string> = {
421  Edit: 'Editing',
422  Write: 'Writing',
423  Read: 'Reading',
424  NotebookEdit: 'Editing',
425  Grep: 'Searching for',
426  Glob: 'Finding',
427  Agent: 'Delegating:',
428  Skill: 'Loading skill',
429  WebFetch: 'Fetching',
430  WebSearch: 'Searching the web for',
431}
432
433// What the tool call is doing, in a few words: "Editing register.tsx".
434export function describe(e: Record<string, unknown>): string {
435  const tool = String(e.tool)
436  const text = (key: string) => (typeof e[key] === 'string' ? (e[key] as string) : undefined)
437  const path = text('file_path') ?? text('notebook_path')
438  const subject =
439    path?.split(/[\\/]/).pop() ??
440    text('description') ??
441    text('pattern') ??
442    text('query') ??
443    text('skill') ??
444    text('url') ??
445    text('command')
446
447  if (subject === undefined) {
448    return `Running ${tool.split('__').pop()}`
449  }
450
451  return VERBS[tool] === undefined ? subject : `${VERBS[tool]} ${subject}`
452}
453
454// A turn can start from something the person did not type: a background task's notification, another
455// session's message, an engine notice. Those keep the current task title instead of becoming it.
456const NOT_A_REQUEST = [
457  'task-notification',
458  'peer',
459  'peer-send-message',
460  'projects-relay',
461  'coordinator',
462  'observer',
463  'observer-activity',
464  'unclassified',
465  'plugin',
466]
467
468function requestTitle(text: string, origin: string | undefined): string | undefined {
469  const firstLine =
470    text
471      .split('\n')
472      .find(line => line.trim() !== '')
473      ?.trim() ?? ''
474  const isMarkup = /^<\/?[a-z][\w-]*[\s>]/i.test(firstLine)
475
476  return firstLine === '' || isMarkup || NOT_A_REQUEST.includes(origin ?? '') ? undefined : firstLine.slice(0, 80)
477}
478
479const LOOKING = ['Read', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'ToolSearch']
480const CODING = ['Edit', 'Write', 'NotebookEdit']
481const SHELL = ['Bash', 'PowerShell']
482
483export const register: Register = on => {
484  on('tool.call', async ($, e, next) => {
485    const tool = String(e.tool)
486
487    if (e.agentId !== undefined) {
488      agentActivity[e.agentId] = describe(e as Record<string, unknown>).slice(0, 80)
489
490      return next(e)
491    }
492
493    if (tool === OWN_TOOL) {
494      return next(e)
495    }
496
497    activityNow = describe(e as Record<string, unknown>).slice(0, 120)
498
499    if (isPlain) {
500      $.ui.invalidate('ui.render')
501    }
502    await setMood(
503      $,
504      CODING.includes(tool) ? 'coding' : SHELL.includes(tool) ? 'console' : LOOKING.includes(tool) ? 'looking' : 'tool',
505    )
506
507    const ran = await next(e)
508
509    if (ran.isError === true) {
510      await setMood($, 'oops')
511    }
512
513    return ran
514  })
515
516  on('turn.complete', async ($, e, next) => {
517    if (e.agentId !== undefined) {
518      delete agentActivity[e.agentId]
519
520      return next(e)
521    }
522
523    const list = await read($, tasks)
524    const isFinished = list.every(t => t.status === 'completed')
525    activityNow = ''
526    cycleAt = 0
527    await setRun($, isFinished ? 'done' : 'idle')
528    await setMood($, isFinished ? 'happy' : 'sleepy')
529
530    return next(e)
531  })
532
533  // ponytail: own tool because some sessions (desktop Code tab) ship no TodoWrite/TaskCreate.
534  on('tool.call', { tool: OWN_TOOL }, async ($, e) => {
535    const { todos, title: newTitle } = e as { todos?: unknown; title?: unknown }
536
537    if (!Array.isArray(todos) || !todos.every(isTodo)) {
538      return { deny: 'todos must be an array of { content, status, activeForm? }' }
539    }
540
541    await write($, () => fromTodos(todos))
542
543    if (typeof newTitle === 'string') {
544      await update($, title, () => newTitle.slice(0, 120))
545    }
546
547    return { result: `${todos.filter(t => t.status === 'completed').length}/${todos.length} done` }
548  })
549
550  on('session.start', async ($, e, next) => {
551    $.clock.every(CYCLE_MS, () => void cycle($))
552    wardrobe = Array.from(e.cwd).reduce((sum, ch) => (sum * 31 + ch.charCodeAt(0)) % 9973, 7)
553
554    await $.tool.register({
555      name: 'set_tasks',
556      description:
557        'Show the progress of the current task to the user. Call it at the start of any task with more than one step, then again whenever a step starts or finishes. Send the title and the whole list every time. Keep exactly one step in_progress.',
558      inputSchema: {
559        type: 'object',
560        required: ['title', 'todos'],
561        properties: {
562          title: {
563            type: 'string',
564            description: 'What the user asked for, present continuous: "Changing the animation"',
565          },
566          todos: {
567            type: 'array',
568            items: {
569              type: 'object',
570              required: ['content', 'status'],
571              properties: {
572                content: { type: 'string', description: 'The step, imperative: "Run tests"' },
573                activeForm: { type: 'string', description: 'Shown while in progress: "Running tests"' },
574                status: { type: 'string', enum: STATUSES },
575              },
576            },
577          },
578        },
579      },
580    })
581
582    return next(e)
583  })
584
585  on('prompt.submit', async ($, e, next) => {
586    const list = await read($, tasks)
587
588    if (isPlain && faults < MAX_FAULTS) {
589      isPlain = false
590      $.ui.invalidate('ui.render')
591    }
592
593    await setMood($, 'thinking')
594    await setRun($, 'running')
595
596    if (list.every(t => t.status === 'completed')) {
597      const asked = requestTitle(e.text, e.origin?.kind)
598      await write($, () => [])
599
600      if (asked !== undefined) {
601        wardrobe += 4
602        await update($, title, () => asked)
603      }
604    }
605
606    return next(e)
607  })
608
609  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
610    const ran = await next(e)
611
612    if (ran.deny === undefined && ran.isError !== true) {
613      await write($, () => fromTodos(e.todos))
614    }
615
616    return ran
617  })
618
619  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
620    const ran = await next(e)
621
622    if (ran.deny === undefined && ran.isError !== true) {
623      const { id, subject } = ran.result.task
624      await write($, list => [
625        ...list.filter(t => t.id !== id),
626        { id, label: subject, active: e.activeForm ?? subject, status: 'pending' },
627      ])
628    }
629
630    return ran
631  })
632
633  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
634    const ran = await next(e)
635
636    if (ran.deny === undefined && ran.isError !== true && ran.result.success) {
637      const { status } = e
638      await write($, list =>
639        status === 'deleted'
640          ? list.filter(t => t.id !== e.taskId)
641          : list.map(t =>
642              t.id === e.taskId
643                ? {
644                    ...t,
645                    label: e.subject ?? t.label,
646                    active: e.activeForm ?? e.subject ?? t.active,
647                    status: status ?? t.status,
648                  }
649                : t,
650            ),
651      )
652    }
653
654    return ran
655  })
656
657  on('ui.message', async ($, e, next) => {
658    if (e.element !== 'band') {
659      return next(e)
660    }
661
662    lastSent = await bandProps($)
663
664    return { props: lastSent }
665  })
666
667  on('ui.fault', ($, e, next) => {
668    if (e.element === 'band') {
669      faults += 1
670      isPlain = true
671      lastSent = undefined
672    }
673
674    return next(e)
675  })
676
677  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
678    const props = await bandProps($)
679
680    if (e.props.hasSurvey) {
681      return next(e)
682    }
683
684    const { Box, Text } = $.ui.resolve(e)
685    const feeling: Mood = await read($, mood)
686    let avatar
687    let band
688
689    if (e.surface === 'terminal') {
690      avatar = <Text color="#D97757">{KAOMOJI[feeling]}</Text>
691    } else {
692      const { Svg } = $.ui.resolve(e)
693      const buddies = Math.min(3, props.agents.length)
694      avatar = (
695        <Svg
696          source={face(feeling, buddies, outfitFor(feeling))}
697          alt={`Claude is ${feeling}${buddies > 0 ? `, with ${buddies} helpers` : ''}`}
698          width={Math.round((64 + buddies * BUDDY) * FACE_SCALE)}
699          height={FACE_HEIGHT}
700        />
701      )
702    }
703
704    if (!isPlain && (e.surface === 'terminal' || e.surface === 'desktop')) {
705      lastSent ??= props
706      band = $.ui.resolve(e).Client({ key: 'band', module: './band.tsx', props: lastSent, flexGrow: 1 })
707    } else {
708      const share = props.complete ? 1 : props.total > 0 ? props.done / props.total : 0
709      const filled = Math.round(share * PLAIN_CELLS)
710      const accent = props.complete ? '#4ade80' : '#8b5cf6'
711      const label = props.complete
712        ? props.total > 0
713          ? ` ✓ ${props.done}/${props.total} · 100%`
714          : ' ✓ Done'
715        : props.total > 0
716          ? ` ${props.done}/${props.total} · ${Math.round(share * 100)}%`
717          : ''
718
719      band = (
720        <Box flexDirection="column">
721          <Text bold wrap="truncate-end">
722            {props.task}
723          </Text>
724          <Box>
725            <Text color={accent}>{'█'.repeat(filled)}</Text>
726            <Text dimColor>{'░'.repeat(PLAIN_CELLS - filled)}</Text>
727            <Text bold color={accent}>
728              {label}
729            </Text>
730          </Box>
731          <Text dimColor wrap="truncate-end">
732            {props.complete ? '✓ Done' : props.subtask}
733          </Text>
734          {props.agents.slice(0, 3).map(a => (
735            <Text dimColor wrap="truncate-end">
736              {`↳ ${a.label} · ${a.activity}`}
737            </Text>
738          ))}
739        </Box>
740      )
741    }
742
743    return (
744      <Box gap={1} alignItems="center">
745        {avatar}
746        {band}
747      </Box>
748    )
749  })
750}
751
hooks/band.tsx 195 lines
1import type { ClientSurface } from 'claude-code'
2
3type Agent = { label: string; activity: string }
4type Props = {
5  task: string
6  subtask: string
7  done: number
8  total: number
9  complete: boolean
10  state: 'idle' | 'running' | 'done'
11  agents: Agent[]
12}
13type State = { frame: number }
14
15const PINK = [
16  [255, 95, 162],
17  [139, 92, 246],
18  [34, 211, 238],
19] as const
20const GREEN = [
21  [22, 163, 74],
22  [74, 222, 128],
23  [16, 185, 129],
24] as const
25const TRACK = [88, 88, 98] as const
26const WHITE = [255, 255, 255] as const
27const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
28const PARTIAL = '▏▎▍▌▋▊▉'
29const FRAME_MS = 60
30const HEARTBEAT_MS = 250
31const SWEEP = 9
32const MAX_AGENT_ROWS = 3
33
34// The animation's own values live here, not in surface.state, so a tick never depends on what an old closure sees.
35let latest: Props = { task: '', subtask: '', done: 0, total: 0, complete: false, state: 'idle', agents: [] }
36let frame = 0
37let shown = 0
38let tone = 0
39// ponytail: one live timer pair per module; a newer start retires the older one, so a start that
40// repeats can never pile timers up. Two bands at once would share one clock; give each its own if that is ever needed.
41let generation = 0
42
43function fraction(p: Props): number {
44  return p.complete ? 1 : p.total === 0 ? 0 : p.done / p.total
45}
46
47function mix(a: readonly number[], b: readonly number[], t: number): number[] {
48  return a.map((v, i) => v + (b[i]! - v) * t)
49}
50
51function hex(c: readonly number[]): string {
52  return '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
53}
54
55function gradient(t: number, stops: readonly (readonly number[])[]): number[] {
56  const x = (((t % 1) + 1) % 1) * stops.length
57  const i = Math.floor(x) % stops.length
58
59  return mix(stops[i]!, stops[(i + 1) % stops.length]!, x - Math.floor(x))
60}
61
62function start(surface: ClientSurface<State>) {
63  generation += 1
64  const mine = generation
65  shown = fraction(latest)
66  tone = latest.complete ? 1 : 0
67
68  const stopFrames = surface.every(FRAME_MS, () => {
69    if (mine !== generation) {
70      stopFrames()
71
72      return
73    }
74
75    const gap = fraction(latest) - shown
76    const toneGap = (latest.complete ? 1 : 0) - tone
77
78    // Nothing moves while idle, so ask for no redraw: every redraw is one more render the surface must answer in time.
79    if (latest.state === 'idle' && gap === 0 && toneGap === 0) {
80      return
81    }
82
83    frame += 1
84    shown = Math.abs(gap) < 0.002 ? fraction(latest) : shown + gap * 0.22
85    tone = Math.abs(toneGap) < 0.01 ? (latest.complete ? 1 : 0) : tone + toneGap * 0.14
86    surface.setState({ frame })
87  })
88  const stopBeats = surface.every(HEARTBEAT_MS, () => {
89    if (mine !== generation) {
90      stopBeats()
91
92      return
93    }
94
95    surface.post(1)
96  })
97}
98
99export default function Band(p: Props, surface: ClientSurface<State>) {
100  latest = p
101  const { Box, Text } = surface.elements
102
103  if (surface.state === undefined) {
104    surface.setState({ frame })
105    start(surface)
106  }
107
108  const agents = p.agents ?? []
109  const cells = Math.max(10, Math.min(44, (surface.columns || 36) - 12))
110  const flow = frame * 0.016
111  const glint = ((frame * (0.9 + 0.75 * tone)) % (cells + 20)) - 10
112  const pulse = tone * (0.5 + 0.5 * Math.sin(frame * 0.18)) * 0.16
113  const isSweep = p.state === 'running' && p.total === 0 && !p.complete
114  const isEmpty = p.total === 0 && !p.complete && !isSweep
115  const eighths = Math.round(shown * cells * 8)
116  const full = Math.floor(eighths / 8)
117  const rest = eighths % 8
118  const head = (frame * 0.68) % (cells + SWEEP)
119
120  const colour = (i: number) =>
121    mix(gradient((i / cells) * 0.9 - flow, PINK), gradient((i / cells) * 0.9 - flow, GREEN), tone)
122
123  const bar = Array.from({ length: cells }, (_, i) => {
124    const base = colour(i)
125
126    if (isSweep) {
127      const behind = head - i
128
129      return behind >= 0 && behind < SWEEP ? (
130        <Text color={hex(mix(TRACK, base, 1 - behind / SWEEP))}>█</Text>
131      ) : (
132        <Text color={hex(TRACK)}>░</Text>
133      )
134    }
135
136    if (isEmpty) {
137      return <Text color={hex(mix(TRACK, base, Math.max(0, 1 - Math.abs(i - glint) / 8) * 0.4))}>░</Text>
138    }
139
140    if (i > full || (i === full && rest === 0)) {
141      return <Text color={hex(TRACK)}>░</Text>
142    }
143
144    const shine = Math.max(0, 1 - Math.abs(i - glint) / 6) * (0.5 + 0.25 * tone) + pulse
145    const lit = mix(base, WHITE, Math.min(0.85, shine))
146
147    return <Text color={hex(lit)}>{i === full ? PARTIAL[rest - 1] : '█'}</Text>
148  })
149
150  const accent = hex(colour(0))
151  const spinner = SPINNER[Math.floor(frame / 2) % SPINNER.length]
152  const label = p.complete
153    ? p.total > 0
154      ? ` ✓ ${p.done}/${p.total} · 100%`
155      : ' ✓ Done'
156    : p.total > 0
157      ? ` ${p.done}/${p.total} · ${Math.round(fraction(p) * 100)}%`
158      : ''
159
160  if (label !== '') {
161    bar.push(
162      <Text bold color={accent}>
163        {label}
164      </Text>,
165    )
166  }
167
168  const lines = [
169    <Text bold wrap="truncate-end">
170      {p.task}
171    </Text>,
172    <Box>{bar}</Box>,
173    p.complete ? (
174      <Text color={accent} wrap="truncate-end">
175        ✓ Done
176      </Text>
177    ) : (
178      <Text dimColor wrap="truncate-end">
179        {p.state === 'running' ? `${spinner} ${p.subtask}` : p.subtask}
180      </Text>
181    ),
182    ...agents.slice(0, MAX_AGENT_ROWS).map(a => (
183      <Text dimColor wrap="truncate-end">
184        {`↳ ${spinner} ${a.label} · ${a.activity}`}
185      </Text>
186    )),
187  ]
188
189  if (agents.length > MAX_AGENT_ROWS) {
190    lines.push(<Text dimColor>{`↳ +${agents.length - MAX_AGENT_ROWS} more agents`}</Text>)
191  }
192
193  return <Box flexDirection="column">{lines}</Box>
194}
195
types/index.d.ts 20 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = { id: string; label: string; active: string; status: TaskStatus }
4
5export type Mood = 'thinking' | 'looking' | 'working' | 'coding' | 'console' | 'tool' | 'oops' | 'happy' | 'sleepy'
6
7export type Run = 'idle' | 'running' | 'done'
8
9export type Hat = 'none' | 'cap' | 'beanie' | 'party' | 'hardhat' | 'nightcap'
10
11export type Jacket = 'none' | 'jacket' | 'hoodie' | 'vest'
12
13export type Outfit = { hat: Hat; jacket: Jacket }
14
15declare module 'claude-code' {
16  interface PluginState {
17    'progress-buddy': { tasks: Task[]; title: string; mood: Mood; run: Run }
18  }
19}
20