A box above the prompt that always says what the agent is doing right now, alive with color and motion: an animated status glyph, a glowing jellyfish on the…

See what Claude Code is doing at every step, in plain English, live, and in color.
Agent Pulse is a fork of Tzafrir Rehan's whats-agent-doing 0.4.3 (MIT, see LICENSE), imported with its history, so the original commits keep their author. Everything that makes the box useful is Tzafrir's: the plain-English step names, the command descriptions in place of flags, the timers, the history behind the triangle, the agent rows. This fork adds color, motion and small charts on top, on the terminal and on the desktop app's Code tab, and a jellyfish.
| Thinking | Running a tool | Writing the reply | Waiting for you | Idle |
|---|---|---|---|---|
The turn so far, as the timeline strip draws it:
The box above the prompt names the step Claude is on and how long it has been on it. Click ▸ (or focus the band with ctrl+x tab and press Enter) to see every step it has taken. This is the original's behaviour, unchanged; its README has the full table of states.
╭─────────────────────────────────────────────────────────────────╮
│ ▸ ⠹ ❯ Find the commit that broke fixed-amount coupons ▃ · 1m 48s │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ├─ ⠼ Find callers of applyCoupon › Searching for "applyCoupon(" │
│ └─ ⠧ Check the coupon tests › Reading coupon.test.ts 33s │
╰─────────────────────────────────────────────────────────────────╯
Every effect carries meaning: nothing moves just to be busy.
| Addition | What it tells you | Terminal | Desktop |
|---|---|---|---|
| Status glyph | Claude is working, and on what kind of step | a spinner per phase: ◐◓◑◒ reading, ∘○◎● thinking, ⣀⣤⣶⣿ writing, braille for tools | the jellyfish acts out the phase |
| Jellyfish | The same, at a glance | bobs, pulses, swings its tentacles; bubbles while thinking, ink while writing, a ! or ? while it waits on you, asleep when idle | |
| Breathing border | You are needed: an approval or a question | the border fades in and out | the glow breathes yellow |
| Flash | A call just finished, and how | the border flashes green ✓, red ✗ or yellow ⊘ | a halo ring |
| Sparkle | The turn is done | ✦ ✧ after the timer | stars around the jellyfish |
| Timer heat | A step is taking long | amber after 30 s, orange after 2 min, red after 5 min | same |
| Tool glyphs | Which tool runs | ◧ Read, ✎ Edit, ✚ Write, ❯ shell, ⌕ Grep, ⌁ web, ◆ agent, ⧉ MCP | same |
| Agent rows | Who is doing what | ├─ └─ tree lines, a color and a spinner per agent | same, a color per agent |
| Timeline strip | Where the turn's time went | colored ━ per phase, under the headline | an SVG strip |
| Duration bars | Which step was slow | ▏▎▍▌▋▊▉█ per history row | SVG bars |
| Meters | How much is being written | ▁▂▃▄▅▆▇█ for the reply's words or a call's size | same |
| Hover cards | The whole label, when the window cuts it | rest the pointer on a row | same |
| Chime | Claude started waiting for your approval | opt-in | opt-in |
| Above the question | Claude asked you something: the dialog covers the band | a still copy of the headline and agents above the question | same |
In /config, type Animation to find the three rows:
| Setting | Values | Default |
|---|---|---|
| Animation | off: still, but with the colors and charts. subtle: the status glyph, the flash, the jellyfish's swim. full: adds the breathing border, bubbles, ink and sparkles | subtle |
| Palette | claude: follows your theme with the Claude orange. neon: vivid. mono: color only for what needs you | claude |
| Sound | off, or approval: a soft chime when Claude starts waiting for your approval | off |
A change reloads the mod at once; the box keeps its history. On the desktop, the jellyfish also stops moving when your system asks for reduced motion.
Uninstall whats-agent-doing first, or you will see two boxes:
claude plugin uninstall whats-agent-doing@tzafrir
Then, in Claude Code:
/plugin marketplace add afterever/claude-mods
/plugin install agent-pulse@afterever-mods
Or from your shell:
claude plugin marketplace add afterever/claude-mods
claude plugin install agent-pulse@afterever-mods
Requirements: Claude Code in a terminal, or in the desktop app's Code tab. The plugin is a mod, written with Claude Code's function hooks, which are early access: they load only where function hooks are enabled, and their API may change between releases. Hooks are not loaded in Claude chat or Cowork.
$.state) and nothing else: no files, no network, no processes, no storage across sessions. claude plugin validate lists every call it makes: $.agent.list, $.audio.play, $.clock, $.state and $.ui.sounds/chime.wav), generated for it.Load the plugin from a clone; the session reloads it as you edit:
claude --plugin-dir agent-pulse
claude plugin validate agent-pulse
claude plugin test agent-pulse
The desktop app starts its own sessions, so there the folder goes in CLAUDE_CODE_PLUGIN_DIRS (in the env block of ~/.claude/settings.json) instead of a flag.
| File | What it does |
|---|---|
hooks/register.tsx | The hooks, as in the original, plus the settings, the timeline, the flourishes and the chime; it hands the drawing to draw.tsx |
hooks/draw.tsx | The box: one renderer for the terminal, one for the desktop and every surface that draws SVG |
hooks/pulse.tsx | The terminal's animations, each a fixed-width Client on its own frame clock: the spinner and the sparkle |
hooks/jelly.ts | SVG for the desktop: the jellyfish, the strip and the bars, animated by CSS |
hooks/palette.ts | The palettes, timer heat, per-agent colors |
hooks/glyphs.ts | Tool glyphs, spinners, meters and bars |
hooks/activity-of.ts | Turns a tool call, whole or still streaming, into its short label (the original's) |
hooks/text.ts | Printable labels, thought snippets, word counts, sizes, durations (the original's) |
types/index.d.ts | The $.state contract |
tests/register.test.tsx | The original's tests: a whole turn state by state, the history, hostile labels, approvals, agents |
tests/pulse.test.tsx | Every animation level on both surfaces, the flash, the breath, the sparkle, the jellyfish's stable source |
DESIGN.md | Why each addition is built the way it is, and what was turned down |
MIT. Copyright (c) 2026 Tzafrir Rehan, and afterever for the Agent Pulse changes.
hooks/register.tsx 970 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, Timer, TurnCompleteReason, TurnStepChunk } from 'claude-code'
3
4import type {
5 ActivityAgent,
6 ActivityBeat,
7 ActivityEntry,
8 ActivityNow,
9 ActivityOutcome,
10 ActivityTimeline,
11} from '../types'
12import { activityOf, partialArgsOf } from './activity-of'
13import { drawDesktop, drawQuestion, drawTerminal } from './draw'
14import type { PulseOf, View } from './draw'
15import { meterOf } from './glyphs'
16import { levelOf, paletteOf } from './palette'
17import type { Level } from './palette'
18import { clip, durationOf, oneLine, plural, sizeOf, thoughtOf, wordsAdded } from './text'
19
20const IDLE: ActivityNow = {
21 phase: 'idle',
22 label: 'Idle, waiting for your prompt',
23 sinceMs: 0,
24}
25
26const MAX_HISTORY = 100
27const MAX_THOUGHT_CHARS = 70
28const MAX_THOUGHT_BUFFER = 2000
29const MAX_INPUT_HEAD = 4000
30const LARGE_INPUT_CHARS = 1024
31const MAX_SEGMENTS = 200
32const STREAM_THROTTLE_MS = 250
33const TICK_MS = 1000
34const BREATH_TICK_MS = 125
35const FLASH_MS = 380
36const FULL_REPLY_WORDS = 800
37const FULL_INPUT_CHARS = 64 * 1024
38const CHIME = 'sounds/chime.wav'
39
40const now = atom({ plugin: 'agent-pulse', key: 'now' } as const, IDLE)
41
42const history = atom(
43 { plugin: 'agent-pulse', key: 'history' } as const,
44 [] as readonly ActivityEntry[],
45)
46
47const team = atom(
48 { plugin: 'agent-pulse', key: 'agents' } as const,
49 [] as readonly ActivityAgent[],
50)
51
52const isExpanded = atom(
53 { plugin: 'agent-pulse', key: 'isExpanded' } as const,
54 false,
55)
56
57const timeline = atom(
58 { plugin: 'agent-pulse', key: 'timeline' } as const,
59 { segments: [], open: null } as ActivityTimeline,
60)
61
62const beat = atom(
63 { plugin: 'agent-pulse', key: 'beat' } as const,
64 null as ActivityBeat | null,
65)
66
67/** What the model is doing in the main loop, between and around tool calls. */
68type ModelPhase = 'idle' | 'requesting' | 'thinking' | 'writing' | 'composing' | 'compacting'
69
70/** A main-loop tool call in flight. */
71type Call = {
72 tool: string
73 label: string
74 startMs: number
75 isAwaitingApproval: boolean
76}
77
78/**
79 * A subagent at work, from its spawn (or its first call, when the spawn came
80 * before a reload) to its stop. `toolUseId` names the Agent call that started
81 * it; `activeMs` is when it last did something.
82 */
83type Agent = {
84 name: string
85 label: string
86 startMs: number
87 activeMs: number
88 toolUseId: string | null
89 isBackground: boolean
90}
91
92/**
93 * The module's live view of the session, from which the headline is drawn:
94 * started over on a reload, while the headline and history in `$.state` stay.
95 */
96type Live = {
97 calls: Map<string, Call>
98 agents: Map<string, Agent>
99 agentNames: Map<string, string>
100 described: Map<string, string>
101 phase: ModelPhase
102 phaseStartMs: number
103 turnStartMs: number | null
104 turnActions: number
105 isFirstRequest: boolean
106 resultsToReview: number
107 thought: string
108 thoughtWords: number
109 isThoughtInWord: boolean
110 replyWords: number
111 isReplyInWord: boolean
112 composing: { tool: string; head: string; chars: number } | null
113 lastAction: string | null
114 lastTurn: string | null
115 lastPublishMs: number
116 trailing: Timer | null
117 isSweeping: boolean
118 ticker: Timer | null
119 /** The redraws of a breathing border while Claude waits on the person. */
120 breath: Timer | null
121 beats: number
122 level: Level
123 isChiming: boolean
124}
125
126/**
127 * Registers Agent Pulse: a box above the prompt naming what Claude is doing
128 * right now, and, behind its triangle, what it has done; alive with color
129 * and motion as far as `options.animation` lets it.
130 *
131 * The model's own stream (`turn.step`) says whether it is reading, thinking
132 * (the thought's latest sentence), writing the reply or composing a tool's
133 * input; `tool.call` says which tool runs, `classic.PermissionRequest` when
134 * one waits for approval, and a subagent's calls show under its Agent call.
135 *
136 * @param on the engine's registrar
137 * @param options the `userConfig` values: `animation`, `palette`, `sound`
138 */
139export const register: Register = (on, options) => {
140 const level = levelOf(options['animation'])
141 const palette = paletteOf(options['palette'])
142 const look = { level, palette }
143 const live: Live = {
144 calls: new Map(),
145 agents: new Map(),
146 agentNames: new Map(),
147 described: new Map(),
148 phase: 'idle',
149 phaseStartMs: 0,
150 turnStartMs: null,
151 turnActions: 0,
152 isFirstRequest: false,
153 resultsToReview: 0,
154 thought: '',
155 thoughtWords: 0,
156 isThoughtInWord: false,
157 replyWords: 0,
158 isReplyInWord: false,
159 composing: null,
160 lastAction: null,
161 lastTurn: null,
162 lastPublishMs: 0,
163 trailing: null,
164 isSweeping: false,
165 ticker: null,
166 breath: null,
167 beats: 0,
168 level,
169 isChiming: options['sound'] === 'approval',
170 }
171
172 on('session.start', async ($, e, next) => {
173 const result = await next(e)
174
175 await publish($, live)
176
177 return result
178 })
179
180 on('turn.start', async ($, e, next) => {
181 if (live.turnStartMs !== null) {
182 return next(e)
183 }
184
185 live.turnStartMs = await $.clock.now()
186 live.turnActions = 0
187 live.isFirstRequest = true
188 live.resultsToReview = 0
189 live.lastAction = null
190 live.calls.clear()
191 await update($, timeline, () => ({ segments: [], open: null }))
192
193 const prompt = oneLine(e.text)
194
195 if (prompt !== '') {
196 await remember($, { kind: 'turn', label: clip(prompt, 60), durationMs: null, outcome: 'ok' })
197 }
198
199 await enter($, live, 'requesting')
200
201 keepTicking($, live)
202 await publish($, live)
203
204 return next(e)
205 })
206
207 on('turn.step', async function* ($, e, next) {
208 if (e.agentId !== undefined) {
209 return yield* next(e)
210 }
211
212 await enter($, live, 'requesting')
213 await publish($, live)
214
215 try {
216 for await (const chunk of next(e)) {
217 await follow($, live, chunk)
218 yield chunk
219 }
220 } finally {
221 live.isFirstRequest = false
222 live.composing = null
223 await enter($, live, 'requesting')
224 await publish($, live)
225 }
226 })
227
228 on('tool.call', async ($, e, next) => {
229 const label = activityOf(e.tool, e as unknown as Record<string, unknown>)
230
231 if (e.agentId !== undefined) {
232 const agent = await agentOf($, live, e.agentId)
233
234 // Kept after the call: the agent's last action stands while it thinks.
235 agent.label = label
236 agent.activeMs = await $.clock.now()
237 await publish($, live, false)
238
239 try {
240 return await next(e)
241 } finally {
242 agent.label = label
243 await publish($, live, false)
244 }
245 }
246
247 const startMs = await $.clock.now()
248
249 live.calls.set(e.tool_use_id, { tool: e.tool, label, startMs, isAwaitingApproval: false })
250 live.lastAction = label
251 live.turnActions += 1
252 await publish($, live)
253
254 let outcome: ActivityOutcome = 'interrupted'
255
256 try {
257 const result = await next(e)
258
259 outcome = result.deny !== undefined ? 'denied' : result.isError ? 'error' : 'ok'
260
261 if (isAgentTool(e.tool)) {
262 const args = e as unknown as Record<string, unknown>
263 const handle = args['name']
264 const description = args['description']
265
266 // A named agent runs as a teammate, which the engine lists by the
267 // call's `name`; the call's `description` is what to show for it.
268 if (typeof handle === 'string' && typeof description === 'string' && oneLine(description) !== '') {
269 live.described.set(handle, description)
270 void sweep($, live)
271 }
272
273 await launched($, live, result.result, description)
274 }
275
276 return result
277 } finally {
278 live.calls.delete(e.tool_use_id)
279 live.resultsToReview += 1
280
281 await remember($, {
282 kind: 'tool',
283 label,
284 durationMs: (await $.clock.now()) - startMs,
285 outcome,
286 tool: e.tool,
287 })
288
289 await pulseBeat($, live, 'flash', outcome)
290 await publish($, live)
291 }
292 })
293
294 on('classic.PermissionRequest', async ($, e, next) => {
295 if (e.agent_id !== undefined) {
296 const agent = await agentOf($, live, e.agent_id)
297
298 agent.label = `Waiting for your approval: ${agent.label}`
299 } else {
300 const waiting = [...live.calls.values()]
301 .reverse()
302 .find(call => call.tool === e.tool_name && !call.isAwaitingApproval)
303
304 if (waiting) {
305 waiting.isAwaitingApproval = true
306 }
307 }
308
309 if (live.isChiming) {
310 // A chime is a nicety: no sound device, or a refused play, changes nothing.
311 $.audio.play({ asset: CHIME }).catch(() => {})
312 }
313
314 await publish($, live)
315
316 return next(e)
317 })
318
319 // The run-in-background pill shows only under a call that is running, so
320 // its drawing means the person has answered the approval it waited on.
321 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
322 const call = live.calls.get(e.props.tool_use_id)
323
324 if (call?.isAwaitingApproval) {
325 call.isAwaitingApproval = false
326 $.clock.after(0, () => void publish($, live))
327 }
328
329 return next(e)
330 })
331
332 on('agent.spawn', async ($, e, next) => {
333 const result = await next(e)
334
335 if (result.agentId !== undefined) {
336 const startMs = await $.clock.now()
337 const name = e.description || e.name || 'Agent'
338
339 live.agentNames.set(result.agentId, name)
340 live.agents.set(result.agentId, {
341 name,
342 label: 'Getting started',
343 startMs,
344 activeMs: startMs,
345 toolUseId: e.tool_use_id,
346 isBackground: e.background,
347 })
348 keepTicking($, live)
349 await publish($, live)
350 }
351
352 return result
353 })
354
355 on('classic.SubagentStop', async ($, e, next) => {
356 await finish($, live, e.agent_id, 'ok')
357
358 return next(e)
359 })
360
361 on('classic.PreCompact', async ($, e, next) => {
362 await enter($, live, 'compacting')
363 await publish($, live)
364
365 return next(e)
366 })
367
368 on('classic.PostCompact', async ($, e, next) => {
369 const durationMs = (await $.clock.now()) - live.phaseStartMs
370
371 live.phase = live.turnStartMs === null ? 'idle' : 'requesting'
372 await remember($, { kind: 'compact', label: 'Compacted the conversation', durationMs, outcome: 'ok' })
373 await publish($, live)
374
375 return next(e)
376 })
377
378 on('turn.complete', async ($, e, next) => {
379 if (e.agentId !== undefined) {
380 await finish($, live, e.agentId, outcomeOf(e.reason))
381
382 return next(e)
383 }
384
385 if (live.turnStartMs === null) {
386 return next(e)
387 }
388
389 const durationMs = (await $.clock.now()) - live.turnStartMs
390 const took = durationOf(durationMs)
391
392 const [label, outcome]: [string, ActivityOutcome] =
393 e.reason === 'aborted'
394 ? [`interrupted after ${took}`, 'interrupted']
395 : e.reason === 'error'
396 ? [`stopped on an error after ${took}`, 'error']
397 : e.reason === 'refusal'
398 ? ['declined the request', 'denied']
399 : [`done in ${took}, ${plural(live.turnActions, 'action')}`, 'ok']
400
401 await enter($, live, 'idle')
402 await remember($, { kind: 'end', label: capitalized(label), durationMs, outcome })
403
404 live.lastTurn = label
405 live.turnStartMs = null
406 live.calls.clear()
407 await pulseBeat($, live, 'done', outcome)
408
409 keepTicking($, live)
410 await publish($, live)
411
412 return next(e)
413 })
414
415 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
416 if (e.props.hasSurvey) {
417 return next(e)
418 }
419
420 // The band is one site for every plugin: what the plugins beneath draw
421 // stays, under the box, so another mod's band is never hidden.
422 const below = await next(e)
423 const view = await viewOf($, look, e.props.maxRows, e.props.bodyColumns)
424
425 if (e.surface === 'terminal') {
426 const ui = $.ui.resolve(e)
427 const { Client } = ui
428 const pulse: PulseOf = (key, props, width) => (
429 <Client key={key} module="./pulse.tsx" props={props} width={width} height={1} />
430 )
431
432 return drawTerminal(ui, view, pulse, below)
433 }
434
435 return drawDesktop($.ui.resolve(e), view, below)
436 })
437
438 // The question dialog takes the prompt's place and covers the band, so the
439 // box draws a still copy of itself above the dialog while it is open.
440 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
441 const dialog = await next(e)
442
443 return drawQuestion($.ui.resolve(e), await viewOf($, look, 12, 40), dialog)
444 })
445}
446
447/** Everything a drawing reads, at the size its site gives it. */
448async function viewOf(
449 $: EngineInterface,
450 look: Pick<View, 'level' | 'palette'>,
451 maxRows: number,
452 bodyColumns: number,
453): Promise<View> {
454 return {
455 headline: await read($, now),
456 entries: await read($, history),
457 working: await read($, team),
458 timeline: await read($, timeline),
459 beat: await read($, beat),
460 isOpen: await read($, isExpanded),
461 nowMs: await $.clock.now(),
462 maxRows,
463 bodyColumns,
464 ...look,
465 onToggle: () => void update($, isExpanded, open => !open),
466 }
467}
468
469/** The headline as the live view has it. */
470function current(live: Live): ActivityNow {
471 if (live.phase === 'compacting') {
472 return { phase: 'compacting', label: 'Compacting the conversation', sinceMs: live.phaseStartMs }
473 }
474
475 const open = [...live.calls.entries()]
476 const [newestId, newest] = open.at(-1) ?? []
477
478 if (newestId !== undefined && newest !== undefined) {
479 const more = open.length > 1 ? ` (+${open.length - 1} more)` : ''
480
481 if (newest.isAwaitingApproval) {
482 return {
483 phase: 'approval',
484 label: `Waiting for your approval: ${newest.label}${more}`,
485 sinceMs: newest.startMs,
486 tool: newest.tool,
487 }
488 }
489
490 if (newest.tool === 'AskUserQuestion') {
491 return { phase: 'question', label: newest.label, sinceMs: newest.startMs }
492 }
493
494 const inner = isAgentTool(newest.tool) ? agentUnder(live, newestId)?.label : undefined
495
496 return inner === undefined
497 ? { phase: 'tool', label: `${newest.label}${more}`, sinceMs: newest.startMs, tool: newest.tool }
498 : { phase: 'agent', label: `${newest.label} › ${inner}${more}`, sinceMs: newest.startMs, tool: newest.tool }
499 }
500
501 if (live.phase === 'idle' && live.agents.size > 0) {
502 // Between turns, background agents may still be at work.
503 const working = [...live.agents.values()]
504 const sinceMs = Math.min(...working.map(agent => agent.startMs))
505 const busiest = working.reduce((a, b) => (b.activeMs > a.activeMs ? b : a))
506
507 return {
508 phase: 'agent',
509 label: working.length === 1
510 ? `${busiest.name} › ${busiest.label}`
511 : `${plural(working.length, 'agent')} working`,
512 sinceMs,
513 }
514 }
515
516 const sinceMs = live.phaseStartMs
517
518 switch (live.phase) {
519 case 'requesting':
520 return {
521 phase: 'requesting',
522 label: live.isFirstRequest
523 ? 'Reading your prompt'
524 : live.resultsToReview > 0
525 ? `Reviewing the results of ${plural(live.resultsToReview, 'action')}`
526 : 'Waiting for the model',
527 sinceMs,
528 }
529 case 'thinking': {
530 // With no thought text to quote, the last action stands, the dot
531 // turned to thinking's color; before any, the prompt is what it reads.
532 const snippet = thoughtOf(live.thought, MAX_THOUGHT_CHARS)
533 const label = snippet ? `Thinking: ${snippet}` : standingLabel(live)
534
535 return { phase: 'thinking', label, sinceMs }
536 }
537 case 'writing':
538 return {
539 phase: 'writing',
540 label: `Writing the reply · ${plural(live.replyWords, 'word')}`,
541 sinceMs,
542 ...meterField(meterOf(live.replyWords, FULL_REPLY_WORDS)),
543 }
544 case 'composing': {
545 // A call is named once its input says what it does: a file tool by
546 // its path, a command by its description, which streams after the
547 // command itself. Until then the last action stands.
548 const { composing } = live
549 const args = composing ? partialArgsOf(composing.head) : {}
550 const isDescribed = composing !== null && (isShellTool(composing.tool)
551 ? typeof args['description'] === 'string'
552 : Object.keys(args).length > 0)
553
554 if (!composing || !isDescribed) {
555 return { phase: 'composing', label: standingLabel(live), sinceMs }
556 }
557
558 const size = composing.chars >= LARGE_INPUT_CHARS ? ` · ${sizeOf(composing.chars)}` : ''
559
560 return {
561 phase: 'composing',
562 label: `${activityOf(composing.tool, args)}${size}`,
563 sinceMs,
564 tool: composing.tool,
565 ...meterField(meterOf(composing.chars, FULL_INPUT_CHARS)),
566 }
567 }
568 case 'idle':
569 return live.lastTurn === null
570 ? IDLE
571 : { ...IDLE, label: `Idle · last turn ${live.lastTurn}` }
572 }
573}
574
575/** A meter field for the headline, left out while there is nothing to meter. */
576function meterField(meter: string): { meter?: string } {
577 return meter === '' ? {} : { meter }
578}
579
580/**
581 * Writes the headline. A streaming update (`isUrgent` false) lands at most
582 * every STREAM_THROTTLE_MS, the last one held on a timer.
583 */
584async function publish($: EngineInterface, live: Live, isUrgent = true): Promise<void> {
585 const nowMs = await $.clock.now()
586
587 if (!isUrgent && nowMs - live.lastPublishMs < STREAM_THROTTLE_MS) {
588 live.trailing ??= $.clock.after(STREAM_THROTTLE_MS, () => {
589 live.trailing = null
590 void publish($, live)
591 })
592
593 return
594 }
595
596 live.lastPublishMs = nowMs
597 const headline = current(live)
598 const working = [...live.agents.values()].map(agent => ({
599 name: agent.name,
600 label: agent.label,
601 sinceMs: agent.startMs,
602 }))
603
604 await update($, now, () => headline)
605 await update($, team, () => working)
606 await follows($, live, headline, nowMs)
607 keepBreathing($, live, headline)
608}
609
610/**
611 * Carries the headline's phase into the turn's timeline: a new phase closes
612 * the running segment and opens its own; the turn's end closes the last.
613 */
614async function follows($: EngineInterface, live: Live, headline: ActivityNow, nowMs: number): Promise<void> {
615 const isTurn = live.turnStartMs !== null
616
617 await update($, timeline, line => {
618 const { open } = line
619
620 if (isTurn ? open?.phase === headline.phase : open === null) {
621 return line
622 }
623
624 const segments = open === null
625 ? line.segments
626 : [...line.segments, { phase: open.phase, ms: Math.max(0, nowMs - open.sinceMs) }].slice(-MAX_SEGMENTS)
627
628 return { segments, open: isTurn ? { phase: headline.phase, sinceMs: nowMs } : null }
629 })
630}
631
632/**
633 * Redraws the box eight times a second while Claude waits on the person at
634 * full animation, so its border breathes; stops when the wait ends.
635 */
636function keepBreathing($: EngineInterface, live: Live, headline: ActivityNow): void {
637 const isWaiting = live.level === 'full' && (headline.phase === 'approval' || headline.phase === 'question')
638
639 if (isWaiting && live.breath === null) {
640 live.breath = $.clock.every(BREATH_TICK_MS, () => $.ui.invalidate('ui.render'))
641 } else if (!isWaiting && live.breath !== null) {
642 live.breath.cancel()
643 live.breath = null
644 }
645}
646
647/**
648 * Marks a moment worth a flourish: a call's end flashes the border, a turn's
649 * end sparkles. The flash is redrawn away once it has shown.
650 */
651async function pulseBeat(
652 $: EngineInterface,
653 live: Live,
654 kind: ActivityBeat['kind'],
655 outcome: ActivityOutcome,
656): Promise<void> {
657 if (live.level === 'off') {
658 return
659 }
660
661 live.beats += 1
662 const beatOf: ActivityBeat = { id: live.beats, kind, outcome, atMs: await $.clock.now() }
663
664 await update($, beat, () => beatOf)
665
666 if (kind === 'flash') {
667 $.clock.after(FLASH_MS, () => $.ui.invalidate('ui.render'))
668 }
669}
670
671/** The agent at work under `agentId`, taken up if its spawn came before a reload. */
672async function agentOf($: EngineInterface, live: Live, agentId: string): Promise<Agent> {
673 const known = live.agents.get(agentId)
674
675 if (known) {
676 return known
677 }
678
679 const startMs = await $.clock.now()
680 const agent: Agent = {
681 name: live.agentNames.get(agentId) ?? 'Agent',
682 label: 'Getting started',
683 startMs,
684 activeMs: startMs,
685 toolUseId: null,
686 isBackground: true,
687 }
688
689 live.agents.set(agentId, agent)
690 keepTicking($, live)
691
692 return agent
693}
694
695/**
696 * Lets an agent go. A background agent's run is recorded in the history; a
697 * foreground one's is its Agent call's row.
698 */
699async function finish(
700 $: EngineInterface,
701 live: Live,
702 agentId: string,
703 outcome: ActivityOutcome,
704): Promise<void> {
705 const agent = live.agents.get(agentId)
706
707 if (!agent) {
708 return
709 }
710
711 live.agents.delete(agentId)
712
713 if (agent.isBackground) {
714 await remember($, {
715 kind: 'tool',
716 label: `Agent: ${agent.name}`,
717 durationMs: (await $.clock.now()) - agent.startMs,
718 outcome,
719 tool: 'Agent',
720 })
721 }
722
723 keepTicking($, live)
724 await publish($, live)
725}
726
727/**
728 * Names agents first met by their calls, and lets go of agents the engine
729 * lists as no longer running, in case their stop never reached the box.
730 */
731async function sweep($: EngineInterface, live: Live): Promise<void> {
732 if (live.agents.size === 0 || live.isSweeping) {
733 return
734 }
735
736 live.isSweeping = true
737
738 try {
739 await sweepListed($, live, await $.agent.list())
740 } catch {
741 // The list is a backstop; the box keeps going without it.
742 } finally {
743 live.isSweeping = false
744 }
745}
746
747async function sweepListed($: EngineInterface, live: Live, listed: readonly AgentInfo[]): Promise<void> {
748 const statuses = new Map(listed.map(info => [info.id, info.status]))
749 let isRenamed = false
750
751 for (const [agentId, agent] of [...live.agents.entries()]) {
752 const status = statuses.get(agentId)
753
754 // An agent first met by its calls is named once the engine lists it.
755 if (!live.agentNames.has(agentId)) {
756 const handle = listed.find(info => info.id === agentId)?.name
757 const described = handle === undefined ? undefined : live.described.get(handle)
758
759 if (described !== undefined) {
760 live.agentNames.set(agentId, described)
761 }
762
763 const name = described ?? handle
764
765 if (name !== undefined && name !== agent.name) {
766 agent.name = name
767 isRenamed = true
768 }
769 }
770
771 if (status !== undefined && status !== 'running') {
772 await finish($, live, agentId, status === 'completed' ? 'ok' : status === 'failed' ? 'error' : 'interrupted')
773 }
774 }
775
776 if (isRenamed) {
777 await publish($, live)
778 }
779}
780
781/**
782 * Keeps the elapsed time ticking while a turn runs or an agent works, and
783 * stops it once neither does.
784 */
785function keepTicking($: EngineInterface, live: Live): void {
786 const isBusy = live.turnStartMs !== null || live.agents.size > 0
787
788 if (isBusy && live.ticker === null) {
789 live.ticker = $.clock.every(TICK_MS, () => {
790 $.ui.invalidate('ui.render')
791 void sweep($, live)
792 })
793 } else if (!isBusy && live.ticker !== null) {
794 live.ticker.cancel()
795 live.ticker = null
796 }
797}
798
799async function remember($: EngineInterface, entry: ActivityEntry): Promise<void> {
800 await update($, history, entries => [...entries, entry].slice(-MAX_HISTORY))
801}
802
803/** Enters a model phase, recording the thinking or reply block it closes. */
804async function enter($: EngineInterface, live: Live, next: ModelPhase): Promise<void> {
805 if (next === live.phase) {
806 return
807 }
808
809 const nowMs = await $.clock.now()
810 const durationMs = nowMs - live.phaseStartMs
811
812 if (live.phase === 'thinking') {
813 const words = live.thoughtWords
814
815 await remember($, {
816 kind: 'thought',
817 label: words > 0 ? `Thought (${plural(words, 'word')})` : 'Thought',
818 durationMs,
819 outcome: 'ok',
820 })
821 }
822
823 if (live.phase === 'writing' && live.replyWords > 0) {
824 await remember($, {
825 kind: 'reply',
826 label: `Wrote the reply (${plural(live.replyWords, 'word')})`,
827 durationMs,
828 outcome: 'ok',
829 })
830 }
831
832 if (next === 'thinking') {
833 live.thought = ''
834 live.thoughtWords = 0
835 live.isThoughtInWord = false
836 }
837
838 if (next === 'writing') {
839 live.replyWords = 0
840 live.isReplyInWord = false
841 }
842
843 live.phase = next
844 live.phaseStartMs = nowMs
845}
846
847/** Follows one chunk of the main loop's response. */
848async function follow($: EngineInterface, live: Live, chunk: TurnStepChunk): Promise<void> {
849 switch (chunk.kind) {
850 case 'thinking': {
851 const isNew = live.phase !== 'thinking'
852
853 await enter($, live, 'thinking')
854 live.resultsToReview = 0
855 const { added, isInWord } = wordsAdded(chunk.text, live.isThoughtInWord)
856
857 live.thought = (live.thought + chunk.text).slice(-MAX_THOUGHT_BUFFER)
858 live.thoughtWords += added
859 live.isThoughtInWord = isInWord
860 await publish($, live, isNew)
861
862 return
863 }
864 case 'text': {
865 const isNew = live.phase !== 'writing'
866
867 await enter($, live, 'writing')
868 live.resultsToReview = 0
869 const { added, isInWord } = wordsAdded(chunk.text, live.isReplyInWord)
870
871 live.replyWords += added
872 live.isReplyInWord = isInWord
873 await publish($, live, isNew)
874
875 return
876 }
877 case 'tool':
878 await enter($, live, 'composing')
879 live.resultsToReview = 0
880 live.composing = { tool: chunk.name, head: '', chars: 0 }
881 await publish($, live)
882
883 return
884 case 'input': {
885 const { composing } = live
886
887 if (composing) {
888 composing.chars += chunk.json.length
889
890 if (composing.head.length < MAX_INPUT_HEAD) {
891 composing.head += chunk.json
892 }
893
894 await publish($, live, false)
895 }
896
897 return
898 }
899 }
900}
901
902/**
903 * What the box keeps saying while Claude thinks or writes a call it cannot
904 * name yet: the turn's last action, or the prompt before there is one.
905 */
906function standingLabel(live: Live): string {
907 return live.lastAction ?? 'Reading your prompt'
908}
909
910/**
911 * Names a background agent by the Agent call that launched it: the call's
912 * short `description`, keyed by the id its result carries (the result's own
913 * `description` can hold the whole prompt). Its own calls may already have
914 * shown it.
915 */
916async function launched(
917 $: EngineInterface,
918 live: Live,
919 result: unknown,
920 asked: unknown,
921): Promise<void> {
922 if (typeof result !== 'object' || result === null) {
923 return
924 }
925
926 const { agentId, description, status } = result as Record<string, unknown>
927
928 if (typeof agentId !== 'string' || status !== 'async_launched') {
929 return
930 }
931
932 const name = [asked, description].find(
933 (text): text is string => typeof text === 'string' && oneLine(text) !== '',
934 )
935
936 if (name === undefined) {
937 return
938 }
939
940 const agent = await agentOf($, live, agentId)
941
942 live.agentNames.set(agentId, name)
943 agent.name = name
944 await publish($, live)
945}
946
947/** The agent an Agent call started, or the one that last did something. */
948function agentUnder(live: Live, toolUseId: string): Agent | undefined {
949 const working = [...live.agents.values()]
950
951 return working.find(agent => agent.toolUseId === toolUseId)
952 ?? working.reduce<Agent | undefined>((a, b) => (a === undefined || b.activeMs > a.activeMs ? b : a), undefined)
953}
954
955function outcomeOf(reason: TurnCompleteReason): ActivityOutcome {
956 return reason === 'aborted' ? 'interrupted' : reason === 'error' ? 'error' : reason === 'refusal' ? 'denied' : 'ok'
957}
958
959function isShellTool(tool: string): boolean {
960 return tool === 'Bash' || tool === 'PowerShell'
961}
962
963function isAgentTool(tool: string): boolean {
964 return tool === 'Agent' || tool === 'Task'
965}
966
967function capitalized(text: string): string {
968 return text.charAt(0).toUpperCase() + text.slice(1)
969}
970hooks/activity-of.ts 97 lines1import { clip, oneLine } from './text'
2
3const MAX_DETAIL_CHARS = 60
4
5/**
6 * Says in a few words what a tool call is doing, for Agent Pulse.
7 *
8 * @param tool the tool's name; a string, as the declared names are only the
9 * tools of the build that wrote the types (Grep and Glob are on some only)
10 * @param args the call's arguments, whole or as far as they have streamed
11 * @returns a short label such as `Reading register.tsx`
12 */
13export function activityOf(tool: string, args: Record<string, unknown>): string {
14 const arg = (key: string): string => {
15 const value = args[key]
16
17 return typeof value === 'string' ? clip(oneLine(value), MAX_DETAIL_CHARS) : ''
18 }
19
20 const file = (key: string): string => baseName(arg(key))
21
22 switch (tool) {
23 case 'Bash':
24 case 'PowerShell':
25 return arg('description') || `Running ${arg('command')}`
26 case 'Read':
27 return `Reading ${file('file_path')}`
28 case 'Edit':
29 return `Editing ${file('file_path')}`
30 case 'Write':
31 return `Writing ${file('file_path')}`
32 case 'NotebookEdit':
33 return `Editing ${file('notebook_path')}`
34 case 'Grep':
35 return `Searching for "${arg('pattern')}"`
36 case 'Glob':
37 return `Finding files ${arg('pattern')}`
38 case 'WebFetch':
39 return `Fetching ${hostOf(arg('url'))}`
40 case 'WebSearch':
41 return `Searching the web for "${arg('query')}"`
42 case 'Agent':
43 case 'Task':
44 return `Running an agent: ${arg('description')}`
45 case 'TodoWrite':
46 return 'Updating the todo list'
47 case 'AskUserQuestion':
48 return 'Waiting for your answer'
49 case 'Skill':
50 return `Using the ${arg('skill')} skill`
51 }
52
53 if (tool.startsWith('mcp__')) {
54 const [, server = '', name = ''] = tool.split('__')
55
56 return `Using ${server}: ${name}`
57 }
58
59 return `Using ${tool}`
60}
61
62/**
63 * The string arguments a tool call's input has streamed so far, read from
64 * its partial JSON: enough to name the file or command before it is whole.
65 */
66export function partialArgsOf(json: string): Record<string, unknown> {
67 const args: Record<string, unknown> = {}
68
69 for (const match of json.matchAll(/"([a-z_]+)"\s*:\s*"((?:[^"\\]|\\.)*)/g)) {
70 const [, key = '', raw = ''] = match
71
72 args[key] ??= unescaped(raw)
73 }
74
75 return args
76}
77
78function unescaped(raw: string): string {
79 try {
80 return JSON.parse(`"${raw}"`) as string
81 } catch {
82 return raw
83 }
84}
85
86function baseName(path: string): string {
87 return path.split(/[\\/]/).pop() || path
88}
89
90function hostOf(url: string): string {
91 try {
92 return new URL(url).host
93 } catch {
94 return url
95 }
96}
97hooks/draw.tsx 534 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import type { ActivityAgent, ActivityBeat, ActivityEntry, ActivityNow, ActivityOutcome, ActivityPhase, ActivityTimeline } from '../types'
4import { barOf, SPINNERS, toolGlyphOf } from './glyphs'
5import { barSvg, jellySvg, stripSvg } from './jelly'
6import type { Level, Palette } from './palette'
7import { agentColorOf, heatOf, mix } from './palette'
8import type { PulseProps } from './pulse'
9import { durationOf, plural, printable } from './text'
10
11const MAX_HISTORY_ROWS = 12
12const MAX_LABEL_CHARS = 120
13const MAX_CARD_CHARS = 400
14const CARD_FROM_CHARS = 40
15const MAX_AGENT_NAME_CHARS = 40
16const MAX_AGENT_ROWS = 6
17const FLASH_MS = 360
18const SPARKLE_MS = 2000
19const BREATH_MS = 1400
20const HISTORY_BAR_CELLS = 6
21/** Above the question dialog every line counts toward its 12 rows, a row per 40 columns. */
22const QUESTION_LABEL_CHARS = 38
23const QUESTION_AGENT_ROWS = 3
24
25/** Everything a drawing reads, gathered once by the `AbovePrompt` hook. */
26export type View = {
27 headline: ActivityNow
28 entries: readonly ActivityEntry[]
29 working: readonly ActivityAgent[]
30 timeline: ActivityTimeline
31 beat: ActivityBeat | null
32 isOpen: boolean
33 nowMs: number
34 maxRows: number
35 bodyColumns: number
36 level: Level
37 palette: Palette
38 onToggle: () => void
39}
40
41/** Draws one of `pulse.tsx`'s animations: made in the hooks module, which names it. */
42export type PulseOf = (key: string, props: PulseProps, width: number) => RenderElement
43
44type WebElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Svg'>
45
46const OUTCOME_MARKS: Record<ActivityOutcome, string> = {
47 ok: '✓',
48 error: '✗',
49 denied: '⊘',
50 interrupted: '■',
51}
52
53const KIND_MARKS: Partial<Record<ActivityEntry['kind'], { mark: string; color: string }>> = {
54 turn: { mark: '›', color: 'text' },
55 reply: { mark: '✎', color: 'success' },
56 compact: { mark: '⇣', color: 'planMode' },
57}
58
59/** What both surfaces lay out the same way: which agents and rows show. */
60function layoutOf(v: View) {
61 // One agent the headline names; more, or one beside the main loop's
62 // work, get a row each.
63 const isTeamListed = v.working.length > 1 || (v.working.length === 1 && v.headline.phase !== 'agent')
64 const listed = isTeamListed ? v.working.slice(-MAX_AGENT_ROWS) : []
65 const unlisted = isTeamListed ? v.working.length - listed.length : 0
66 const teamRows = listed.length + (unlisted > 0 ? 1 : 0)
67 const strip = stripOf(v)
68 const rows = Math.min(MAX_HISTORY_ROWS, Math.max(1, v.maxRows - 4 - teamRows - (strip.length > 0 ? 1 : 0)))
69 const shown = v.entries.slice(-rows)
70 const hidden = v.entries.length - shown.length
71 const maxMs = Math.max(1, ...shown.filter(hasBar).map(entry => entry.durationMs ?? 0))
72
73 return { listed, unlisted, shown, hidden, maxMs, strip }
74}
75
76/** The turn's segments with the running one closed at now; none when idle and closed. */
77function stripOf(v: View): { phase: ActivityPhase; ms: number; isOpen: boolean }[] {
78 const isWorking = v.headline.phase !== 'idle'
79
80 if (!isWorking && !v.isOpen) {
81 return []
82 }
83
84 const segments = v.timeline.segments.map(s => ({ ...s, isOpen: false }))
85 const { open } = v.timeline
86
87 if (open !== null) {
88 segments.push({ phase: open.phase, ms: Math.max(1, v.nowMs - open.sinceMs), isOpen: true })
89 }
90
91 return segments
92}
93
94function hasBar(entry: ActivityEntry): boolean {
95 return entry.durationMs !== null && entry.kind !== 'turn' && entry.kind !== 'end'
96}
97
98function markOf(v: View, entry: ActivityEntry): { mark: string; color: string } {
99 if (entry.kind === 'thought') {
100 return { mark: '∴', color: v.palette.phase.thinking }
101 }
102
103 return KIND_MARKS[entry.kind] ?? { mark: OUTCOME_MARKS[entry.outcome], color: v.palette.outcome[entry.outcome] }
104}
105
106/** The border's color: a call's flash, the breath of a wait, or the phase. */
107function borderOf(v: View): string {
108 const { phase, sinceMs } = v.headline
109 const { beat, palette, level, nowMs } = v
110
111 if (level !== 'off' && beat?.kind === 'flash' && nowMs - beat.atMs < FLASH_MS) {
112 return palette.outcomeHex[beat.outcome]
113 }
114
115 if (level === 'full' && (phase === 'approval' || phase === 'question')) {
116 const wave = (Math.sin(((nowMs - sinceMs) / BREATH_MS) * 2 * Math.PI) + 1) / 2
117
118 return mix(mix(palette.hex[phase], '#000000', 0.6), palette.hex[phase], wave)
119 }
120
121 return phase === 'idle' ? 'inactive' : palette.phase[phase]
122}
123
124function elapsedOf(v: View): { text: string; color: string | undefined } {
125 if (v.headline.phase === 'idle') {
126 return { text: '', color: undefined }
127 }
128
129 const ms = v.nowMs - v.headline.sinceMs
130
131 return { text: ` · ${durationOf(ms)}`, color: heatOf(v.palette, ms) }
132}
133
134/** The status glyph and the sparkle, for the terminal. */
135function terminalMarks(v: View, Text: Elements['terminal']['Text'], pulse: PulseOf) {
136 const { phase } = v.headline
137 const isWorking = phase !== 'idle'
138 const color = v.palette.phase[phase]
139 const spinner = SPINNERS[phase]
140
141 const glyph = v.level === 'off' || !isWorking
142 ? <Text color={color}>{isWorking ? '●' : '○'}</Text>
143 : pulse(`glyph-${phase}`, { kind: 'spin', frames: [...spinner.frames], ms: spinner.ms, color }, 1)
144
145 const { beat } = v
146 const sparkle = v.level === 'full' && beat?.kind === 'done' && v.nowMs - beat.atMs < SPARKLE_MS
147 ? [pulse(`sparkle-${beat.id}`, { kind: 'sparkle', colors: [...v.palette.sparkle] }, 4)]
148 : []
149
150 return { glyph, sparkle }
151}
152
153/**
154 * The box on the terminal: block characters for the charts, and `Client`s
155 * of fixed width for what moves.
156 */
157export function drawTerminal(ui: Elements['terminal'], v: View, pulse: PulseOf, below: RenderElement): RenderElement {
158 const { Box, Text, Button } = ui
159 const { headline, palette } = v
160 const { listed, unlisted, shown, hidden, maxMs, strip } = layoutOf(v)
161 const { glyph, sparkle } = terminalMarks(v, Text, pulse)
162 const elapsed = elapsedOf(v)
163 const label = printable(headline.label, MAX_LABEL_CHARS)
164 const toolGlyph = toolGlyphOf(headline.tool)
165
166 const stripRow = strip.length > 0
167 ? [
168 <Box key="strip" flexDirection="row">
169 <Text dimColor>{' '}</Text>
170 {stripCells(strip, Math.max(10, Math.min(48, v.bodyColumns - 12))).map((run, i) => (
171 <Text key={`strip-${i}`} color={palette.phase[run.phase]}>{'━'.repeat(run.cells)}</Text>
172 ))}
173 </Box>,
174 ]
175 : []
176
177 const agentRows = listed.map((agent, i) => {
178 const color = agentColorOf(palette, agent.name)
179 const isLast = i === listed.length - 1 && unlisted === 0
180 const mark = v.level === 'off'
181 ? <Text color={color}>◆</Text>
182 : pulse(`agent-glyph-${i}`, { kind: 'spin', frames: [...SPINNERS.agent.frames], ms: SPINNERS.agent.ms, color }, 1)
183
184 return (
185 <Box key={`agent-${i}`} flexDirection="row">
186 <Box flexShrink={0}>
187 <Text dimColor>{isLast ? '└─ ' : '├─ '}</Text>
188 {mark}
189 <Text> </Text>
190 </Box>
191 <Text bold color={color} wrap="truncate-end">{printable(agent.name, MAX_AGENT_NAME_CHARS)}</Text>
192 <Text wrap="truncate-end">{` › ${printable(agent.label, MAX_LABEL_CHARS)}`}</Text>
193 <Box flexShrink={0}>
194 <Text color={heatOf(palette, v.nowMs - agent.sinceMs)} dimColor={heatOf(palette, v.nowMs - agent.sinceMs) === undefined}>
195 {` ${durationOf(v.nowMs - agent.sinceMs)}`}
196 </Text>
197 </Box>
198 </Box>
199 )
200 })
201
202 const moreAgents = unlisted > 0 ? [<Text dimColor>{`└─ … ${plural(unlisted, 'more agent')}`}</Text>] : []
203 const teamBox = agentRows.length > 0
204 ? [<Box key="agents" flexDirection="column">{agentRows}{moreAgents}</Box>]
205 : []
206
207 const historyRows = shown.map((entry, i) => {
208 const { mark, color } = markOf(v, entry)
209 const isQuiet = entry.kind === 'thought' || entry.kind === 'end'
210 const took = entry.durationMs === null ? '' : ` ${durationOf(entry.durationMs)}`
211 const bar = hasBar(entry) ? ` ${barOf((entry.durationMs ?? 0) / maxMs, HISTORY_BAR_CELLS)}` : ''
212 const glyphOfTool = toolGlyphOf(entry.tool)
213 const full = printable(entry.label, MAX_CARD_CHARS)
214
215 return (
216 <Box key={`row-${i}`} flexDirection="row" hover={{ scope: `row-${i}` }}>
217 <Box flexShrink={0}>
218 <Text color={color}>{`${mark} `}</Text>
219 {glyphOfTool === '' ? [] : [<Text color={palette.phase.tool}>{`${glyphOfTool} `}</Text>]}
220 </Box>
221 <Text bold={entry.kind === 'turn'} dimColor={isQuiet} wrap="truncate-end">
222 {printable(entry.label, MAX_LABEL_CHARS)}
223 </Text>
224 <Box flexShrink={0}>
225 <Text dimColor>{took}</Text>
226 <Text color={color} dimColor>{bar}</Text>
227 </Box>
228 {full.length > CARD_FROM_CHARS ? [card(Box, Text, `row-${i}`, full, color)] : []}
229 </Box>
230 )
231 })
232
233 const earlier = hidden > 0 ? [<Text dimColor>{`… ${hidden} earlier`}</Text>] : []
234 const empty = v.entries.length === 0 ? [<Text dimColor>No actions yet</Text>] : []
235 const body = v.isOpen
236 ? [<Box key="history" flexDirection="column" marginTop={1}>{earlier}{historyRows}{empty}</Box>]
237 : []
238
239 const fullLabel = printable(headline.label, MAX_CARD_CHARS)
240
241 return (
242 <Box flexDirection="column">
243 <Box flexDirection="column" borderStyle="round" borderColor={borderOf(v)} paddingX={1} alignSelf="flex-start">
244 <Box key="activity" flexDirection="row" hover={{ scope: 'headline' }}>
245 <Button key="toggle" plain label={v.isOpen ? '▾' : '▸'} onPress={v.onToggle} />
246 {/* On a narrow screen only the label gives way: the rest would wrap. */}
247 <Box flexShrink={0}>
248 <Text> </Text>
249 {glyph}
250 <Text> </Text>
251 {toolGlyph === '' ? [] : [<Text color={palette.phase[headline.phase]}>{`${toolGlyph} `}</Text>]}
252 </Box>
253 <Text wrap="truncate-end">{label}</Text>
254 <Box flexShrink={0}>
255 {headline.meter === undefined ? [] : [<Text color={palette.phase[headline.phase]}>{` ${headline.meter}`}</Text>]}
256 <Text color={elapsed.color} dimColor={elapsed.color === undefined}>{elapsed.text}</Text>
257 {sparkle}
258 </Box>
259 </Box>
260 {fullLabel.length > CARD_FROM_CHARS ? [card(Box, Text, 'headline', fullLabel, borderOf(v))] : []}
261 {stripRow}
262 {teamBox}
263 {body}
264 </Box>
265 {below}
266 </Box>
267 )
268}
269
270/**
271 * The whole of a long label, shown while the pointer rests on its row: an
272 * absolute Box, so its showing moves nothing.
273 */
274function card(
275 Box: Elements['terminal']['Box'] | WebElements['Box'],
276 Text: Elements['terminal']['Text'] | WebElements['Text'],
277 scope: string,
278 text: string,
279 color: string,
280): RenderElement {
281 return (
282 <Box
283 key={`card-${scope}`}
284 position="absolute"
285 top={1}
286 left={2}
287 display="none"
288 hover={{ scope, display: 'flex' }}
289 borderStyle="round"
290 borderColor={color}
291 backgroundColor="inverseText"
292 paddingX={1}
293 >
294 <Text>{text}</Text>
295 </Box>
296 )
297}
298
299/**
300 * The strip as runs of cells: each cell takes the phase at its middle in
301 * time, and neighbours of one phase join into one run.
302 */
303function stripCells(
304 segments: readonly { phase: ActivityPhase; ms: number }[],
305 width: number,
306): { phase: ActivityPhase; cells: number }[] {
307 const total = segments.reduce((sum, s) => sum + s.ms, 0)
308 const runs: { phase: ActivityPhase; cells: number }[] = []
309
310 if (total <= 0) {
311 return runs
312 }
313
314 for (let cell = 0; cell < width; cell++) {
315 const at = ((cell + 0.5) / width) * total
316 let passed = 0
317 const segment = segments.find(s => (passed += s.ms) >= at) ?? segments.at(-1)
318
319 if (segment === undefined) {
320 continue
321 }
322
323 const last = runs.at(-1)
324
325 if (last?.phase === segment.phase) {
326 last.cells += 1
327 } else {
328 runs.push({ phase: segment.phase, cells: 1 })
329 }
330 }
331
332 return runs
333}
334
335/**
336 * The box on the desktop (and every surface that draws `Svg`): the jellyfish
337 * beside the headline, and SVG for the strip and the bars.
338 */
339export function drawDesktop(ui: WebElements, v: View, below: RenderElement): RenderElement {
340 const { Box, Text, Button, Svg } = ui
341 const { headline, palette, beat } = v
342 const { phase } = headline
343 const { listed, unlisted, shown, hidden, maxMs, strip } = layoutOf(v)
344 const elapsed = elapsedOf(v)
345 const toolGlyph = toolGlyphOf(headline.tool)
346 const width = Math.max(140, Math.min(480, v.bodyColumns * 8 - 200))
347
348 // A beat belongs to the phase it happened in, so its one-shot plays once.
349 const halo = beat?.kind === 'flash' && beat.atMs >= headline.sinceMs
350 ? { id: beat.id, color: palette.outcomeHex[beat.outcome] }
351 : null
352 const sparkle = beat?.kind === 'done' && phase === 'idle'
353 ? { id: beat.id, color: palette.sparkle[3] ?? palette.hex.writing }
354 : null
355
356 const jelly = jellySvg({ phase, level: v.level, color: palette.hex[phase], halo, sparkle, size: 44 })
357
358 const stripRow = strip.length > 0
359 ? [<Svg key="strip" source={stripSvg(strip, p => palette.hex[p], width, v.level)} alt={`The turn so far: ${stripAlt(strip)}`} width={width} height={6} />]
360 : []
361
362 const agentRows = listed.map((agent, i) => {
363 const color = agentColorOf(palette, agent.name)
364 const isLast = i === listed.length - 1 && unlisted === 0
365 const heat = heatOf(palette, v.nowMs - agent.sinceMs)
366
367 return (
368 <Box key={`agent-${i}`} flexDirection="row">
369 <Box flexShrink={0}>
370 <Text dimColor>{isLast ? '└─ ' : '├─ '}</Text>
371 <Text color={color}>◆ </Text>
372 </Box>
373 <Text weight="semibold" color={color} wrap="truncate-end">{printable(agent.name, MAX_AGENT_NAME_CHARS)}</Text>
374 <Text wrap="truncate-end">{` › ${printable(agent.label, MAX_LABEL_CHARS)}`}</Text>
375 <Box flexShrink={0}>
376 <Text tabular color={heat} dimColor={heat === undefined}>{` ${durationOf(v.nowMs - agent.sinceMs)}`}</Text>
377 </Box>
378 </Box>
379 )
380 })
381
382 const moreAgents = unlisted > 0 ? [<Text dimColor>{`└─ … ${plural(unlisted, 'more agent')}`}</Text>] : []
383 const teamBox = agentRows.length > 0
384 ? [<Box key="agents" flexDirection="column">{agentRows}{moreAgents}</Box>]
385 : []
386
387 const historyRows = shown.map((entry, i) => {
388 const { mark, color } = markOf(v, entry)
389 const isQuiet = entry.kind === 'thought' || entry.kind === 'end'
390 const took = entry.durationMs === null ? '' : ` ${durationOf(entry.durationMs)}`
391 const glyphOfTool = toolGlyphOf(entry.tool)
392 const share = (entry.durationMs ?? 0) / maxMs
393 const full = printable(entry.label, MAX_CARD_CHARS)
394
395 return (
396 <Box key={`row-${i}`} flexDirection="row" alignItems="center" gap={1} hover={{ scope: `row-${i}` }}>
397 <Box flexShrink={0}>
398 <Text color={color}>{mark}</Text>
399 {glyphOfTool === '' ? [] : [<Text color={palette.phase.tool}>{` ${glyphOfTool}`}</Text>]}
400 </Box>
401 <Text weight={entry.kind === 'turn' ? 'semibold' : 'regular'} dimColor={isQuiet} wrap="truncate-end">
402 {printable(entry.label, MAX_LABEL_CHARS)}
403 </Text>
404 <Box flexShrink={0} flexDirection="row" alignItems="center" gap={1}>
405 <Text tabular dimColor>{took}</Text>
406 {hasBar(entry)
407 ? [<Svg key={`bar-${i}`} source={barSvg(share, colorHexOf(v, entry), 48)} alt={`${Math.round(share * 100)}% of the longest`} width={48} height={6} />]
408 : []}
409 </Box>
410 {full.length > CARD_FROM_CHARS ? [card(Box, Text, `row-${i}`, full, color)] : []}
411 </Box>
412 )
413 })
414
415 const earlier = hidden > 0 ? [<Text dimColor>{`… ${hidden} earlier`}</Text>] : []
416 const empty = v.entries.length === 0 ? [<Text dimColor>No actions yet</Text>] : []
417 const body = v.isOpen
418 ? [<Box key="history" flexDirection="column" marginTop={1}>{earlier}{historyRows}{empty}</Box>]
419 : []
420
421 const fullLabel = printable(headline.label, MAX_CARD_CHARS)
422
423 return (
424 <Box flexDirection="column">
425 <Box flexDirection="row" alignItems="center" gap={1} borderStyle="round" borderColor={borderOf(v)} paddingX={1} alignSelf="flex-start">
426 <Svg key="jelly" source={jelly} alt={`Claude is ${phaseWords(phase)}`} width={44} height={44} />
427 <Box flexDirection="column" flexShrink={1} gap={0}>
428 <Box key="activity" flexDirection="row" alignItems="center" hover={{ scope: 'headline' }}>
429 <Button key="toggle" plain label={v.isOpen ? '▾' : '▸'} onPress={v.onToggle} />
430 <Box flexShrink={0}>
431 <Text> </Text>
432 {toolGlyph === '' ? [] : [<Text color={palette.phase[phase]}>{`${toolGlyph} `}</Text>]}
433 </Box>
434 <Text wrap="truncate-end">{printable(headline.label, MAX_LABEL_CHARS)}</Text>
435 <Box flexShrink={0}>
436 {headline.meter === undefined ? [] : [<Text color={palette.phase[phase]}>{` ${headline.meter}`}</Text>]}
437 <Text tabular color={elapsed.color} dimColor={elapsed.color === undefined}>{elapsed.text}</Text>
438 </Box>
439 </Box>
440 {fullLabel.length > CARD_FROM_CHARS ? [card(Box, Text, 'headline', fullLabel, borderOf(v))] : []}
441 {stripRow}
442 {teamBox}
443 {body}
444 </Box>
445 </Box>
446 {below}
447 </Box>
448 )
449}
450
451function colorHexOf(v: View, entry: ActivityEntry): string {
452 return entry.kind === 'thought'
453 ? v.palette.hex.thinking
454 : entry.kind === 'reply'
455 ? v.palette.hex.writing
456 : v.palette.outcomeHex[entry.outcome]
457}
458
459function stripAlt(strip: readonly { phase: ActivityPhase; ms: number }[]): string {
460 return strip.map(s => `${phaseWords(s.phase)} ${durationOf(s.ms)}`).join(', ')
461}
462
463function phaseWords(phase: ActivityPhase): string {
464 switch (phase) {
465 case 'idle':
466 return 'idle'
467 case 'requesting':
468 return 'reading'
469 case 'thinking':
470 return 'thinking'
471 case 'writing':
472 return 'writing the reply'
473 case 'composing':
474 return 'writing a tool call'
475 case 'tool':
476 return 'running a tool'
477 case 'agent':
478 return 'running an agent'
479 case 'approval':
480 return 'waiting for your approval'
481 case 'question':
482 return 'waiting for your answer'
483 case 'compacting':
484 return 'compacting'
485 }
486}
487
488/**
489 * The box while the AskUserQuestion dialog holds the prompt: a still copy
490 * of the headline and the agents at work, above the engine's dialog, which
491 * stands once with nothing below it.
492 *
493 * That site takes no `Client`, no size, place or hover, and 12 rows on the
494 * terminal (a row per 40 columns of text), so each line is cut to fit one row.
495 */
496export function drawQuestion(ui: Pick<WebElements, 'Box' | 'Text'>, v: View, dialog: RenderElement): RenderElement {
497 const { Box, Text } = ui
498 const { headline, palette } = v
499 const { phase } = headline
500 const listed = v.working.slice(-QUESTION_AGENT_ROWS)
501 const unlisted = v.working.length - listed.length
502 const elapsed = elapsedOf(v)
503
504 const agentRows = listed.map((agent, i) => {
505 const color = agentColorOf(palette, agent.name)
506 const isLast = i === listed.length - 1 && unlisted === 0
507
508 return (
509 <Text key={`agent-${i}`}>
510 <Text dimColor>{isLast ? '└─ ' : '├─ '}</Text>
511 <Text color={color}>{'◆ '}</Text>
512 {printable(`${agent.name} › ${agent.label}`, QUESTION_LABEL_CHARS - 5)}
513 </Text>
514 )
515 })
516
517 const moreAgents = unlisted > 0 ? [<Text dimColor>{`└─ … ${plural(unlisted, 'more agent')}`}</Text>] : []
518
519 return (
520 <Box flexDirection="column">
521 <Box key="activity" flexDirection="column" borderStyle="round" borderColor={borderOf(v)} paddingX={1} alignSelf="flex-start">
522 <Text>
523 <Text color={palette.phase[phase]}>{phase === 'idle' ? '○ ' : '● '}</Text>
524 {printable(headline.label, QUESTION_LABEL_CHARS - 12)}
525 <Text color={elapsed.color} dimColor={elapsed.color === undefined}>{elapsed.text}</Text>
526 </Text>
527 {agentRows}
528 {moreAgents}
529 </Box>
530 {dialog}
531 </Box>
532 )
533}
534hooks/glyphs.ts 93 lines1import type { ActivityPhase } from '../types'
2
3/** A one-cell mark per tool, Unicode symbols that keep the box aligned. */
4export function toolGlyphOf(tool: string | undefined): string {
5 if (tool === undefined) {
6 return ''
7 }
8
9 if (tool.startsWith('mcp__')) {
10 return '⧉'
11 }
12
13 switch (tool) {
14 case 'Read':
15 return '◧'
16 case 'Edit':
17 case 'NotebookEdit':
18 return '✎'
19 case 'Write':
20 return '✚'
21 case 'Bash':
22 case 'PowerShell':
23 return '❯'
24 case 'Grep':
25 return '⌕'
26 case 'Glob':
27 return '✱'
28 case 'WebFetch':
29 case 'WebSearch':
30 return '⌁'
31 case 'Agent':
32 case 'Task':
33 return '◆'
34 case 'TodoWrite':
35 return '☰'
36 case 'AskUserQuestion':
37 return '?'
38 case 'Skill':
39 return '✦'
40 default:
41 return '•'
42 }
43}
44
45/** A spinner: its frames, one cell each, and how long each shows. */
46export type Spinner = { frames: readonly string[]; ms: number }
47
48const BRAILLE: Spinner = { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], ms: 80 }
49
50/** The status glyph's animation in each phase; idle stands still. */
51export const SPINNERS: Record<ActivityPhase, Spinner> = {
52 idle: { frames: ['○'], ms: 1000 },
53 requesting: { frames: ['◐', '◓', '◑', '◒'], ms: 140 },
54 thinking: { frames: ['·', '∘', '○', '◎', '●', '◎', '○', '∘'], ms: 130 },
55 writing: { frames: ['⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿', '⣷', '⣶', '⣦', '⣤', '⣄'], ms: 90 },
56 composing: { frames: ['▖', '▘', '▝', '▗'], ms: 110 },
57 tool: BRAILLE,
58 agent: BRAILLE,
59 approval: { frames: ['●', '◉', '○', '◉'], ms: 260 },
60 question: { frames: ['?', '?', ' ', '?'], ms: 260 },
61 compacting: { frames: ['⣿', '⣶', '⣤', '⣀', '⣤', '⣶'], ms: 150 },
62}
63
64const LEVELS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
65
66/**
67 * A one-cell meter on a log scale: `▁` for a little, `█` from `full` on.
68 *
69 * @param amount the count so far (words, characters)
70 * @param full the count that fills it
71 */
72export function meterOf(amount: number, full: number): string {
73 if (amount <= 0) {
74 return ''
75 }
76
77 const share = Math.log1p(amount) / Math.log1p(full)
78
79 return LEVELS[Math.min(LEVELS.length - 1, Math.floor(share * LEVELS.length))] ?? '█'
80}
81
82const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'] as const
83
84/**
85 * A bar `width` cells wide at most, filled to `share` in eighths of a cell;
86 * anything above nothing shows at least a sliver.
87 */
88export function barOf(share: number, width: number): string {
89 const eighths = Math.max(share > 0 ? 1 : 0, Math.round(Math.min(1, Math.max(0, share)) * width * 8))
90
91 return '█'.repeat(Math.floor(eighths / 8)) + EIGHTHS[eighths % 8]
92}
93hooks/palette.ts 169 lines1import type { ActivityOutcome, ActivityPhase } from '../types'
2
3/** How much the box moves: `userConfig.animation`. */
4export type Level = 'off' | 'subtle' | 'full'
5
6export type PaletteName = 'claude' | 'neon' | 'mono'
7
8/**
9 * The colors one palette paints with. `phase` and `outcome` are what `Text`
10 * and borders take, theme keys where one means the same, so they follow the
11 * person's theme; `hex` is the same color as a hex string, for what mixes or
12 * draws in SVG.
13 */
14export type Palette = {
15 phase: Record<ActivityPhase, string>
16 hex: Record<ActivityPhase, string>
17 outcome: Record<ActivityOutcome, string>
18 outcomeHex: Record<ActivityOutcome, string>
19 /** The colors the turn-end sparkle cycles through. */
20 sparkle: readonly string[]
21 /** Amber, orange, red: a step that runs past 30 s, 2 min, 5 min. */
22 heat: readonly [string, string, string]
23 /** One color per subagent, picked by its name. */
24 agents: readonly string[]
25}
26
27const CLAUDE_HEX: Record<ActivityPhase, string> = {
28 idle: '#8a8a8a',
29 requesting: '#8fa8f8',
30 thinking: '#c084fc',
31 writing: '#4eba65',
32 composing: '#47a8e5',
33 tool: '#d97757',
34 agent: '#2ec4b6',
35 approval: '#ffc107',
36 question: '#ffc107',
37 compacting: '#48968c',
38}
39
40const NEON_HEX: Record<ActivityPhase, string> = {
41 idle: '#6c6f93',
42 requesting: '#00e5ff',
43 thinking: '#ff2bd6',
44 writing: '#39ff14',
45 composing: '#7c4dff',
46 tool: '#00e5ff',
47 agent: '#00ffa3',
48 approval: '#ffe600',
49 question: '#ffe600',
50 compacting: '#7c4dff',
51}
52
53const MONO_HEX: Record<ActivityPhase, string> = {
54 idle: '#7a7a7a',
55 requesting: '#b8b8b8',
56 thinking: '#b8b8b8',
57 writing: '#b8b8b8',
58 composing: '#b8b8b8',
59 tool: '#d0d0d0',
60 agent: '#d0d0d0',
61 approval: '#ffc107',
62 question: '#ffc107',
63 compacting: '#b8b8b8',
64}
65
66const OUTCOME_HEX: Record<ActivityOutcome, string> = {
67 ok: '#4eba65',
68 error: '#ff6b80',
69 denied: '#ffc107',
70 interrupted: '#8a8a8a',
71}
72
73const OUTCOME_THEME: Record<ActivityOutcome, string> = {
74 ok: 'success',
75 error: 'error',
76 denied: 'warning',
77 interrupted: 'inactive',
78}
79
80const PALETTES: Record<PaletteName, Palette> = {
81 claude: {
82 phase: {
83 idle: 'inactive',
84 requesting: 'suggestion',
85 thinking: CLAUDE_HEX.thinking,
86 writing: 'success',
87 composing: 'ide',
88 tool: 'claude',
89 agent: CLAUDE_HEX.agent,
90 approval: 'warning',
91 question: 'warning',
92 compacting: 'planMode',
93 },
94 hex: CLAUDE_HEX,
95 outcome: OUTCOME_THEME,
96 outcomeHex: OUTCOME_HEX,
97 sparkle: ['#d97757', '#e48a66', '#f0a47f', '#fbc8a8', '#ffe6d5', '#fbc8a8', '#f0a47f', '#e48a66'],
98 heat: ['#e0af68', '#ff9e64', 'error'],
99 agents: ['#2ec4b6', '#f4a261', '#a78bfa', '#60a5fa', '#f472b6', '#84cc16'],
100 },
101 neon: {
102 phase: NEON_HEX,
103 hex: NEON_HEX,
104 outcome: { ok: '#39ff14', error: '#ff1744', denied: '#ffe600', interrupted: '#6c6f93' },
105 outcomeHex: { ok: '#39ff14', error: '#ff1744', denied: '#ffe600', interrupted: '#6c6f93' },
106 sparkle: ['#ff2bd6', '#c03bff', '#7c4dff', '#00e5ff', '#00ffa3', '#39ff14', '#ffe600', '#ff8c00'],
107 heat: ['#ffe600', '#ff8c00', '#ff1744'],
108 agents: ['#00e5ff', '#ff2bd6', '#39ff14', '#ffe600', '#7c4dff', '#ff8c00'],
109 },
110 mono: {
111 phase: {
112 idle: 'inactive',
113 requesting: 'text',
114 thinking: 'text',
115 writing: 'text',
116 composing: 'text',
117 tool: 'text',
118 agent: 'text',
119 approval: 'warning',
120 question: 'warning',
121 compacting: 'text',
122 },
123 hex: MONO_HEX,
124 outcome: OUTCOME_THEME,
125 outcomeHex: OUTCOME_HEX,
126 sparkle: ['#6e6e6e', '#8a8a8a', '#a8a8a8', '#c8c8c8', '#e8e8e8', '#c8c8c8', '#a8a8a8', '#8a8a8a'],
127 heat: ['warning', 'warning', 'error'],
128 agents: ['#d0d0d0', '#a8a8a8', '#e8e8e8', '#8a8a8a'],
129 },
130}
131
132export function paletteOf(name: unknown): Palette {
133 return PALETTES[name === 'neon' || name === 'mono' ? name : 'claude']
134}
135
136export function levelOf(value: unknown): Level {
137 return value === 'off' || value === 'full' ? value : 'subtle'
138}
139
140/**
141 * The color a step's timer takes as it runs long, or `undefined` while it is
142 * young (drawn dim).
143 */
144export function heatOf(palette: Palette, ms: number): string | undefined {
145 return ms >= 300_000 ? palette.heat[2] : ms >= 120_000 ? palette.heat[1] : ms >= 30_000 ? palette.heat[0] : undefined
146}
147
148/** One color per agent, the same each time its name comes up. */
149export function agentColorOf(palette: Palette, name: string): string {
150 let hash = 0
151
152 for (const char of name) {
153 hash = (hash * 31 + char.charCodeAt(0)) | 0
154 }
155
156 return palette.agents[Math.abs(hash) % palette.agents.length] ?? palette.hex.agent
157}
158
159/**
160 * Mixes two hex colors, `t` 0 being `a` and 1 being `b`.
161 */
162export function mix(a: string, b: string, t: number): string {
163 const channels = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
164 const [from, to] = [channels(a), channels(b)]
165 const k = Math.min(1, Math.max(0, t))
166
167 return `#${from.map((c, i) => Math.round(c + ((to[i] ?? c) - c) * k).toString(16).padStart(2, '0')).join('')}`
168}
169hooks/text.ts 99 lines1/** Collapses runs of whitespace, newlines included, to single spaces. */
2export function oneLine(text: string): string {
3 return text.replace(/\s+/g, ' ').trim()
4}
5
6/** Cuts `text` to `max` characters, marking the cut with an ellipsis. */
7export function clip(text: string, max: number): string {
8 return text.length > max ? `${text.slice(0, max - 1)}…` : text
9}
10
11/**
12 * The sentence Claude is thinking right now: the tail of the thought so far,
13 * with the sentence before it when the current one has barely started.
14 *
15 * @param thought the thinking block's text so far
16 * @param max the longest snippet, cut from the front
17 */
18export function thoughtOf(thought: string, max: number): string {
19 const flat = oneLine(thought.replace(/[*_`#>]/g, ''))
20
21 if (flat === '') {
22 return ''
23 }
24
25 const sentences = flat.split(/(?<=[.!?:])\s+/)
26 const last = sentences.at(-1) ?? ''
27 const before = sentences.at(-2)
28 const tail = last.length < 25 && before !== undefined ? `${before} ${last}` : last
29
30 return tail.length > max ? `…${tail.slice(-(max - 1))}` : tail
31}
32
33export function wordsIn(text: string): number {
34 return text.split(/\s+/).filter(Boolean).length
35}
36
37export function plural(count: number, word: string): string {
38 return `${count} ${word}${count === 1 ? '' : 's'}`
39}
40
41/** `840 B`, `2.3 KB`, `1.1 MB`. */
42export function sizeOf(chars: number): string {
43 if (chars < 1024) {
44 return `${chars} B`
45 }
46
47 if (chars < 1024 * 1024) {
48 return `${(chars / 1024).toFixed(1)} KB`
49 }
50
51 return `${(chars / 1024 / 1024).toFixed(1)} MB`
52}
53
54/** `0.4s` under ten seconds, `12s` under a minute, `3m 05s` after. */
55export function durationOf(ms: number): string {
56 const seconds = Math.max(0, ms) / 1000
57
58 if (seconds < 10) {
59 return `${seconds.toFixed(1)}s`
60 }
61
62 if (seconds < 60) {
63 return `${Math.floor(seconds)}s`
64 }
65
66 const minutes = Math.floor(seconds / 60)
67 const rest = Math.floor(seconds % 60)
68
69 return `${minutes}m ${String(rest).padStart(2, '0')}s`
70}
71
72/**
73 * A label safe to draw: control, format and line-separator characters out
74 * (the engine refuses a drawing holding one), whitespace collapsed, and cut
75 * to `max`. Labels carry paths, commands and thoughts the model wrote.
76 */
77export function printable(text: string, max: number): string {
78 const spaced = text.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').replace(/\p{Cf}/gu, '')
79
80 return clip(oneLine(spaced), max)
81}
82
83/**
84 * Counts the words a streamed chunk adds, carrying whether the text so far
85 * ends inside a word, so a reply is never counted again from its start.
86 *
87 * @param chunk the newly streamed text
88 * @param isInWord whether the text before it ended inside a word
89 */
90export function wordsAdded(chunk: string, isInWord: boolean): { added: number; isInWord: boolean } {
91 const words = chunk.match(/\S+/g)?.length ?? 0
92 const continues = isInWord && /^\S/.test(chunk)
93
94 return {
95 added: continues ? words - 1 : words,
96 isInWord: chunk === '' ? isInWord : /\S$/.test(chunk),
97 }
98}
99hooks/pulse.tsx 63 lines1import type { ClientModule } from 'claude-code'
2
3/**
4 * The terminal's animations, one `Client` region each, all a fixed width so
5 * the box's layout never moves under them:
6 *
7 * - `spin`: the status glyph, cycling `frames` every `ms`
8 * - `sparkle`: a short burst of stars after a turn ends, then nothing
9 *
10 * Each runs on the surface's frame clock; the hooks never run per frame. A
11 * new phase or a new beat is a new `key`, so a new instance with its own
12 * clock.
13 */
14export type PulseProps =
15 | { kind: 'spin'; frames: string[]; ms: number; color: string }
16 | { kind: 'sparkle'; colors: string[] }
17
18type PulseState = { frame: number }
19
20const SPARKLE_FRAMES = [' ✦ ', ' ✧ ✦', '✦ ✧ ', ' ⋆ ✧', ' · ', ' '] as const
21const SPARKLE_MS = 260
22
23const Pulse: ClientModule<PulseProps, PulseState> = (props, surface) => {
24 const { Text } = surface.elements
25
26 if (surface.state === undefined) {
27 let frame = 0
28 const ms = props.kind === 'sparkle' ? SPARKLE_MS : props.ms
29
30 const stop = surface.every(ms, () => {
31 frame += 1
32
33 // The sparkle plays once, then rests on its blank last frame.
34 if (props.kind === 'sparkle' && frame >= SPARKLE_FRAMES.length - 1) {
35 stop()
36 }
37
38 surface.setState({ frame })
39 })
40 }
41
42 const frame = surface.state?.frame ?? 0
43
44 switch (props.kind) {
45 case 'spin':
46 return <Text color={props.color}>{props.frames[frame % props.frames.length] ?? ' '}</Text>
47 case 'sparkle': {
48 const cells = SPARKLE_FRAMES[Math.min(frame, SPARKLE_FRAMES.length - 1)] ?? ' '
49 const n = props.colors.length
50
51 return (
52 <Text>
53 {[...cells].map((char, i) => (
54 <Text color={props.colors[(i + frame) % n]}>{char}</Text>
55 ))}
56 </Text>
57 )
58 }
59 }
60}
61
62export default Pulse
63hooks/jelly.ts 219 lines1import type { ActivityPhase } from '../types'
2import type { Level } from './palette'
3import { mix } from './palette'
4
5/**
6 * SVG for the desktop: the jellyfish, the timeline strip and the history's
7 * duration bars. Each is a whole document for `Svg`'s `source`, animated by
8 * CSS in its own `<style>`, so the browser runs the loop and the mod draws
9 * only when the state changes.
10 *
11 * A source that changes restarts its animations, so nothing here carries the
12 * time: a one-shot (the halo, the sparkle) carries its beat's id instead.
13 */
14
15const REDUCED = '@media (prefers-reduced-motion: reduce){*{animation:none!important}}'
16
17/** How the jellyfish moves in each phase, as CSS durations in seconds. */
18type Gait = { bob: number; pulse: number; swing: number }
19
20const GAITS: Record<ActivityPhase, Gait> = {
21 idle: { bob: 4.2, pulse: 0, swing: 3.6 },
22 requesting: { bob: 1.6, pulse: 2.2, swing: 1.8 },
23 thinking: { bob: 3, pulse: 2.4, swing: 2.6 },
24 writing: { bob: 2, pulse: 1.4, swing: 1.4 },
25 composing: { bob: 1.8, pulse: 1.2, swing: 0.45 },
26 tool: { bob: 1.4, pulse: 0.9, swing: 0.9 },
27 agent: { bob: 1.4, pulse: 0.9, swing: 0.9 },
28 approval: { bob: 2.4, pulse: 2, swing: 2 },
29 question: { bob: 2.4, pulse: 2, swing: 2 },
30 compacting: { bob: 3, pulse: 0, swing: 1.6 },
31}
32
33export type JellyArgs = {
34 phase: ActivityPhase
35 level: Level
36 /** The phase's color, hex. */
37 color: string
38 /** A call that ended in this phase: its id and outcome color, hex. */
39 halo: { id: number; color: string } | null
40 /** A turn that just ended: its id. */
41 sparkle: { id: number; color: string } | null
42 size: number
43}
44
45/** The jellyfish acting out `phase`, as an SVG document. */
46export function jellySvg(args: JellyArgs): string {
47 const { phase, level, color, halo, sparkle, size } = args
48 const gait = GAITS[phase]
49 const isIdle = phase === 'idle'
50 const isWaiting = phase === 'approval' || phase === 'question'
51 const isFull = level === 'full'
52 const light = mix(color, '#ffffff', 0.45)
53 const deep = mix(color, '#000000', 0.25)
54
55 const css: string[] = [
56 '.body{transform-box:fill-box;transform-origin:50% 0}',
57 '.bell{transform-box:fill-box;transform-origin:50% 100%}',
58 '.tent{transform-box:fill-box;transform-origin:50% 0}',
59 '.halo,.star{transform-box:fill-box;transform-origin:50% 50%}',
60 ]
61
62 if (level !== 'off') {
63 css.push(
64 `.body{animation:bob ${gait.bob}s ease-in-out infinite}`,
65 '@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.6px)}}',
66 `.tent{animation:swing ${gait.swing}s ease-in-out infinite}`,
67 '.t2{animation-delay:-.3s}.t3{animation-delay:-.6s}.t4{animation-delay:-.9s}',
68 '@keyframes swing{0%,100%{transform:rotate(-7deg)}50%{transform:rotate(7deg)}}',
69 )
70
71 if (gait.pulse > 0) {
72 css.push(
73 `.bell{animation:pulse ${gait.pulse}s ease-in-out infinite}`,
74 '@keyframes pulse{0%,100%{transform:scale(1,1)}35%{transform:scale(1.07,.9)}}',
75 )
76 }
77
78 if (phase === 'compacting') {
79 css.push(
80 '.bell{animation:squish 1.6s ease-in-out infinite alternate}',
81 '@keyframes squish{from{transform:scale(1,1)}to{transform:scale(1.16,.84)}}',
82 )
83 }
84
85 css.push(
86 `.glow{animation:breathe ${isWaiting ? 1.4 : 3.2}s ease-in-out infinite}`,
87 `@keyframes breathe{0%,100%{opacity:${isIdle ? 0.15 : 0.3}}50%{opacity:${isWaiting ? 1 : isIdle ? 0.3 : 0.65}}}`,
88 '.halo{animation:halo .8s ease-out 1 forwards}',
89 '@keyframes halo{from{transform:scale(.55);opacity:1}to{transform:scale(1.5);opacity:0}}',
90 )
91 }
92
93 if (isFull) {
94 css.push(
95 '.bub{animation:rise 2.4s ease-in infinite;opacity:0}.b2{animation-delay:-.8s}.b3{animation-delay:-1.6s}',
96 '@keyframes rise{0%{transform:translateY(0);opacity:0}25%{opacity:.9}100%{transform:translateY(-12px);opacity:0}}',
97 '.ink{animation:ink 1.2s ease-in-out infinite;opacity:0}.i2{animation-delay:.2s}.i3{animation-delay:.4s}',
98 '@keyframes ink{0%,100%{opacity:0}50%{opacity:.9}}',
99 '.z{animation:zz 3s ease-in-out infinite;opacity:0}',
100 '@keyframes zz{0%{transform:translate(0,0);opacity:0}40%{opacity:.8}100%{transform:translate(3px,-8px);opacity:0}}',
101 '.star{animation:twinkle 1.6s ease-in-out 1 forwards;opacity:0}.s2{animation-delay:.15s}.s3{animation-delay:.3s}.s4{animation-delay:.45s}',
102 '@keyframes twinkle{0%{transform:scale(0) rotate(0);opacity:0}40%{transform:scale(1.2) rotate(60deg);opacity:1}100%{transform:scale(0) rotate(120deg);opacity:0}}',
103 )
104 }
105
106 const eyes = isIdle
107 ? '<path d="M18.6 18.4q1.4 1.1 2.8 0M26.6 18.4q1.4 1.1 2.8 0" stroke="#2b2b3a" stroke-width="1.1" fill="none" stroke-linecap="round"/>'
108 : isWaiting
109 ? '<circle cx="20" cy="18" r="2.3" fill="#2b2b3a"/><circle cx="28" cy="18" r="2.3" fill="#2b2b3a"/>'
110 + '<circle cx="20.8" cy="17.2" r=".8" fill="#fff"/><circle cx="28.8" cy="17.2" r=".8" fill="#fff"/>'
111 : `<circle cx="20" cy="${phase === 'requesting' ? 17.3 : 18}" r="1.6" fill="#2b2b3a"/>`
112 + `<circle cx="28" cy="${phase === 'requesting' ? 17.3 : 18}" r="1.6" fill="#2b2b3a"/>`
113 + '<circle cx="20.5" cy="17.4" r=".55" fill="#fff"/><circle cx="28.5" cy="17.4" r=".55" fill="#fff"/>'
114
115 const extras: string[] = []
116
117 if (isFull && phase === 'thinking') {
118 extras.push(
119 `<circle class="bub b1" cx="36" cy="14" r="1.4" fill="none" stroke="${light}" stroke-width=".8"/>`,
120 `<circle class="bub b2" cx="39" cy="12" r="1" fill="none" stroke="${light}" stroke-width=".7"/>`,
121 `<circle class="bub b3" cx="34" cy="10" r=".8" fill="none" stroke="${light}" stroke-width=".6"/>`,
122 )
123 }
124
125 if (isFull && phase === 'writing') {
126 extras.push(
127 `<circle class="ink i1" cx="39" cy="31" r="1.1" fill="${color}"/>`,
128 `<circle class="ink i2" cx="42" cy="34" r=".9" fill="${color}"/>`,
129 `<circle class="ink i3" cx="45" cy="37" r=".7" fill="${color}"/>`,
130 )
131 }
132
133 if (isFull && isIdle) {
134 extras.push(`<text class="z" x="35" y="11" font-size="6" font-family="sans-serif" fill="${light}">z</text>`)
135 }
136
137 if (isWaiting) {
138 extras.push(
139 `<text x="37" y="11" font-size="9" font-weight="700" font-family="sans-serif" fill="${color}">${phase === 'approval' ? '!' : '?'}</text>`,
140 )
141 }
142
143 if (halo && level !== 'off') {
144 extras.push(`<circle class="halo" data-beat="${halo.id}" cx="24" cy="20" r="14" fill="none" stroke="${halo.color}" stroke-width="1.6"/>`)
145 }
146
147 if (sparkle && isFull) {
148 const star = (x: number, y: number, n: number) =>
149 `<path class="star s${n}" d="M${x} ${y - 3}L${x + 0.8} ${y - 0.8}L${x + 3} ${y}L${x + 0.8} ${y + 0.8}L${x} ${y + 3}L${x - 0.8} ${y + 0.8}L${x - 3} ${y}L${x - 0.8} ${y - 0.8}Z" fill="${sparkle.color}"/>`
150
151 extras.push(`<g data-beat="${sparkle.id}">${star(8, 9, 1)}${star(40, 7, 2)}${star(42, 28, 3)}${star(6, 30, 4)}</g>`)
152 }
153
154 const tentacles = [14.5, 20, 28, 33.5]
155 .map((x, i) => `<path class="tent t${i + 1}" d="M${x} 23q-2 5 0 9q2 4 0 8" stroke="${color}" stroke-width="1.6" fill="none" stroke-linecap="round" opacity=".85"/>`)
156 .join('')
157
158 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 48 48">`
159 + `<style>${css.join('')}${REDUCED}</style>`
160 + '<defs>'
161 + `<radialGradient id="g" cx="50%" cy="45%" r="50%"><stop offset="0" stop-color="${color}" stop-opacity=".9"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></radialGradient>`
162 + `<linearGradient id="b" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${light}"/><stop offset="1" stop-color="${color}"/></linearGradient>`
163 + '</defs>'
164 + `<circle class="glow" cx="24" cy="21" r="22" fill="url(#g)" opacity="${isIdle ? 0.15 : 0.45}"/>`
165 + `<g${isIdle ? ' opacity=".6" transform="translate(0 2)"' : ''}><g class="body">`
166 + tentacles
167 + '<g class="bell">'
168 + `<path d="M9.5 23C9.5 9.5 38.5 9.5 38.5 23Q35 26 31.5 23Q27.75 26 24 23Q20.25 26 16.5 23Q13 26 9.5 23Z" fill="url(#b)" stroke="${deep}" stroke-width=".7"/>`
169 + '<ellipse cx="17.5" cy="14.5" rx="3" ry="1.8" fill="#fff" opacity=".45" transform="rotate(-20 17.5 14.5)"/>'
170 + eyes
171 + '<circle cx="16.6" cy="20.6" r="1.5" fill="#ff8fa3" opacity=".5"/><circle cx="31.4" cy="20.6" r="1.5" fill="#ff8fa3" opacity=".5"/>'
172 + (isWaiting
173 ? '<ellipse cx="24" cy="21" rx="1" ry="1.2" fill="#2b2b3a"/>'
174 : '<path d="M22.6 20.6q1.4 1.2 2.8 0" stroke="#2b2b3a" stroke-width=".9" fill="none" stroke-linecap="round"/>')
175 + '</g></g></g>'
176 + extras.join('')
177 + '</svg>'
178}
179
180/**
181 * The turn's phases as one rounded strip of colored segments, each as wide
182 * as its share of the turn, the running one breathing.
183 */
184export function stripSvg(
185 segments: readonly { phase: ActivityPhase; ms: number; isOpen: boolean }[],
186 hexOf: (phase: ActivityPhase) => string,
187 width: number,
188 level: Level,
189): string {
190 const height = 6
191 const total = segments.reduce((sum, s) => sum + s.ms, 0) || 1
192 let x = 0
193
194 const rects = segments.map(s => {
195 const w = Math.max(1.5, (s.ms / total) * width)
196 const rect = `<rect${s.isOpen && level !== 'off' ? ' class="live"' : ''} x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${height}" fill="${hexOf(s.phase)}"/>`
197
198 x += w
199
200 return rect
201 })
202
203 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
204 + `<style>.live{animation:live 1s ease-in-out infinite}@keyframes live{0%,100%{opacity:1}50%{opacity:.45}}${REDUCED}</style>`
205 + `<clipPath id="c"><rect width="${width}" height="${height}" rx="3"/></clipPath>`
206 + `<g clip-path="url(#c)"><rect width="${width}" height="${height}" fill="#8a8a8a" opacity=".15"/>${rects.join('')}</g>`
207 + '</svg>'
208}
209
210/** One history row's duration as a short rounded bar. */
211export function barSvg(share: number, color: string, width: number): string {
212 const w = Math.max(2, Math.min(1, share) * width)
213
214 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="6" viewBox="0 0 ${width} 6">`
215 + `<rect width="${width}" height="6" rx="3" fill="#8a8a8a" opacity=".15"/>`
216 + `<rect width="${w.toFixed(1)}" height="6" rx="3" fill="${color}" opacity=".85"/>`
217 + '</svg>'
218}
219types/index.d.ts 86 lines1/**
2 * What the box says Claude is doing: `idle` between turns, a model phase
3 * (`requesting`, `thinking`, `writing`, `composing`), a tool phase (`tool`,
4 * `agent`, `approval`, `question`), or `compacting`.
5 */
6export type ActivityPhase =
7 | 'idle'
8 | 'requesting'
9 | 'thinking'
10 | 'writing'
11 | 'composing'
12 | 'tool'
13 | 'agent'
14 | 'approval'
15 | 'question'
16 | 'compacting'
17
18/** The box's headline: the phase, its words, and when it began. */
19export type ActivityNow = {
20 phase: ActivityPhase
21 label: string
22 sinceMs: number
23 /** The tool at work or being written, for its glyph. */
24 tool?: string
25 /** A one-cell meter of the reply's words or the call's size. */
26 meter?: string
27}
28
29/**
30 * The phases of a turn in order, for the timeline strip: the closed ones
31 * with how long each took, and the one running since `open.sinceMs`.
32 */
33export type ActivityTimeline = {
34 segments: readonly { phase: ActivityPhase; ms: number }[]
35 open: { phase: ActivityPhase; sinceMs: number } | null
36}
37
38/**
39 * The last moment worth a flourish: a call that ended (`flash`) or a turn
40 * that did (`done`). `id` counts up, so each plays once.
41 */
42export type ActivityBeat = {
43 id: number
44 kind: 'flash' | 'done'
45 outcome: ActivityOutcome
46 atMs: number
47}
48
49/**
50 * A subagent at work: what it was asked to do, its latest action, and when
51 * it started.
52 */
53export type ActivityAgent = {
54 name: string
55 label: string
56 sinceMs: number
57}
58
59export type ActivityOutcome = 'ok' | 'error' | 'denied' | 'interrupted'
60
61/**
62 * One row of the expanded box: a prompt that started a turn, a stretch of
63 * thinking, a reply, a tool call, a compaction, or how a turn ended.
64 */
65export type ActivityEntry = {
66 kind: 'turn' | 'thought' | 'reply' | 'tool' | 'compact' | 'end'
67 label: string
68 durationMs: number | null
69 outcome: ActivityOutcome
70 /** A tool call's tool, for its glyph. */
71 tool?: string
72}
73
74declare module 'claude-code' {
75 interface PluginState {
76 'agent-pulse': {
77 now: ActivityNow
78 history: readonly ActivityEntry[]
79 agents: readonly ActivityAgent[]
80 isExpanded: boolean
81 timeline: ActivityTimeline
82 beat: ActivityBeat | null
83 }
84 }
85}
86