SLOPSHOPPER

What's Agent Doing

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…

newbandguardtimeragents
★ 4v0.4.3MITupdated 2026-10-04tzafrir/whats-agent-doing
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · whats-agent-doing
› 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 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▸ ○ Claude: Idle · last turn done in 0.7s, 9 actions │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

What's Agent Doing: each step of a bug fix, live above the Claude Code prompt

What's Agent Doing

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.

Why

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:

  • what it is trying to do right now,
  • what that command is for,
  • whether it's stuck or just slow.

A bug fix, step by step

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 runsThe 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/cartReset 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 wonderWithoutWith the box
Is it stuck?A spinner and a long command lineThe step in plain English, and how long it has been on it
What is that command for?Decode the flags yourselfThe 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.

Install

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.

What it shows

StateExample
Reading the promptReading your prompt
Thinking: the thought's latest sentence where the model streams it, else the last action with the dot turned magentaThinking: …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 toolReading app.ts, Find the commit that broke fixed-amount coupons (a command's own description), Fetching github.com
A subagent at workRunning an agent: find the bug › Reading app.ts
Background agents, during a turn or after it3 agents working, with a row per agent: ◆ Draft the plan › Searching the web 1m 12s
Waiting on youWaiting for your approval: Reset the bisect and rerun the cart tests, Waiting for your answer
Reading tool resultsReviewing the results of 3 actions
Writing the replyWriting the reply · 120 words
CompactingCompacting the conversation
Between turnsIdle · 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.

Data and privacy

  • Stays in the session. The plugin keeps its headline and history 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: $.clock, $.state and $.ui.
  • Only displays, never changes what Claude does. Every hook is display only and changes nothing: 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.
  • Only displays permission prompts, never answers them. The plugin hooks 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.
  • 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, or a screen share, can read them. Labels are cleaned of control and invisible characters before they are drawn.

Read the full privacy policy.

Support

Report a problem or ask for a feature in GitHub Issues.

Develop

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.

FileWhat it does
hooks/register.tsxThe 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.tsTurns a tool call, whole or still streaming, into its short label
hooks/text.tsPrintable labels, thought snippets, word counts, sizes, durations
types/index.d.tsThe $.state contract: the headline, the agents at work, the history, the toggle
tests/register.test.tsxIdle, 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.jsonMakes this repository its own one-plugin marketplace
.claude-plugin/icon.pngThe listing icon: the box and its status dot above the prompt

License

MIT

Source 4 files
hooks/register.tsx 948 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  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 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 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}
97
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
types/index.d.ts 58 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}
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