SLOPSHOPPER

Agent Pulse

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…

newbandrowsguardtimeragents
v1.0.1MITupdated 2026-10-16afterever/claude-mods/agent-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-pulse
› 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 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▸ ○ Idle · last turn done in 0.9s, 9 actions │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▸ ○ Idle · last turn done in 0.9s, 9 actions │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

Agent Pulse

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.

ThinkingRunning a toolWriting the replyWaiting for youIdle
The jellyfish thinking, bubbles risingThe jellyfish running a tool, its bell pulsingThe jellyfish writing, trailing inkThe jellyfish waiting for approval, glowing yellowThe jellyfish idle, asleep

The turn so far, as the timeline strip draws it:

A turn's phases as colored segments: reading, thinking, writing a call, running tools, waiting for approval, writing the reply

What it shows

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        │
╰─────────────────────────────────────────────────────────────────╯

What this fork adds

Every effect carries meaning: nothing moves just to be busy.

AdditionWhat it tells youTerminalDesktop
Status glyphClaude is working, and on what kind of stepa spinner per phase: ◐◓◑◒ reading, ∘○◎● thinking, ⣀⣤⣶⣿ writing, braille for toolsthe jellyfish acts out the phase
JellyfishThe same, at a glancebobs, pulses, swings its tentacles; bubbles while thinking, ink while writing, a ! or ? while it waits on you, asleep when idle
Breathing borderYou are needed: an approval or a questionthe border fades in and outthe glow breathes yellow
FlashA call just finished, and howthe border flashes green ✓, red ✗ or yellow ⊘a halo ring
SparkleThe turn is done✦ ✧ after the timerstars around the jellyfish
Timer heatA step is taking longamber after 30 s, orange after 2 min, red after 5 minsame
Tool glyphsWhich tool runs◧ Read, ✎ Edit, ✚ Write, ❯ shell, ⌕ Grep, ⌁ web, ◆ agent, ⧉ MCPsame
Agent rowsWho is doing what├─ └─ tree lines, a color and a spinner per agentsame, a color per agent
Timeline stripWhere the turn's time wentcolored ━ per phase, under the headlinean SVG strip
Duration barsWhich step was slow▏▎▍▌▋▊▉█ per history rowSVG bars
MetersHow much is being written▁▂▃▄▅▆▇█ for the reply's words or a call's sizesame
Hover cardsThe whole label, when the window cuts itrest the pointer on a rowsame
ChimeClaude started waiting for your approvalopt-inopt-in
Above the questionClaude asked you something: the dialog covers the banda still copy of the headline and agents above the questionsame

Settings

In /config, type Animation to find the three rows:

SettingValuesDefault
Animationoff: 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 sparklessubtle
Paletteclaude: follows your theme with the Claude orange. neon: vivid. mono: color only for what needs youclaude
Soundoff, or approval: a soft chime when Claude starts waiting for your approvaloff

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.

Install

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.

Data and privacy

  • Stays in the session. The plugin keeps its headline, history, timeline and last flourish in the session's own state ($.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.
  • Only displays, never changes what Claude does. Every hook reads its event and passes it on as it came. It never allows, denies or changes a permission request; it only shows that one is waiting, and plays the chime if you turned it on. The chime is a file of the plugin's own (sounds/chime.wav), generated for it.
  • Shows what Claude is working on. The box and its history display your prompts, file paths, URLs, the descriptions of commands (or the command itself when it has none), MCP tool names and, where the model streams it, a line of its thinking. Anyone who can see your screen can read them. Labels are cleaned of control and invisible characters before they are drawn.

Develop

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.

FileWhat it does
hooks/register.tsxThe hooks, as in the original, plus the settings, the timeline, the flourishes and the chime; it hands the drawing to draw.tsx
hooks/draw.tsxThe box: one renderer for the terminal, one for the desktop and every surface that draws SVG
hooks/pulse.tsxThe terminal's animations, each a fixed-width Client on its own frame clock: the spinner and the sparkle
hooks/jelly.tsSVG for the desktop: the jellyfish, the strip and the bars, animated by CSS
hooks/palette.tsThe palettes, timer heat, per-agent colors
hooks/glyphs.tsTool glyphs, spinners, meters and bars
hooks/activity-of.tsTurns a tool call, whole or still streaming, into its short label (the original's)
hooks/text.tsPrintable labels, thought snippets, word counts, sizes, durations (the original's)
types/index.d.tsThe $.state contract
tests/register.test.tsxThe original's tests: a whole turn state by state, the history, hostile labels, approvals, agents
tests/pulse.test.tsxEvery animation level on both surfaces, the flash, the breath, the sparkle, the jellyfish's stable source
DESIGN.mdWhy each addition is built the way it is, and what was turned down

License

MIT. Copyright (c) 2026 Tzafrir Rehan, and afterever for the Agent Pulse changes.

Source 9 files
hooks/register.tsx 970 lines
1import { 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}
970
hooks/activity-of.ts 97 lines
1import { 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}
97
hooks/draw.tsx 534 lines
1import 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}
534
hooks/glyphs.ts 93 lines
1import 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}
93
hooks/palette.ts 169 lines
1import 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}
169
hooks/text.ts 99 lines
1/** 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}
99
hooks/pulse.tsx 63 lines
1import 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
63
hooks/jelly.ts 219 lines
1import 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}
219
types/index.d.ts 86 lines
1/**
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