A small box above the prompt that always says what the agent is doing right now (reading, thinking, writing, running a tool, waiting for you) and expands to…


See what Claude Code is doing at every step, in plain English, live.
A box above the prompt names the step Claude is on and how long it has been on it. Click ▸ to see every step it has taken.
With today's models, Claude goes into deep work: bisects, one-off scripts, long chains of flags. For minutes at a time you see a spinner and commands scrolling past, and you can't easily tell:
The cart total is wrong when a coupon is applied. Fix it.
To track this down, Claude runs commands you'd have to stop and decode. The box says what each one is for, and how long it has been running:
| Claude runs | The box says |
|---|---|
npx tsx -e "import {cartTotal} from './src/cart'; console.log(cartTotal({…coupon:{amount:10},taxRate:0.08}))" | Reproduce the total with a $10 coupon |
git bisect start HEAD v2.3.0 && git bisect run npx vitest run src/cart -t "fixed coupon" | Find the commit that broke fixed-amount coupons · 1m 48s |
git bisect reset && npx vitest run src/cart | Reset the bisect and rerun the cart tests |
The bisect runs for almost two minutes. The box shows it is bisecting, not stuck.
Click ▸ for every step and how it went:
╭─────────────────────────────────────────────────────────────╮
│ ▾ ○ Claude: Idle · last turn done in 2m 40s, 5 actions │
│ │
│ › The cart total is wrong when a coupon is applied. Fix it. │
│ ✓ Reading cart.ts 0.6s │
│ ✓ Reproduce the total with a $10 coupon 2.1s │
│ ✓ Find the commit that broke fixed-amount coupons 1m 48s │
│ ✓ Editing cart.ts 0.6s │
│ ✓ Reset the bisect and rerun the cart tests 7.4s │
│ ✎ Wrote the reply (47 words) 5.8s │
│ ✓ Done in 2m 40s, 5 actions 2m 40s │
╰─────────────────────────────────────────────────────────────╯
| You wonder | Without | With the box |
|---|---|---|
| Is it stuck? | A spinner and a long command line | The step in plain English, and how long it has been on it |
| What is that command for? | Decode the flags yourself | The command's own description: Find the commit that broke fixed-amount coupons |
| What happened while I was away? | Scroll back and piece it together | ▸ history with ✓ done, ✗ failed, ⊘ denied, ■ interrupted, and durations |
Background agents get a row each, and keep it after your turn ends, while they're still working:
╭───────────────────────────────────────────────────────────────────╮
│ ▸ ● Claude: 2 agents working · 34s │
│ ◆ Find callers of applyCoupon › Searching for "applyCoupon(" 34s │
│ ◆ Check the coupon tests › Reading coupon.test.ts 33s │
╰───────────────────────────────────────────────────────────────────╯
The plugin is free and MIT-licensed. It only displays: no network, no files, no telemetry. It is one of the first mods, built on Claude Code's new function hooks.
In Claude Code:
/plugin marketplace add tzafrir/whats-agent-doing
/plugin install whats-agent-doing@tzafrir
Or from your shell:
claude plugin marketplace add tzafrir/whats-agent-doing
claude plugin install whats-agent-doing@tzafrir
Then start a new session (or run /reload-plugins). Update later with claude plugin marketplace update tzafrir.
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. The box is drawn in the band above the prompt; hooks are not loaded in Claude chat or Cowork.
| State | Example |
|---|---|
| Reading the prompt | Reading your prompt |
| Thinking: the thought's latest sentence where the model streams it, else the last action with the dot turned magenta | Thinking: …the hook needs to await next |
| Writing a tool call, once its input says what it does (a file's path, a command's description) | Writing register.tsx · 2.3 KB |
| Running a tool | Reading app.ts, Find the commit that broke fixed-amount coupons (a command's own description), Fetching github.com |
| A subagent at work | Running an agent: find the bug › Reading app.ts |
| Background agents, during a turn or after it | 3 agents working, with a row per agent: ◆ Draft the plan › Searching the web 1m 12s |
| Waiting on you | Waiting for your approval: Reset the bisect and rerun the cart tests, Waiting for your answer |
| Reading tool results | Reviewing the results of 3 actions |
| Writing the reply | Writing the reply · 120 words |
| Compacting | Compacting the conversation |
| Between turns | Idle · last turn done in 42s, 6 actions (or interrupted, stopped on an error, declined) |
Parallel calls show the newest with (+N more), and a timer counts how long the current state has lasted.
Click the triangle (or focus the band with ctrl+x tab and press Enter) to expand the history: each prompt, stretch of thinking, tool call (✓ done, ✗ failed, ⊘ denied, ■ interrupted), reply and turn ending, with durations. The box steps aside while a survey holds the band; collapse the whole band with ctrl+x ctrl+a. Other mods that draw above the prompt keep their place: their boxes stack under this one.
$.state) and nothing else: no files, no network, no processes, no storage across sessions. claude plugin validate lists every call it makes: $.clock, $.state and $.ui.session.start, turn.start, turn.step (the model's stream), tool.call, classic.PermissionRequest, classic.SubagentStop, classic.PreCompact, classic.PostCompact and turn.complete each read their event and pass it on as it came. Settings, instructions, tool descriptions, other hooks' results, tool calls and their results, and the model's stream all reach Claude exactly as they would without the plugin.classic.PermissionRequest, which runs each time a tool call, Claude's or a subagent's, needs your approval. The hook is display only and decides nothing: it never allows, denies or changes the request, adds no permission rules, and passes the request on as it came. Its one job is to show Waiting for your approval: <tool> in the box until you answer.Read the full privacy policy.
Report a problem or ask for a feature in GitHub Issues.
Load the plugin from a clone; the session reloads it as you edit:
git clone https://github.com/tzafrir/whats-agent-doing
claude --plugin-dir whats-agent-doing
claude plugin validate .
claude plugin test .
Loading the plugin writes the API's types to .claude-plugin/types/, which tsconfig.json extends; then npx -p typescript tsc -p . typechecks it.
| File | What it does |
|---|---|
hooks/register.tsx | The hooks: the model's stream (turn.step), tools (tool.call), approvals (classic.PermissionRequest), subagents (agent.spawn, their calls and stops), compaction, turns, and the box's drawing (ui.render on AbovePrompt) |
hooks/activity-of.ts | Turns a tool call, whole or still streaming, into its short label |
hooks/text.ts | Printable labels, thought snippets, word counts, sizes, durations |
types/index.d.ts | The $.state contract: the headline, the agents at work, the history, the toggle |
tests/register.test.tsx | Idle, a whole turn state by state, the history toggle, thinking and commands without repeats, hostile labels, an approval wait, background agents after the turn, another mod's band kept beneath |
.claude-plugin/marketplace.json | Makes this repository its own one-plugin marketplace |
.claude-plugin/icon.png | The listing icon: the box and its status dot above the prompt |
MIT
hooks/register.tsx 948 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, Timer, TurnCompleteReason, TurnStepChunk } from 'claude-code'
3
4import type {
5 ActivityAgent,
6 ActivityEntry,
7 ActivityNow,
8 ActivityOutcome,
9 ActivityPhase,
10} from '../types'
11import { activityOf, partialArgsOf } from './activity-of'
12import { clip, durationOf, oneLine, plural, printable, sizeOf, thoughtOf, wordsAdded } from './text'
13
14const IDLE: ActivityNow = {
15 phase: 'idle',
16 label: 'Idle, waiting for your prompt',
17 sinceMs: 0,
18}
19
20const MAX_HISTORY = 100
21const MAX_HISTORY_ROWS = 12
22const MAX_THOUGHT_CHARS = 70
23const MAX_THOUGHT_BUFFER = 2000
24const MAX_INPUT_HEAD = 4000
25const LARGE_INPUT_CHARS = 1024
26const MAX_LABEL_CHARS = 120
27const MAX_AGENT_NAME_CHARS = 40
28const MAX_AGENT_ROWS = 6
29const STREAM_THROTTLE_MS = 250
30const TICK_MS = 1000
31
32const now = atom({ plugin: 'whats-agent-doing', key: 'now' } as const, IDLE)
33
34const history = atom(
35 { plugin: 'whats-agent-doing', key: 'history' } as const,
36 [] as readonly ActivityEntry[],
37)
38
39const team = atom(
40 { plugin: 'whats-agent-doing', key: 'agents' } as const,
41 [] as readonly ActivityAgent[],
42)
43
44const isExpanded = atom(
45 { plugin: 'whats-agent-doing', key: 'isExpanded' } as const,
46 false,
47)
48
49const PHASE_COLORS: Record<ActivityPhase, string> = {
50 idle: 'gray',
51 requesting: 'cyan',
52 thinking: 'magenta',
53 writing: 'green',
54 composing: 'blue',
55 tool: 'cyan',
56 agent: 'cyan',
57 approval: 'yellow',
58 question: 'yellow',
59 compacting: 'blue',
60}
61
62const OUTCOME_MARKS: Record<ActivityOutcome, { mark: string; color: string }> = {
63 ok: { mark: '✓', color: 'green' },
64 error: { mark: '✗', color: 'red' },
65 denied: { mark: '⊘', color: 'yellow' },
66 interrupted: { mark: '■', color: 'gray' },
67}
68
69const KIND_MARKS: Partial<Record<ActivityEntry['kind'], { mark: string; color: string }>> = {
70 turn: { mark: '›', color: 'white' },
71 thought: { mark: '∴', color: 'magenta' },
72 reply: { mark: '✎', color: 'green' },
73 compact: { mark: '⇣', color: 'blue' },
74}
75
76/** What the model is doing in the main loop, between and around tool calls. */
77type ModelPhase = 'idle' | 'requesting' | 'thinking' | 'writing' | 'composing' | 'compacting'
78
79/** A main-loop tool call in flight. */
80type Call = {
81 tool: string
82 label: string
83 startMs: number
84 isAwaitingApproval: boolean
85}
86
87/**
88 * A subagent at work, from its spawn (or its first call, when the spawn came
89 * before a reload) to its stop. `toolUseId` names the Agent call that started
90 * it; `activeMs` is when it last did something.
91 */
92type Agent = {
93 name: string
94 label: string
95 startMs: number
96 activeMs: number
97 toolUseId: string | null
98 isBackground: boolean
99}
100
101/**
102 * The module's live view of the session, from which the headline is drawn:
103 * started over on a reload, while the headline and history in `$.state` stay.
104 */
105type Live = {
106 calls: Map<string, Call>
107 agents: Map<string, Agent>
108 agentNames: Map<string, string>
109 described: Map<string, string>
110 phase: ModelPhase
111 phaseStartMs: number
112 turnStartMs: number | null
113 turnActions: number
114 isFirstRequest: boolean
115 resultsToReview: number
116 thought: string
117 thoughtWords: number
118 isThoughtInWord: boolean
119 replyWords: number
120 isReplyInWord: boolean
121 composing: { tool: string; head: string; chars: number } | null
122 lastAction: string | null
123 lastTurn: string | null
124 lastPublishMs: number
125 trailing: Timer | null
126 isSweeping: boolean
127 ticker: Timer | null
128}
129
130/**
131 * Registers What's Agent Doing: a box above the prompt naming what Claude is
132 * doing right now, and, behind its triangle, what it has done.
133 *
134 * The model's own stream (`turn.step`) says whether it is reading, thinking
135 * (the thought's latest sentence), writing the reply or composing a tool's
136 * input; `tool.call` says which tool runs, `classic.PermissionRequest` when
137 * one waits for approval, and a subagent's calls show under its Agent call.
138 *
139 * @param on the engine's registrar
140 */
141export const register: Register = on => {
142 const live: Live = {
143 calls: new Map(),
144 agents: new Map(),
145 agentNames: new Map(),
146 described: new Map(),
147 phase: 'idle',
148 phaseStartMs: 0,
149 turnStartMs: null,
150 turnActions: 0,
151 isFirstRequest: false,
152 resultsToReview: 0,
153 thought: '',
154 thoughtWords: 0,
155 isThoughtInWord: false,
156 replyWords: 0,
157 isReplyInWord: false,
158 composing: null,
159 lastAction: null,
160 lastTurn: null,
161 lastPublishMs: 0,
162 trailing: null,
163 isSweeping: false,
164 ticker: null,
165 }
166
167 on('session.start', async ($, e, next) => {
168 const result = await next(e)
169
170 await publish($, live)
171
172 return result
173 })
174
175 on('turn.start', async ($, e, next) => {
176 if (live.turnStartMs !== null) {
177 return next(e)
178 }
179
180 live.turnStartMs = await $.clock.now()
181 live.turnActions = 0
182 live.isFirstRequest = true
183 live.resultsToReview = 0
184 live.lastAction = null
185 live.calls.clear()
186
187 const prompt = oneLine(e.text)
188
189 if (prompt !== '') {
190 await remember($, { kind: 'turn', label: clip(prompt, 60), durationMs: null, outcome: 'ok' })
191 }
192
193 await enter($, live, 'requesting')
194
195 keepTicking($, live)
196 await publish($, live)
197
198 return next(e)
199 })
200
201 on('turn.step', async function* ($, e, next) {
202 if (e.agentId !== undefined) {
203 return yield* next(e)
204 }
205
206 await enter($, live, 'requesting')
207 await publish($, live)
208
209 try {
210 for await (const chunk of next(e)) {
211 await follow($, live, chunk)
212 yield chunk
213 }
214 } finally {
215 live.isFirstRequest = false
216 live.composing = null
217 await enter($, live, 'requesting')
218 await publish($, live)
219 }
220 })
221
222 on('tool.call', async ($, e, next) => {
223 const label = activityOf(e.tool, e as unknown as Record<string, unknown>)
224
225 if (e.agentId !== undefined) {
226 const agent = await agentOf($, live, e.agentId)
227
228 // Kept after the call: the agent's last action stands while it thinks.
229 agent.label = label
230 agent.activeMs = await $.clock.now()
231 await publish($, live, false)
232
233 try {
234 return await next(e)
235 } finally {
236 agent.label = label
237 await publish($, live, false)
238 }
239 }
240
241 const startMs = await $.clock.now()
242
243 live.calls.set(e.tool_use_id, { tool: e.tool, label, startMs, isAwaitingApproval: false })
244 live.lastAction = label
245 live.turnActions += 1
246 await publish($, live)
247
248 let outcome: ActivityOutcome = 'interrupted'
249
250 try {
251 const result = await next(e)
252
253 outcome = result.deny !== undefined ? 'denied' : result.isError ? 'error' : 'ok'
254
255 if (isAgentTool(e.tool)) {
256 const args = e as unknown as Record<string, unknown>
257 const handle = args['name']
258 const description = args['description']
259
260 // A named agent runs as a teammate, which the engine lists by the
261 // call's `name`; the call's `description` is what to show for it.
262 if (typeof handle === 'string' && typeof description === 'string' && oneLine(description) !== '') {
263 live.described.set(handle, description)
264 void sweep($, live)
265 }
266
267 await launched($, live, result.result, description)
268 }
269
270 return result
271 } finally {
272 live.calls.delete(e.tool_use_id)
273 live.resultsToReview += 1
274
275 await remember($, {
276 kind: 'tool',
277 label,
278 durationMs: (await $.clock.now()) - startMs,
279 outcome,
280 })
281
282 await publish($, live)
283 }
284 })
285
286 on('classic.PermissionRequest', async ($, e, next) => {
287 if (e.agent_id !== undefined) {
288 const agent = await agentOf($, live, e.agent_id)
289
290 agent.label = `Waiting for your approval: ${agent.label}`
291 } else {
292 const waiting = [...live.calls.values()]
293 .reverse()
294 .find(call => call.tool === e.tool_name && !call.isAwaitingApproval)
295
296 if (waiting) {
297 waiting.isAwaitingApproval = true
298 }
299 }
300
301 await publish($, live)
302
303 return next(e)
304 })
305
306 // The run-in-background pill shows only under a call that is running, so
307 // its drawing means the person has answered the approval it waited on.
308 on('ui.render', { component: 'ToolProgress' }, ($, e, next) => {
309 const call = live.calls.get(e.props.tool_use_id)
310
311 if (call?.isAwaitingApproval) {
312 call.isAwaitingApproval = false
313 $.clock.after(0, () => void publish($, live))
314 }
315
316 return next(e)
317 })
318
319 on('agent.spawn', async ($, e, next) => {
320 const result = await next(e)
321
322 if (result.agentId !== undefined) {
323 const startMs = await $.clock.now()
324 const name = e.description || e.name || 'Agent'
325
326 live.agentNames.set(result.agentId, name)
327 live.agents.set(result.agentId, {
328 name,
329 label: 'Getting started',
330 startMs,
331 activeMs: startMs,
332 toolUseId: e.tool_use_id,
333 isBackground: e.background,
334 })
335 keepTicking($, live)
336 await publish($, live)
337 }
338
339 return result
340 })
341
342 on('classic.SubagentStop', async ($, e, next) => {
343 await finish($, live, e.agent_id, 'ok')
344
345 return next(e)
346 })
347
348 on('classic.PreCompact', async ($, e, next) => {
349 await enter($, live, 'compacting')
350 await publish($, live)
351
352 return next(e)
353 })
354
355 on('classic.PostCompact', async ($, e, next) => {
356 const durationMs = (await $.clock.now()) - live.phaseStartMs
357
358 live.phase = live.turnStartMs === null ? 'idle' : 'requesting'
359 await remember($, { kind: 'compact', label: 'Compacted the conversation', durationMs, outcome: 'ok' })
360 await publish($, live)
361
362 return next(e)
363 })
364
365 on('turn.complete', async ($, e, next) => {
366 if (e.agentId !== undefined) {
367 await finish($, live, e.agentId, outcomeOf(e.reason))
368
369 return next(e)
370 }
371
372 if (live.turnStartMs === null) {
373 return next(e)
374 }
375
376 const durationMs = (await $.clock.now()) - live.turnStartMs
377 const took = durationOf(durationMs)
378
379 const [label, outcome]: [string, ActivityOutcome] =
380 e.reason === 'aborted'
381 ? [`interrupted after ${took}`, 'interrupted']
382 : e.reason === 'error'
383 ? [`stopped on an error after ${took}`, 'error']
384 : e.reason === 'refusal'
385 ? ['declined the request', 'denied']
386 : [`done in ${took}, ${plural(live.turnActions, 'action')}`, 'ok']
387
388 await enter($, live, 'idle')
389 await remember($, { kind: 'end', label: capitalized(label), durationMs, outcome })
390
391 live.lastTurn = label
392 live.turnStartMs = null
393 live.calls.clear()
394
395 keepTicking($, live)
396 await publish($, live)
397
398 return next(e)
399 })
400
401 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402 if (e.props.hasSurvey) {
403 return next(e)
404 }
405
406 // The band is one site for every plugin: what the plugins beneath draw
407 // stays, under the box, so another mod's band is never hidden.
408 const below = await next(e)
409
410 const { Box, Text, Button } = $.ui.resolve(e)
411
412 const headline = await read($, now)
413 const entries = await read($, history)
414 const working = await read($, team)
415 const isOpen = await read($, isExpanded)
416 const nowMs = await $.clock.now()
417
418 const isWorking = headline.phase !== 'idle'
419 const color = PHASE_COLORS[headline.phase]
420 const elapsed = isWorking ? ` · ${durationOf(nowMs - headline.sinceMs)}` : ''
421
422 // One agent the headline names; more, or one beside the main loop's
423 // work, get a row each.
424 const isTeamListed = working.length > 1 || (working.length === 1 && headline.phase !== 'agent')
425 const listed = isTeamListed ? working.slice(-MAX_AGENT_ROWS) : []
426 const unlisted = isTeamListed ? working.length - listed.length : 0
427
428 const agentRows = listed.map((agent, i) => (
429 <Box key={`agent-${i}`} flexDirection="row">
430 <Box flexShrink={0}>
431 <Text color={PHASE_COLORS.agent}>{'◆ '}</Text>
432 </Box>
433 <Text bold wrap="truncate-end">{printable(agent.name, MAX_AGENT_NAME_CHARS)}</Text>
434 <Text wrap="truncate-end">{` › ${printable(agent.label, MAX_LABEL_CHARS)}`}</Text>
435 <Box flexShrink={0}>
436 <Text dimColor>{` ${durationOf(nowMs - agent.sinceMs)}`}</Text>
437 </Box>
438 </Box>
439 ))
440
441 const moreAgents = unlisted > 0 ? [<Text dimColor>{`… ${plural(unlisted, 'more agent')}`}</Text>] : []
442
443 const teamBox = isTeamListed
444 ? [
445 <Box key="agents" flexDirection="column">
446 {agentRows}
447 {moreAgents}
448 </Box>,
449 ]
450 : []
451
452 const teamRows = listed.length + moreAgents.length
453 const rows = Math.min(MAX_HISTORY_ROWS, Math.max(1, e.props.maxRows - 4 - teamRows))
454 const shown = entries.slice(-rows)
455 const hidden = entries.length - shown.length
456
457 const historyRows = shown.map((entry, i) => {
458 const { mark, color: markColor } = KIND_MARKS[entry.kind] ?? OUTCOME_MARKS[entry.outcome]
459 const isQuiet = entry.kind === 'thought' || entry.kind === 'end'
460 const took = entry.durationMs === null ? '' : ` ${durationOf(entry.durationMs)}`
461
462 return (
463 <Box key={`row-${i}`} flexDirection="row">
464 <Box flexShrink={0}>
465 <Text color={markColor}>{`${mark} `}</Text>
466 </Box>
467 <Text bold={entry.kind === 'turn'} dimColor={isQuiet} wrap="truncate-end">
468 {printable(entry.label, MAX_LABEL_CHARS)}
469 </Text>
470 <Box flexShrink={0}>
471 <Text dimColor>{took}</Text>
472 </Box>
473 </Box>
474 )
475 })
476
477 const earlier = hidden > 0 ? [<Text dimColor>{`… ${hidden} earlier`}</Text>] : []
478 const empty = entries.length === 0 ? [<Text dimColor>No actions yet</Text>] : []
479
480 const body = isOpen
481 ? [
482 <Box key="history" flexDirection="column" marginTop={1}>
483 {earlier}
484 {historyRows}
485 {empty}
486 </Box>,
487 ]
488 : []
489
490 return (
491 <Box flexDirection="column">
492 <Box
493 flexDirection="column"
494 borderStyle="round"
495 borderColor={isWorking ? color : 'gray'}
496 paddingX={1}
497 alignSelf="flex-start"
498 >
499 <Box key="activity" flexDirection="row">
500 <Button
501 key="toggle"
502 plain
503 label={isOpen ? '▾' : '▸'}
504 onPress={() => update($, isExpanded, open => !open)}
505 />
506 {/* On a narrow screen only the label gives way: the rest would wrap. */}
507 <Box flexShrink={0}>
508 <Text color={color}>{isWorking ? ' ● ' : ' ○ '}</Text>
509 <Text bold>Claude: </Text>
510 </Box>
511 <Text wrap="truncate-end">{printable(headline.label, MAX_LABEL_CHARS)}</Text>
512 <Box flexShrink={0}>
513 <Text dimColor>{elapsed}</Text>
514 </Box>
515 </Box>
516 {teamBox}
517 {body}
518 </Box>
519 {below}
520 </Box>
521 )
522 })
523}
524
525/** The headline as the live view has it. */
526function current(live: Live): ActivityNow {
527 if (live.phase === 'compacting') {
528 return { phase: 'compacting', label: 'Compacting the conversation', sinceMs: live.phaseStartMs }
529 }
530
531 const open = [...live.calls.entries()]
532 const [newestId, newest] = open.at(-1) ?? []
533
534 if (newestId !== undefined && newest !== undefined) {
535 const more = open.length > 1 ? ` (+${open.length - 1} more)` : ''
536
537 if (newest.isAwaitingApproval) {
538 return {
539 phase: 'approval',
540 label: `Waiting for your approval: ${newest.label}${more}`,
541 sinceMs: newest.startMs,
542 }
543 }
544
545 if (newest.tool === 'AskUserQuestion') {
546 return { phase: 'question', label: newest.label, sinceMs: newest.startMs }
547 }
548
549 const inner = isAgentTool(newest.tool) ? agentUnder(live, newestId)?.label : undefined
550
551 return inner === undefined
552 ? { phase: 'tool', label: `${newest.label}${more}`, sinceMs: newest.startMs }
553 : { phase: 'agent', label: `${newest.label} › ${inner}${more}`, sinceMs: newest.startMs }
554 }
555
556 if (live.phase === 'idle' && live.agents.size > 0) {
557 // Between turns, background agents may still be at work.
558 const working = [...live.agents.values()]
559 const sinceMs = Math.min(...working.map(agent => agent.startMs))
560 const busiest = working.reduce((a, b) => (b.activeMs > a.activeMs ? b : a))
561
562 return {
563 phase: 'agent',
564 label: working.length === 1
565 ? `${busiest.name} › ${busiest.label}`
566 : `${plural(working.length, 'agent')} working`,
567 sinceMs,
568 }
569 }
570
571 const sinceMs = live.phaseStartMs
572
573 switch (live.phase) {
574 case 'requesting':
575 return {
576 phase: 'requesting',
577 label: live.isFirstRequest
578 ? 'Reading your prompt'
579 : live.resultsToReview > 0
580 ? `Reviewing the results of ${plural(live.resultsToReview, 'action')}`
581 : 'Waiting for the model',
582 sinceMs,
583 }
584 case 'thinking': {
585 // With no thought text to quote, the last action stands, the dot
586 // turned to thinking's color; before any, the prompt is what it reads.
587 const snippet = thoughtOf(live.thought, MAX_THOUGHT_CHARS)
588 const label = snippet ? `Thinking: ${snippet}` : standingLabel(live)
589
590 return { phase: 'thinking', label, sinceMs }
591 }
592 case 'writing':
593 return {
594 phase: 'writing',
595 label: `Writing the reply · ${plural(live.replyWords, 'word')}`,
596 sinceMs,
597 }
598 case 'composing': {
599 // A call is named once its input says what it does: a file tool by
600 // its path, a command by its description, which streams after the
601 // command itself. Until then the last action stands.
602 const { composing } = live
603 const args = composing ? partialArgsOf(composing.head) : {}
604 const isDescribed = composing !== null && (isShellTool(composing.tool)
605 ? typeof args['description'] === 'string'
606 : Object.keys(args).length > 0)
607
608 if (!composing || !isDescribed) {
609 return { phase: 'composing', label: standingLabel(live), sinceMs }
610 }
611
612 const size = composing.chars >= LARGE_INPUT_CHARS ? ` · ${sizeOf(composing.chars)}` : ''
613
614 return { phase: 'composing', label: `${activityOf(composing.tool, args)}${size}`, sinceMs }
615 }
616 case 'idle':
617 return live.lastTurn === null
618 ? IDLE
619 : { ...IDLE, label: `Idle · last turn ${live.lastTurn}` }
620 }
621}
622
623/**
624 * Writes the headline. A streaming update (`isUrgent` false) lands at most
625 * every STREAM_THROTTLE_MS, the last one held on a timer.
626 */
627async function publish($: EngineInterface, live: Live, isUrgent = true): Promise<void> {
628 const nowMs = await $.clock.now()
629
630 if (!isUrgent && nowMs - live.lastPublishMs < STREAM_THROTTLE_MS) {
631 live.trailing ??= $.clock.after(STREAM_THROTTLE_MS, () => {
632 live.trailing = null
633 void publish($, live)
634 })
635
636 return
637 }
638
639 live.lastPublishMs = nowMs
640 const headline = current(live)
641 const working = [...live.agents.values()].map(agent => ({
642 name: agent.name,
643 label: agent.label,
644 sinceMs: agent.startMs,
645 }))
646
647 await update($, now, () => headline)
648 await update($, team, () => working)
649}
650
651/** The agent at work under `agentId`, taken up if its spawn came before a reload. */
652async function agentOf($: EngineInterface, live: Live, agentId: string): Promise<Agent> {
653 const known = live.agents.get(agentId)
654
655 if (known) {
656 return known
657 }
658
659 const startMs = await $.clock.now()
660 const agent: Agent = {
661 name: live.agentNames.get(agentId) ?? 'Agent',
662 label: 'Getting started',
663 startMs,
664 activeMs: startMs,
665 toolUseId: null,
666 isBackground: true,
667 }
668
669 live.agents.set(agentId, agent)
670 keepTicking($, live)
671
672 return agent
673}
674
675/**
676 * Lets an agent go. A background agent's run is recorded in the history; a
677 * foreground one's is its Agent call's row.
678 */
679async function finish(
680 $: EngineInterface,
681 live: Live,
682 agentId: string,
683 outcome: ActivityOutcome,
684): Promise<void> {
685 const agent = live.agents.get(agentId)
686
687 if (!agent) {
688 return
689 }
690
691 live.agents.delete(agentId)
692
693 if (agent.isBackground) {
694 await remember($, {
695 kind: 'tool',
696 label: `Agent: ${agent.name}`,
697 durationMs: (await $.clock.now()) - agent.startMs,
698 outcome,
699 })
700 }
701
702 keepTicking($, live)
703 await publish($, live)
704}
705
706/**
707 * Names agents first met by their calls, and lets go of agents the engine
708 * lists as no longer running, in case their stop never reached the box.
709 */
710async function sweep($: EngineInterface, live: Live): Promise<void> {
711 if (live.agents.size === 0 || live.isSweeping) {
712 return
713 }
714
715 live.isSweeping = true
716
717 try {
718 await sweepListed($, live, await $.agent.list())
719 } catch {
720 // The list is a backstop; the box keeps going without it.
721 } finally {
722 live.isSweeping = false
723 }
724}
725
726async function sweepListed($: EngineInterface, live: Live, listed: readonly AgentInfo[]): Promise<void> {
727 const statuses = new Map(listed.map(info => [info.id, info.status]))
728 let isRenamed = false
729
730 for (const [agentId, agent] of [...live.agents.entries()]) {
731 const status = statuses.get(agentId)
732
733 // An agent first met by its calls is named once the engine lists it.
734 if (!live.agentNames.has(agentId)) {
735 const handle = listed.find(info => info.id === agentId)?.name
736 const described = handle === undefined ? undefined : live.described.get(handle)
737
738 if (described !== undefined) {
739 live.agentNames.set(agentId, described)
740 }
741
742 const name = described ?? handle
743
744 if (name !== undefined && name !== agent.name) {
745 agent.name = name
746 isRenamed = true
747 }
748 }
749
750 if (status !== undefined && status !== 'running') {
751 await finish($, live, agentId, status === 'completed' ? 'ok' : status === 'failed' ? 'error' : 'interrupted')
752 }
753 }
754
755 if (isRenamed) {
756 await publish($, live)
757 }
758}
759
760/**
761 * Keeps the elapsed time ticking while a turn runs or an agent works, and
762 * stops it once neither does.
763 */
764function keepTicking($: EngineInterface, live: Live): void {
765 const isBusy = live.turnStartMs !== null || live.agents.size > 0
766
767 if (isBusy && live.ticker === null) {
768 live.ticker = $.clock.every(TICK_MS, () => {
769 $.ui.invalidate('ui.render')
770 void sweep($, live)
771 })
772 } else if (!isBusy && live.ticker !== null) {
773 live.ticker.cancel()
774 live.ticker = null
775 }
776}
777
778async function remember($: EngineInterface, entry: ActivityEntry): Promise<void> {
779 await update($, history, entries => [...entries, entry].slice(-MAX_HISTORY))
780}
781
782/** Enters a model phase, recording the thinking or reply block it closes. */
783async function enter($: EngineInterface, live: Live, next: ModelPhase): Promise<void> {
784 if (next === live.phase) {
785 return
786 }
787
788 const nowMs = await $.clock.now()
789 const durationMs = nowMs - live.phaseStartMs
790
791 if (live.phase === 'thinking') {
792 const words = live.thoughtWords
793
794 await remember($, {
795 kind: 'thought',
796 label: words > 0 ? `Thought (${plural(words, 'word')})` : 'Thought',
797 durationMs,
798 outcome: 'ok',
799 })
800 }
801
802 if (live.phase === 'writing' && live.replyWords > 0) {
803 await remember($, {
804 kind: 'reply',
805 label: `Wrote the reply (${plural(live.replyWords, 'word')})`,
806 durationMs,
807 outcome: 'ok',
808 })
809 }
810
811 if (next === 'thinking') {
812 live.thought = ''
813 live.thoughtWords = 0
814 live.isThoughtInWord = false
815 }
816
817 if (next === 'writing') {
818 live.replyWords = 0
819 live.isReplyInWord = false
820 }
821
822 live.phase = next
823 live.phaseStartMs = nowMs
824}
825
826/** Follows one chunk of the main loop's response. */
827async function follow($: EngineInterface, live: Live, chunk: TurnStepChunk): Promise<void> {
828 switch (chunk.kind) {
829 case 'thinking': {
830 const isNew = live.phase !== 'thinking'
831
832 await enter($, live, 'thinking')
833 live.resultsToReview = 0
834 const { added, isInWord } = wordsAdded(chunk.text, live.isThoughtInWord)
835
836 live.thought = (live.thought + chunk.text).slice(-MAX_THOUGHT_BUFFER)
837 live.thoughtWords += added
838 live.isThoughtInWord = isInWord
839 await publish($, live, isNew)
840
841 return
842 }
843 case 'text': {
844 const isNew = live.phase !== 'writing'
845
846 await enter($, live, 'writing')
847 live.resultsToReview = 0
848 const { added, isInWord } = wordsAdded(chunk.text, live.isReplyInWord)
849
850 live.replyWords += added
851 live.isReplyInWord = isInWord
852 await publish($, live, isNew)
853
854 return
855 }
856 case 'tool':
857 await enter($, live, 'composing')
858 live.resultsToReview = 0
859 live.composing = { tool: chunk.name, head: '', chars: 0 }
860 await publish($, live)
861
862 return
863 case 'input': {
864 const { composing } = live
865
866 if (composing) {
867 composing.chars += chunk.json.length
868
869 if (composing.head.length < MAX_INPUT_HEAD) {
870 composing.head += chunk.json
871 }
872
873 await publish($, live, false)
874 }
875
876 return
877 }
878 }
879}
880
881/**
882 * What the box keeps saying while Claude thinks or writes a call it cannot
883 * name yet: the turn's last action, or the prompt before there is one.
884 */
885function standingLabel(live: Live): string {
886 return live.lastAction ?? 'Reading your prompt'
887}
888
889/**
890 * Names a background agent by the Agent call that launched it: the call's
891 * short `description`, keyed by the id its result carries (the result's own
892 * `description` can hold the whole prompt). Its own calls may already have
893 * shown it.
894 */
895async function launched(
896 $: EngineInterface,
897 live: Live,
898 result: unknown,
899 asked: unknown,
900): Promise<void> {
901 if (typeof result !== 'object' || result === null) {
902 return
903 }
904
905 const { agentId, description, status } = result as Record<string, unknown>
906
907 if (typeof agentId !== 'string' || status !== 'async_launched') {
908 return
909 }
910
911 const name = [asked, description].find(
912 (text): text is string => typeof text === 'string' && oneLine(text) !== '',
913 )
914
915 if (name === undefined) {
916 return
917 }
918
919 const agent = await agentOf($, live, agentId)
920
921 live.agentNames.set(agentId, name)
922 agent.name = name
923 await publish($, live)
924}
925
926/** The agent an Agent call started, or the one that last did something. */
927function agentUnder(live: Live, toolUseId: string): Agent | undefined {
928 const working = [...live.agents.values()]
929
930 return working.find(agent => agent.toolUseId === toolUseId)
931 ?? working.reduce<Agent | undefined>((a, b) => (a === undefined || b.activeMs > a.activeMs ? b : a), undefined)
932}
933
934function outcomeOf(reason: TurnCompleteReason): ActivityOutcome {
935 return reason === 'aborted' ? 'interrupted' : reason === 'error' ? 'error' : reason === 'refusal' ? 'denied' : 'ok'
936}
937
938function isShellTool(tool: string): boolean {
939 return tool === 'Bash' || tool === 'PowerShell'
940}
941
942function isAgentTool(tool: string): boolean {
943 return tool === 'Agent' || tool === 'Task'
944}
945
946function capitalized(text: string): string {
947 return text.charAt(0).toUpperCase() + text.slice(1)
948}hooks/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 What's Agent Doing.
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/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}
99types/index.d.ts 58 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}
24
25/**
26 * A subagent at work: what it was asked to do, its latest action, and when
27 * it started.
28 */
29export type ActivityAgent = {
30 name: string
31 label: string
32 sinceMs: number
33}
34
35export type ActivityOutcome = 'ok' | 'error' | 'denied' | 'interrupted'
36
37/**
38 * One row of the expanded box: a prompt that started a turn, a stretch of
39 * thinking, a reply, a tool call, a compaction, or how a turn ended.
40 */
41export type ActivityEntry = {
42 kind: 'turn' | 'thought' | 'reply' | 'tool' | 'compact' | 'end'
43 label: string
44 durationMs: number | null
45 outcome: ActivityOutcome
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'whats-agent-doing': {
51 now: ActivityNow
52 history: readonly ActivityEntry[]
53 agents: readonly ActivityAgent[]
54 isExpanded: boolean
55 }
56 }
57}
58