A box above the prompt that says what Claude is doing right now (reading, thinking, running a tool, waiting for you) in tail-claude-hud's icons and colors, and…

A box above the Claude Code prompt that says what Claude is doing right now, drawn with tail-claude-hud's icons and colors. Press the triangle to see every step it has taken.
╭────────────────────────────────────────────────────────────────╮
│ ▾ Idle · last turn done in 12s, 2 actions │
│ │
│ › Run the shell command `sleep 5 && echo done` with the Bash … │
│ Read VERSION 0.4s │
│ Wait five seconds 5.7s │
│ Wrote the reply (11 words) <0.1s │
│ Done in 12s, 2 actions │
╰────────────────────────────────────────────────────────────────╯
It is a Claude Code mod: a plugin of function hooks, which are early access and load only where function hooks are enabled. It replaces whats-agent-doing with the same states and history, restyled to match the statusline.
/plugin install tch-activity --marketplace kylesnowschwartz/tail-claude-hud
Answer y to add the marketplace, then pick a scope. If whats-agent-doing is installed too, disable it (claude plugin disable whats-agent-doing@tzafrir) or both boxes stack above the prompt.
At session start the mod runs tail-claude-hud style --json once, in the background, and keeps the answer for the session; the box draws with the built-in look until the answer arrives. That output carries the HUD's resolved icons (Nerd Font, unicode or ascii, per your [style] icons), its semantic colors, the agent palette and the tool categories, so the box draws with whatever your HUD config draws with.
The binary is found in this order:
binary option (/plugin → tch-activity → configure, or pluginConfigs."tch-activity".options.binary in settings).~/go/bin/tail-claude-hud, where go install puts it. A Claude Code session runs with the login PATH, which often lacks Go's bin folder.tail-claude-hud on the session's PATH.If your statusLine command passes --preset (for example tail-claude-hud --preset powerline), set the preset option to the same value so the box reads the same look. The HUD's automatic light theme for powerline layouts on a light terminal background is not part of the export.
The box uses the HUD's state colors (running, thinking, needs you, success, error, muted) and its agent palette. The per-widget colors in your theme file (theme-active.toml) are not used.
If the binary is missing, too old to have style --json, or prints something unreadable, the box draws with a built-in unicode look (✓ ✗ ⧗ ≡ $ ◆ ∴ ❝) in the HUD's default colors. Its icons are the HUD's own icons = "unicode" set: one-column symbols, no emoji (see Style). The expanded history then ends with one dim line saying why. Nothing else is reported. The mod asks again after a reload or an option change, so a newer binary is picked up without a new session.
| State | Headline | ||
|---|---|---|---|
| Before any turn | Idle, waiting for your prompt (clock icon, muted) | ||
| Reading the prompt | Reading your prompt | ||
| Thinking | Thinking: …the hook needs to await next (the thought's latest sentence), or Thinking while none has streamed | ||
| Writing a tool call | Preparing the next action until its input says what it does, then Run the tests, ` Writing register.tsx \ | 2.3 KB` | |
| Running a tool | the tool's category icon in the running color, then its label: Wait five seconds, Reading app.ts, Fetching example.org | ||
| Parallel calls | the newest, then (+N more) | ||
| A subagent at work | ` Running an agent: find the bug \ | Reading app.ts` | |
| Background agents | 2 agents working, and a row per agent under the headline label: ` Draft the plan \ | Searching the web \ | 1m 12s`. Each agent's icon and name take the color the HUD's agents widget gives it: its Agent call's position among the session's last 10, wrapped at the palette's 8 colors. |
| Waiting on you | Waiting for your approval: Run the tests, Waiting for your answer (permission icon and color). In the terminal the approval dialog takes the band's place while it is up; a subagent's row says it waits until its call runs. | ||
| 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 the request, stopped at a declined call; the glyph is the turn's outcome |
Labels are drawn in the default text color. A state's color is on its glyph and on the border: yellow while Claude works or thinks, red while it or a subagent waits on you, muted grey when idle. Numbers, timers and separators are faint. A timer after the label (| 4.2s) counts how long the current state has lasted, in tenths of a second under ten seconds; a tool's timer leaves out the time its approval dialog was up. The box widens to fit its widest row and keeps that width for the rest of the session.
Focus the band with ctrl+x tab and press Enter, or click the triangle, to expand the history: each prompt (›), stretch of thinking, tool call, reply and turn ending, with durations. A tool row is in the past tense and has one glyph, as the HUD's tools widget draws a finished tool: the tool's category icon in the success color, or the error color when it failed, as in Read VERSION 0.4s. A call you declined shows the HUD's declined icon and an interrupted one its interrupted icon (⊘ and ■ in the unicode set, both muted) in place of the category icon, so those rows never rely on color alone. A question to you shows the bell, and an agent's row the robot, under the same rule. Thought, reply and compaction (⇣) icons are faint. A background agent gets a Started an agent: row when it is launched and a Ran an agent: row when it finishes. The turn's last row says how it ended with its time in the label, Done in 12s, 2 actions, or ⊘ Stopped at a declined call after 7.1s when its last call was one you declined. A prompt the engine sends for you (<agent-message …>, <task-notification>) is listed by its tag, Agent message.
The box steps aside while a survey holds the band. Other mods that draw above the prompt keep their place beneath it.
The mod runs one local command (tail-claude-hud style --json) when a session starts, and again after a reload while the HUD's look is not in place; it reads HOME to find the binary. It keeps its headline, history and look in the session's own state ($.state). It makes no network calls.
It writes one file: while a call of the main conversation waits on your approval, ~/.config/tail-claude-hud/waiting/<session id> holds the session id, the working folder's name and the tool's name, the file the HUD's hook permission-request writes. Other sessions' status lines read it to show that this one waits on you. The mod removes it, by running /bin/rm -f on that path, as soon as you approve or decline the call and when the session ends or is cleared. A file it fails to remove is ignored by the HUD after two minutes.
Every hook passes its event on unchanged; it never answers a permission prompt.
The box shows your prompts, file names, URLs, command descriptions (or the command when it has none), MCP tool names and a line of the model's thinking where it streams. Labels are cleaned of control and invisible characters before they are drawn.
From the repository root:
just mod-dev # claude with this folder loaded (hot-reloads on save), whats-agent-doing off for the session
just mod-check # validate (mod and marketplace), run the tests, typecheck
just mod-dev builds this checkout's HUD binary to a temporary folder and points the mod at it, and passes --settings with "enabledPlugins":{"whats-agent-doing@tzafrir":false}, which turns an installed whats-agent-doing off for that session alone. Set TCH_HUD_BINARY=/path/to/tail-claude-hud to use another HUD binary. Loading the mod writes the API's types to .claude-plugin/types/ (git-ignored), which tsconfig.json extends. just mod-check typechecks only once that has happened; until then it validates and runs the tests, prints type check skipped: run 'just mod-dev' once to write the API types, and exits 0.
QA.md is a scripted checklist for driving the mod in tmux.
| File | What it does |
|---|---|
hooks/register.tsx | The hooks module: every hook, every function that takes $, the state atoms, the style load and the drawing. The engine follows $ and state references only within this file. |
hooks/live.ts | The live view of the session and the headline it yields (pure) |
hooks/marks.ts | The glyph and color for each state, outcome and history row (pure) |
hooks/style.ts | The built-in look, parsing style --json, and icon and color lookups (pure) |
hooks/activity-of.ts | A tool call, whole or streaming, as a short label |
hooks/text.ts | Printable labels, thought snippets, word counts, sizes, durations |
hooks/waiting.ts | The approval breadcrumb's path and contents (pure) |
types/index.d.ts | The $.state contract |
tests/ | The turn state by state, the history toggle, hostile labels, approvals, agents and their palette, the HUD look and its fallback |
MIT. Portions are adapted from whats-agent-doing by Tzafrir Rehan (MIT); see NOTICE.
hooks/register.tsx 1229 lines1// The tracking hooks are adapted from whats-agent-doing's register.tsx (MIT,
2// Copyright (c) 2026 Tzafrir Rehan); see NOTICE. The engine traces `$` and
3// state references only within the hooks module's own file, so every function
4// that takes `$` and every state atom lives here; the other files are pure.
5import { atom, read, update } from 'claude-code'
6import type {
7 AgentInfo,
8 EngineInterface,
9 PluginOptions,
10 Register,
11 RenderInput,
12 TurnStepChunk,
13} from 'claude-code'
14
15import type { ActivityAgent, ActivityEntry, ActivityOutcome, TchStyle, TchStyleSource } from '../types'
16import { activityOf, isAgentTool } from './activity-of'
17import {
18 agentLabel,
19 callOf,
20 colorIndexOf,
21 createLive,
22 current,
23 dropMainCalls,
24 endWait,
25 entryOf,
26 IDLE,
27 isAgentWaiting,
28 outcomeOf,
29 STARTING_LABEL,
30 turnEndLabel,
31 turnEndOf,
32 UNNAMED_AGENT,
33 type Agent,
34 type Call,
35 type Live,
36 type ModelPhase,
37 withAgentCall,
38 withAgentKey,
39} from './live'
40import { AGENT_INDENT_COLUMNS, bandLayout, boxColumnsOf, columnsOf, type Segment } from './layout'
41import { borderColor, entryMark, headlineMark, styleNote } from './marks'
42import {
43 agentColor,
44 BARE_BINARY,
45 BUILTIN_STYLE,
46 builtin,
47 GO_BIN_SUFFIX,
48 icon,
49 isStyleStale,
50 parseStyle,
51 type LoadedStyle,
52} from './style'
53import { breadcrumbOf, waitingPathOf } from './waiting'
54import { capitalized, durationOf, elapsedOf, oneLine, TENTHS_UNDER_MS, plural, printable, promptLabel, wordsAdded } from './text'
55
56/** The headline. */
57const now = atom({ plugin: 'tch-activity', key: 'now' } as const, IDLE)
58
59/** The expanded box's rows, oldest first. */
60const history = atom(
61 { plugin: 'tch-activity', key: 'history' } as const,
62 [] as readonly ActivityEntry[],
63)
64
65/** The subagents at work, in the order they were met. */
66const team = atom(
67 { plugin: 'tch-activity', key: 'agents' } as const,
68 [] as readonly ActivityAgent[],
69)
70
71/** The Agent calls (or agent ids) whose position picks each agent's color. */
72const agentOrder = atom({ plugin: 'tch-activity', key: 'agentOrder' } as const, [] as readonly string[])
73
74/** How the last main turn ended, for the idle headline after a reload. */
75const lastTurn = atom(
76 { plugin: 'tch-activity', key: 'lastTurn' } as const,
77 null as { label: string; outcome: ActivityOutcome } | null,
78)
79
80const isExpanded = atom({ plugin: 'tch-activity', key: 'isExpanded' } as const, false)
81
82/** The widest the box has been this session, in columns: it grows to fit and never shrinks. */
83const boxWidth = atom({ plugin: 'tch-activity', key: 'width' } as const, 0)
84
85/** The HUD's look, loaded once per session; `null` until then. */
86const styleState = atom({ plugin: 'tch-activity', key: 'style' } as const, null as TchStyle | null)
87
88const styleSource = atom(
89 { plugin: 'tch-activity', key: 'styleSource' } as const,
90 null as TchStyleSource | null,
91)
92
93/**
94 * Registers tch-activity: a box above the prompt naming what Claude is doing
95 * right now in tail-claude-hud's icons and colors, and, behind its triangle,
96 * what it has done.
97 *
98 * The model's stream (`turn.step`) says whether it is reading, thinking,
99 * writing the reply or composing a tool's input; `tool.call` which tool
100 * runs; `tool.check` when one is put to approval; `session.compact` when
101 * the conversation compacts; a subagent's calls carry its `agentId`. Every hook is display only: it reads
102 * its event and passes it on unchanged.
103 *
104 * @param on the engine's registrar
105 * @param options `userConfig` values; `binary` names the tail-claude-hud binary
106 */
107export const register: Register = (on, options) => {
108 const live = createLive()
109 const asked: StyleAsked = { isAsked: false, options }
110
111 on('session.start', async ($, e, next) => {
112 const result = await next(e)
113
114 ensureStyle($, asked)
115 await publish($, live)
116
117 return result
118 })
119
120 on('turn.start', async ($, e, next) => {
121 // A module loaded after the session started (a plugin folder added or
122 // edited mid-session) saw no `session.start`: its first turn loads the look.
123 ensureStyle($, asked)
124
125 // A prompt folded into a running turn starts no new one.
126 if (live.turnStartMs !== null) {
127 return next(e)
128 }
129
130 live.turnStartMs = await $.clock.now()
131 live.turnActions = 0
132 live.isDeclinedLast = false
133 live.isFirstRequest = true
134 live.resultsToReview = 0
135 dropMainCalls(live)
136
137 const prompt = promptLabel(e.text, MAX_PROMPT_CHARS)
138
139 if (prompt !== '') {
140 await remember($, entryOf('turn', prompt))
141 }
142
143 await enter($, live, 'requesting')
144
145 keepTicking($, live)
146 await publish($, live)
147
148 return next(e)
149 })
150
151 on('turn.step', async function* ($, e, next) {
152 if (e.agentId !== undefined) {
153 return yield* next(e)
154 }
155
156 // A module reloaded mid-turn missed the turn's start: it takes the turn
157 // up here, so the turn's end returns the box to idle.
158 if (live.turnStartMs === null) {
159 live.turnStartMs = await $.clock.now()
160 keepTicking($, live)
161 }
162
163 await enter($, live, 'requesting')
164 await publish($, live)
165
166 try {
167 for await (const chunk of next(e)) {
168 await follow($, live, chunk)
169 yield chunk
170 }
171 } finally {
172 live.isFirstRequest = false
173 live.composing = null
174 await enter($, live, 'requesting')
175 await publish($, live)
176 }
177 })
178
179 on('tool.call', async ($, e, next) => {
180 const args = e as unknown as Record<string, unknown>
181 const label = activityOf(e.tool, args)
182
183 if (e.agentId !== undefined) {
184 const agent = await agentOf($, live, e.agentId)
185 const startMs = await $.clock.now()
186
187 // Kept after the call: the agent's last action stands while it thinks.
188 agent.label = label
189 agent.activeMs = startMs
190 live.calls.set(e.tool_use_id, callOf(e.tool, label, startMs, e.agentId))
191 await publish($, live, false)
192
193 try {
194 return await next(e)
195 } finally {
196 live.calls.delete(e.tool_use_id)
197 await publish($, live, false)
198 }
199 }
200
201 const startMs = await $.clock.now()
202
203 if (isAgentTool(e.tool)) {
204 await rememberAgentKey($, live, e.tool_use_id)
205 }
206
207 const call = callOf(e.tool, label, startMs, null)
208
209 live.calls.set(e.tool_use_id, call)
210 live.turnActions += 1
211 await publish($, live)
212
213 // A call that never settles (the turn was interrupted) keeps this.
214 let outcome: ActivityOutcome = 'interrupted'
215 let done = activityOf(e.tool, args, 'past')
216
217 try {
218 const result = await next(e)
219
220 outcome = result.deny !== undefined ? 'denied' : result.isError ? 'error' : 'ok'
221
222 if (isAgentTool(e.tool)) {
223 const handle = args['name']
224 const description = args['description']
225
226 // A named agent runs as a teammate, which the engine lists by the
227 // call's `name`; the call's `description` is what to show for it.
228 if (typeof handle === 'string' && typeof description === 'string' && oneLine(description) !== '') {
229 live.described.set(handle, description)
230 void sweep($, live)
231 }
232
233 if (await launched($, live, result.result, description, e.tool_use_id)) {
234 done = `Started an agent: ${typeof description === 'string' ? oneLine(description) : ''}`
235 }
236 }
237
238 return result
239 } finally {
240 // A call put to approval that errors without ever being seen running
241 // was refused by its decider, which the engine reports as an error.
242 if (outcome === 'error' && call.askedMs !== null) {
243 outcome = 'denied'
244 }
245
246 // Decided before it was taken for a dialog: no wait of yours to leave out.
247 if (!call.isPutToYou) {
248 call.askedMs = null
249 }
250
251 // Still waiting as it ends: the dialog was declined or the turn stopped.
252 if (call.isAwaitingApproval) {
253 clearWaiting($, live, call)
254 }
255
256 const endMs = await $.clock.now()
257
258 endWait(call, endMs)
259 live.calls.delete(e.tool_use_id)
260 live.resultsToReview += 1
261 live.isDeclinedLast = outcome === 'denied'
262
263 await remember($, {
264 ...entryOf('tool', done, outcome),
265 durationMs: endMs - startMs - call.waitedMs,
266 tool: e.tool,
267 })
268
269 await publish($, live)
270 }
271 })
272
273 // An `ask` verdict puts the call to the mode's decider: the person's
274 // dialog, or the auto-mode classifier. Display only: the verdict passes on
275 // unchanged.
276 on('tool.check', async ($, e, next) => {
277 const verdict = await next(e)
278 const call = e.tool_use_id === undefined ? undefined : live.calls.get(e.tool_use_id)
279
280 if (verdict.decision === 'ask' && call !== undefined && call.askedMs === null && !call.isAwaitingApproval) {
281 call.askedMs = await $.clock.now()
282 $.clock.after(DIALOG_GRACE_MS, () => void putToYou($, live, call))
283 }
284
285 return verdict
286 }).catch(($, e, next) => next(e))
287
288 // A call's row is drawn running once it runs, or at once when a decider
289 // other than the dialog (the auto-mode classifier) reviews it: for a call
290 // that waited on you, it means you approved it.
291 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
292 if (e.props.isRunning) {
293 await approved($, live, e.props.tool_use_id)
294 }
295
296 return next(e)
297 })
298
299 // The run-in-background pill shows only under a running call: the same sign.
300 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
301 await approved($, live, e.props.tool_use_id)
302
303 return next(e)
304 })
305
306 on('agent.spawn', async ($, e, next) => {
307 const result = await next(e)
308
309 if (result.agentId !== undefined) {
310 const startMs = await $.clock.now()
311 const name = e.description || e.name || UNNAMED_AGENT
312
313 live.agentNames.set(result.agentId, name)
314 live.agents.set(result.agentId, {
315 name,
316 label: STARTING_LABEL,
317 startMs,
318 activeMs: startMs,
319 toolUseId: e.tool_use_id,
320 isBackground: e.background,
321 colorKey: e.tool_use_id || result.agentId,
322 })
323 await placeAgent($, live, result.agentId, e.tool_use_id)
324 keepTicking($, live)
325 await publish($, live)
326 }
327
328 return result
329 })
330
331 // A compaction of the main conversation, timed from the call to its
332 // answer. One made ahead of time (`precompute`) runs unseen in the
333 // background and is not shown.
334 on('session.compact', async ($, e, next) => {
335 if (e.agentId !== undefined || e.trigger === 'precompute') {
336 return next(e)
337 }
338
339 await enter($, live, 'compacting')
340 keepTicking($, live)
341 await publish($, live)
342
343 let outcome: ActivityOutcome = 'error'
344
345 try {
346 const result = await next(e)
347
348 outcome = result.skip === undefined ? 'ok' : 'denied'
349
350 return result
351 } finally {
352 const durationMs = (await $.clock.now()) - live.phaseStartMs
353 const label = outcome === 'ok' ? 'Compacted the conversation' : outcome === 'denied' ? 'Compaction skipped' : 'Compaction failed'
354
355 await enter($, live, live.turnStartMs === null ? 'idle' : 'requesting')
356 await remember($, { ...entryOf('compact', label, outcome), durationMs })
357 keepTicking($, live)
358 await publish($, live)
359 }
360 })
361
362 // Every end, a /clear's included, takes the ending session's breadcrumb
363 // with it; a /clear's new session id is not the one that waited.
364 on('session.end', async ($, e, next) => {
365 clearWaiting($, live, null, e.sessionId)
366
367 return next(e)
368 })
369
370 on('turn.complete', async ($, e, next) => {
371 if (e.agentId !== undefined) {
372 await finish($, live, e.agentId, outcomeOf(e.reason))
373
374 return next(e)
375 }
376
377 if (live.turnStartMs === null) {
378 return next(e)
379 }
380
381 const durationMs = (await $.clock.now()) - live.turnStartMs
382 const end = turnEndOf(e.reason, live.isDeclinedLast)
383 const outcome = outcomeOf(end)
384 const label = turnEndLabel(end, durationMs, live.turnActions)
385
386 await enter($, live, 'idle')
387 // The row says its time in its label, so it has no duration of its own.
388 await remember($, entryOf('end', capitalized(label), outcome))
389
390 live.lastTurn = { label, outcome }
391 await update($, lastTurn, () => live.lastTurn)
392 live.turnStartMs = null
393 dropMainCalls(live)
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 // A reload (an edit, a changed option) sees no session start: the first
407 // draw after it checks the look, off the draw.
408 ensureStyle($, asked)
409
410 // The band is one site for every plugin: what the plugins beneath draw
411 // stays, under the box, so another mod's band is never hidden.
412 const below = await next(e)
413 const { Box } = $.ui.resolve(e)
414
415 return (
416 <Box flexDirection="column">
417 {await drawBox($, e)}
418 {below}
419 </Box>
420 )
421 })
422}
423
424const MAX_HISTORY = 100
425
426const MAX_PROMPT_CHARS = 60
427
428const MAX_THOUGHT_BUFFER = 2000
429
430const MAX_INPUT_HEAD = 4000
431
432/** Streaming chunks arrive far faster than anyone reads; publish at most this often. */
433const STREAM_THROTTLE_MS = 250
434
435/** The running timer shows tenths under ten seconds; the box redraws this often then. */
436const TICK_MS = 100
437
438/** Past ten seconds the timer counts whole seconds: the box redraws, and checks on agents, once a second. */
439const TICKS_PER_SECOND = 10
440
441/**
442 * Writes the headline. A streaming update (`isUrgent` false) lands at most
443 * every STREAM_THROTTLE_MS, the last one held on a timer.
444 */
445async function publish($: EngineInterface, live: Live, isUrgent = true): Promise<void> {
446 const nowMs = await $.clock.now()
447
448 if (!isUrgent && nowMs - live.lastPublishMs < STREAM_THROTTLE_MS) {
449 live.trailing ??= $.clock.after(STREAM_THROTTLE_MS, () => {
450 live.trailing = null
451 void publish($, live)
452 })
453
454 return
455 }
456
457 live.lastPublishMs = nowMs
458 await restore($, live)
459
460 const headline = current(live)
461 const working: ActivityAgent[] = [...live.agents.entries()].map(([id, agent]) => ({
462 name: agent.name,
463 label: agentLabel(live, { ...agent, id }),
464 sinceMs: agent.startMs,
465 colorIndex: colorIndexOf(live, agent.colorKey),
466 isWaiting: isAgentWaiting(live, id),
467 }))
468
469 await update($, now, () => headline)
470 await update($, team, () => working)
471}
472
473/**
474 * Takes up, once per module load, what the session's state kept across a
475 * reload: the agent order and how the last turn ended.
476 */
477async function restore($: EngineInterface, live: Live): Promise<void> {
478 if (live.isRestored) {
479 return
480 }
481
482 live.isRestored = true
483 live.agentOrder = await read($, agentOrder)
484 live.lastTurn ??= await read($, lastTurn)
485}
486
487/** Adds an agent to the order that picks its color, as the HUD's agents widget orders it. */
488async function rememberAgentKey($: EngineInterface, live: Live, key: string): Promise<void> {
489 await restore($, live)
490
491 const order = withAgentKey(live.agentOrder, key)
492
493 if (order !== live.agentOrder) {
494 live.agentOrder = order
495 await update($, agentOrder, () => order)
496 }
497}
498
499/**
500 * Places an agent in the color order by its Agent call, dropping the entry
501 * its id took if its own calls arrived first.
502 */
503async function placeAgent($: EngineInterface, live: Live, agentId: string, toolUseId: string): Promise<void> {
504 await restore($, live)
505
506 const order = toolUseId === '' ? withAgentKey(live.agentOrder, agentId) : withAgentCall(live.agentOrder, agentId, toolUseId)
507
508 if (order.join('\n') !== live.agentOrder.join('\n')) {
509 live.agentOrder = order
510 await update($, agentOrder, () => order)
511 }
512}
513
514/**
515 * Marks a call that waited on approval as approved, once it is seen running:
516 * the dialog has closed, and the call's timer counts from here.
517 */
518async function approved($: EngineInterface, live: Live, toolUseId: string): Promise<void> {
519 const call = live.calls.get(toolUseId)
520
521 // Drawn running within the grace: no dialog is up, the decider's time is the call's.
522 if (call !== undefined && call.askedMs !== null && !call.isPutToYou) {
523 call.askedMs = null
524
525 return
526 }
527
528 if (call?.isAwaitingApproval) {
529 call.isAwaitingApproval = false
530
531 if (call.agentId === null) {
532 clearWaiting($, live, call)
533 }
534
535 // The wait ends now, before the call can: a fast tool's end must find it closed.
536 endWait(call, await $.clock.now())
537
538 // Drawing may not write state: the headline is published just after.
539 $.clock.after(0, () => void publish($, live))
540 }
541}
542
543/**
544 * How long a call put to approval may go undrawn before the box takes it for
545 * the dialog. The engine draws a call running at once when a decider other
546 * than the dialog (the auto-mode classifier, a host) reviews it, and only
547 * after you answer when the dialog asks you.
548 */
549const DIALOG_GRACE_MS = 250
550
551/**
552 * Takes a call still asked and undrawn after the grace as waiting on you:
553 * the headline and border say so and, for the main loop, the breadcrumb is
554 * written.
555 */
556async function putToYou($: EngineInterface, live: Live, call: Call): Promise<void> {
557 const isInFlight = [...live.calls.values()].includes(call)
558
559 if (!isInFlight || call.askedMs === null || call.isAwaitingApproval) {
560 return
561 }
562
563 call.isAwaitingApproval = true
564 call.isPutToYou = true
565
566 if (call.agentId === null) {
567 await markWaiting($, call.tool)
568 }
569
570 await publish($, live)
571}
572
573/**
574 * Writes this session's approval breadcrumb, which other sessions' HUD
575 * status lines show as an alert. A failure is let go: the HUD ignores a
576 * breadcrumb past its staleness limit.
577 */
578async function markWaiting($: EngineInterface, tool: string): Promise<void> {
579 try {
580 const home = await $.env.get('HOME')
581
582 if (home === undefined || home === '') {
583 return
584 }
585
586 const sessionId = await $.session.id()
587
588 await $.fs.write(waitingPathOf(home, sessionId), breadcrumbOf(sessionId, await $.session.cwd(), tool))
589 } catch {
590 // The HUD's staleness limit retires a breadcrumb that never lands.
591 }
592}
593
594/** `rm` answers at once; this bounds a stuck one. */
595const REMOVE_TIMEOUT_MS = 1500
596
597/**
598 * Removes a session's approval breadcrumb, unless another main-loop call
599 * besides `ending` still waits. The session is `sessionId`, or the current
600 * one, read fresh since a /clear changes it. The removal runs just after:
601 * inside a declined call's chain the engine aborts `$` calls. `$.fs` has no
602 * delete, so `/bin/rm -f` removes the file; a failure is let go, as the HUD
603 * ignores a breadcrumb past its staleness limit.
604 */
605function clearWaiting($: EngineInterface, live: Live, ending: Call | null, sessionId?: string): void {
606 const isStillWaiting = [...live.calls.values()]
607 .some(call => call !== ending && call.agentId === null && call.isAwaitingApproval)
608
609 if (sessionId === undefined && isStillWaiting) {
610 return
611 }
612
613 $.clock.after(0, async () => {
614 try {
615 const home = await $.env.get('HOME')
616
617 if (home === undefined || home === '') {
618 return
619 }
620
621 const path = waitingPathOf(home, sessionId ?? (await $.session.id()))
622
623 await $.process.run(['/bin/rm', '-f', path], { timeoutMs: REMOVE_TIMEOUT_MS })
624 } catch {
625 // The HUD's staleness limit retires a breadcrumb left behind.
626 }
627 })
628}
629
630/** The agent at work under `agentId`, taken up if its spawn came before a reload. */
631async function agentOf($: EngineInterface, live: Live, agentId: string): Promise<Agent> {
632 const known = live.agents.get(agentId)
633
634 if (known) {
635 return known
636 }
637
638 const startMs = await $.clock.now()
639 const agent: Agent = {
640 name: live.agentNames.get(agentId) ?? UNNAMED_AGENT,
641 label: STARTING_LABEL,
642 startMs,
643 activeMs: startMs,
644 toolUseId: null,
645 isBackground: true,
646 colorKey: agentId,
647 }
648
649 live.agents.set(agentId, agent)
650 await rememberAgentKey($, live, agentId)
651 keepTicking($, live)
652
653 return agent
654}
655
656/**
657 * Lets an agent go. A background agent's run is recorded in the history; a
658 * foreground one's is its Agent call's row.
659 */
660async function finish(
661 $: EngineInterface,
662 live: Live,
663 agentId: string,
664 outcome: ActivityOutcome,
665): Promise<void> {
666 const agent = live.agents.get(agentId)
667
668 if (!agent) {
669 return
670 }
671
672 live.agents.delete(agentId)
673
674 if (agent.isBackground) {
675 await remember($, {
676 ...entryOf('tool', `Ran an agent: ${agent.name}`, outcome),
677 durationMs: (await $.clock.now()) - agent.startMs,
678 tool: 'Agent',
679 })
680 }
681
682 keepTicking($, live)
683 await publish($, live)
684}
685
686/**
687 * Names agents first met by their calls, and lets go of agents the engine
688 * lists as no longer running, in case their stop never reached the box.
689 */
690async function sweep($: EngineInterface, live: Live): Promise<void> {
691 if (live.agents.size === 0 || live.isSweeping) {
692 return
693 }
694
695 live.isSweeping = true
696
697 try {
698 await sweepListed($, live, await $.agent.list())
699 } catch {
700 // The list is a backstop; the box keeps going without it.
701 } finally {
702 live.isSweeping = false
703 }
704}
705
706async function sweepListed($: EngineInterface, live: Live, listed: readonly AgentInfo[]): Promise<void> {
707 const statuses = new Map(listed.map(info => [info.id, info.status]))
708 let isRenamed = false
709
710 for (const [agentId, agent] of [...live.agents.entries()]) {
711 const status = statuses.get(agentId)
712
713 // An agent first met by its calls is named once the engine lists it.
714 if (!live.agentNames.has(agentId)) {
715 const handle = listed.find(info => info.id === agentId)?.name
716 const described = handle === undefined ? undefined : live.described.get(handle)
717
718 if (described !== undefined) {
719 live.agentNames.set(agentId, described)
720 }
721
722 const name = described ?? handle
723
724 if (name !== undefined && name !== agent.name) {
725 agent.name = name
726 isRenamed = true
727 }
728 }
729
730 if (status !== undefined && status !== 'running') {
731 await finish($, live, agentId, status === 'completed' ? 'ok' : status === 'failed' ? 'error' : 'interrupted')
732 }
733 }
734
735 if (isRenamed) {
736 await publish($, live)
737 }
738}
739
740/**
741 * Keeps the elapsed time ticking while a turn runs, an agent works or the
742 * conversation compacts, and stops it once none does.
743 */
744function keepTicking($: EngineInterface, live: Live): void {
745 const isBusy = live.turnStartMs !== null || live.agents.size > 0 || live.phase === 'compacting'
746
747 if (isBusy && live.ticker === null) {
748 let ticks = 0
749
750 live.ticker = $.clock.every(TICK_MS, async () => {
751 ticks += 1
752
753 const isWholeSecond = ticks % TICKS_PER_SECOND === 0
754
755 if (isWholeSecond) {
756 void sweep($, live)
757 }
758
759 if (isWholeSecond || hasTenthsTimer(live, await $.clock.now())) {
760 $.ui.invalidate('ui.render')
761 }
762 })
763 } else if (!isBusy && live.ticker !== null) {
764 live.ticker.cancel()
765 live.ticker = null
766 }
767}
768
769/** Whether a timer the box draws is still under ten seconds, and so shows tenths. */
770function hasTenthsTimer(live: Live, nowMs: number): boolean {
771 const headline = current(live)
772 const starts = [...live.agents.values()].map(agent => agent.startMs)
773
774 if (headline.phase !== 'idle') {
775 starts.push(headline.sinceMs)
776 }
777
778 return starts.some(startMs => nowMs - startMs < TENTHS_UNDER_MS)
779}
780
781async function remember($: EngineInterface, entry: ActivityEntry): Promise<void> {
782 await update($, history, entries => [...entries, entry].slice(-MAX_HISTORY))
783}
784
785/** Enters a model phase, recording the thinking or reply block it closes. */
786async function enter($: EngineInterface, live: Live, next: ModelPhase): Promise<void> {
787 if (next === live.phase) {
788 return
789 }
790
791 const nowMs = await $.clock.now()
792 const durationMs = nowMs - live.phaseStartMs
793
794 if (live.phase === 'thinking') {
795 const words = live.thoughtWords
796 const label = words > 0 ? `Thought (${plural(words, 'word')})` : 'Thought'
797
798 await remember($, { ...entryOf('thought', label), durationMs })
799 }
800
801 if (live.phase === 'writing' && live.replyWords > 0) {
802 const label = `Wrote the reply (${plural(live.replyWords, 'word')})`
803
804 await remember($, { ...entryOf('reply', label), durationMs })
805 }
806
807 if (next === 'thinking') {
808 live.thought = ''
809 live.thoughtWords = 0
810 live.isThoughtInWord = false
811 }
812
813 if (next === 'writing') {
814 live.replyWords = 0
815 live.isReplyInWord = false
816 }
817
818 live.phase = next
819 live.phaseStartMs = nowMs
820}
821
822/** Follows one chunk of the main loop's response. */
823async function follow($: EngineInterface, live: Live, chunk: TurnStepChunk): Promise<void> {
824 // The model went on after a declined call: the decline did not end the turn.
825 if (chunk.kind === 'text' || chunk.kind === 'tool') {
826 live.isDeclinedLast = false
827 }
828
829 switch (chunk.kind) {
830 case 'thinking': {
831 const isNew = live.phase !== 'thinking'
832
833 await enter($, live, 'thinking')
834 live.resultsToReview = 0
835 const { added, isInWord } = wordsAdded(chunk.text, live.isThoughtInWord)
836
837 live.thought = (live.thought + chunk.text).slice(-MAX_THOUGHT_BUFFER)
838 live.thoughtWords += added
839 live.isThoughtInWord = isInWord
840 await publish($, live, isNew)
841
842 return
843 }
844 case 'text': {
845 const isNew = live.phase !== 'writing'
846
847 await enter($, live, 'writing')
848 live.resultsToReview = 0
849 const { added, isInWord } = wordsAdded(chunk.text, live.isReplyInWord)
850
851 live.replyWords += added
852 live.isReplyInWord = isInWord
853 await publish($, live, isNew)
854
855 return
856 }
857 case 'tool':
858 await enter($, live, 'composing')
859 live.resultsToReview = 0
860 live.composing = { tool: chunk.name, head: '', chars: 0 }
861
862 // The stream names Agent calls in the order the transcript keeps them,
863 // the order the HUD colors agents by.
864 if (isAgentTool(chunk.name)) {
865 await rememberAgentKey($, live, chunk.id)
866 }
867
868 await publish($, live)
869
870 return
871 case 'input': {
872 const { composing } = live
873
874 if (composing) {
875 composing.chars += chunk.json.length
876
877 if (composing.head.length < MAX_INPUT_HEAD) {
878 composing.head += chunk.json
879 }
880
881 await publish($, live, false)
882 }
883
884 return
885 }
886 }
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 *
895 * @returns whether the call launched a background agent
896 */
897async function launched(
898 $: EngineInterface,
899 live: Live,
900 result: unknown,
901 asked: unknown,
902 toolUseId: string,
903): Promise<boolean> {
904 if (typeof result !== 'object' || result === null) {
905 return false
906 }
907
908 const { agentId, description, status } = result as Record<string, unknown>
909
910 if (typeof agentId !== 'string' || status !== 'async_launched') {
911 return false
912 }
913
914 const name = [asked, description].find(
915 (text): text is string => typeof text === 'string' && oneLine(text) !== '',
916 )
917
918 if (name === undefined) {
919 return true
920 }
921
922 const agent = await agentOf($, live, agentId)
923
924 live.agentNames.set(agentId, name)
925 agent.name = name
926 agent.toolUseId ??= toolUseId
927 agent.colorKey = toolUseId
928 await placeAgent($, live, agentId, toolUseId)
929 await publish($, live)
930
931 return true
932}
933
934/**
935 * The HUD answers in a few milliseconds. A binary too old to know `style`
936 * runs as the statusline, which reads its JSON from stdin: the empty stdin
937 * ends it at once, and this bounds anything slower.
938 */
939const STYLE_TIMEOUT_MS = 1500
940
941/** Whether this module load has asked the HUD for its look yet. */
942type StyleAsked = { isAsked: boolean; options: PluginOptions }
943
944/**
945 * Checks the look once per module load, off the hook that asks: the hook's
946 * event goes on at once and the box redraws when the look arrives. A stored
947 * look stands only when the HUD supplied it with this load's binary and
948 * preset; the built-in look, or one from other options, is asked again.
949 */
950function ensureStyle($: EngineInterface, asked: StyleAsked): void {
951 if (asked.isAsked) {
952 return
953 }
954
955 asked.isAsked = true
956 $.clock.after(0, () => void refreshStyle($, asked.options))
957}
958
959async function refreshStyle($: EngineInterface, options: PluginOptions): Promise<void> {
960 const preset = presetOf(options)
961 let binary = BARE_BINARY
962
963 try {
964 binary = await binaryOf($, options)
965 } catch {
966 // Without HOME or the file check, the bare name is still worth a try.
967 }
968
969 const stored = await read($, styleSource)
970
971 if (!isStyleStale(stored, binary, preset)) {
972 return
973 }
974
975 const loaded = await loadStyle($, binary, preset)
976
977 if (loaded.source.kind === 'builtin') {
978 $.ui.log(`tch-activity: ${loaded.source.reason}; drawing the built-in look`, { to: 'debug' })
979 }
980
981 await update($, styleState, () => loaded.style)
982 await update($, styleSource, () => loaded.source)
983 $.ui.invalidate('ui.render')
984}
985
986/**
987 * Asks the HUD for its look. Never rejects: a binary that is missing, slow,
988 * failing or answering something unreadable yields the built-in look and the
989 * reason, which the expanded history shows as one dim line.
990 */
991async function loadStyle($: EngineInterface, binary: string, preset: string): Promise<LoadedStyle> {
992 const argv = [binary, 'style', '--json', ...(preset === '' ? [] : ['--preset', preset])]
993 const command = argv.slice(0, 3).join(' ') + (preset === '' ? '' : ` --preset ${preset}`)
994
995 try {
996 const ran = await $.process.run(argv, { stdin: '', timeoutMs: STYLE_TIMEOUT_MS })
997
998 if (ran.exitCode !== 0) {
999 return builtin(`${command} exited ${ran.exitCode}`, binary, preset)
1000 }
1001
1002 const parsed = parseStyle(ran.stdout)
1003
1004 return parsed === null
1005 ? builtin(`${command} printed no style this mod reads`, binary, preset)
1006 : { style: parsed, source: { kind: 'hud', binary, preset } }
1007 } catch (error) {
1008 return builtin(`${binary} could not run: ${error instanceof Error ? error.message : String(error)}`, binary, preset)
1009 }
1010}
1011
1012/** The `preset` option: the preset the statusline command runs with, or empty. */
1013function presetOf(options: PluginOptions): string {
1014 const preset = options['preset']
1015
1016 return typeof preset === 'string' ? preset.trim() : ''
1017}
1018
1019/**
1020 * The HUD binary: the `binary` option when set; else `$HOME/go/bin`'s copy
1021 * when it exists, since a session's PATH is the login one and often lacks
1022 * Go's bin folder; else the bare name.
1023 */
1024async function binaryOf($: EngineInterface, options: PluginOptions): Promise<string> {
1025 const configured = options['binary']
1026
1027 if (typeof configured === 'string' && configured.trim() !== '') {
1028 return configured.trim()
1029 }
1030
1031 const home = await $.env.get('HOME')
1032
1033 if (home !== undefined && home !== '') {
1034 const goBin = `${home}${GO_BIN_SUFFIX}`
1035
1036 if (await $.fs.exists(goBin)) {
1037 return goBin
1038 }
1039 }
1040
1041 return BARE_BINARY
1042}
1043
1044const MAX_LABEL_CHARS = 120
1045const MAX_AGENT_NAME_CHARS = 40
1046
1047/** The HUD's segment separator, drawn faint as the statusline draws it. */
1048const SEPARATOR = ' | '
1049
1050/** The headline's show/hide arrow, one column wide. */
1051const TOGGLE_COLUMNS = 1
1052
1053type AbovePromptInput = RenderInput<'AbovePrompt'>
1054
1055/**
1056 * Draws the box above the prompt: the headline, a row per agent at work
1057 * beside the main loop, and, behind the triangle, the history. The box is as
1058 * wide as the widest row it has drawn this session, so it never shrinks
1059 * between states.
1060 */
1061async function drawBox($: EngineInterface, e: AbovePromptInput) {
1062 const { Box, Text, Button } = $.ui.resolve(e)
1063
1064 const style = (await read($, styleState)) ?? BUILTIN_STYLE
1065 const source = await read($, styleSource)
1066 const headline = await read($, now)
1067 const entries = await read($, history)
1068 const working = await read($, team)
1069 const isOpen = await read($, isExpanded)
1070 const widest = await read($, boxWidth)
1071 const nowMs = await $.clock.now()
1072
1073 /**
1074 * A row's segments as Text: a clipped segment gives way on a narrow
1075 * screen; the others keep their width, since they would wrap.
1076 */
1077 const line = (segments: readonly Segment[]) =>
1078 segments.map(segment => {
1079 const text = (
1080 <Text color={segment.color} dimColor={segment.isFaint === true} wrap={segment.isClipped ? 'truncate-end' : undefined}>
1081 {segment.text}
1082 </Text>
1083 )
1084
1085 return segment.isClipped ? text : <Box flexShrink={0}>{text}</Box>
1086 })
1087
1088 /**
1089 * One agent's row, as the HUD's agents widget draws a running agent: the
1090 * task icon and name in the agent's palette color, then its latest action
1091 * and its running time, each after a faint separator.
1092 */
1093 const agentRow = (agent: ActivityAgent): Segment[] => {
1094 const tint = agentColor(style, agent.colorIndex)
1095
1096 return [
1097 { text: `${icon(style, 'task')} `, color: tint },
1098 { text: printable(agent.name, MAX_AGENT_NAME_CHARS), color: tint, isClipped: true },
1099 { text: SEPARATOR, isFaint: true },
1100 { text: printable(agent.label, MAX_LABEL_CHARS), isClipped: true },
1101 { text: `${SEPARATOR}${elapsedOf(nowMs - agent.sinceMs)}`, isFaint: true },
1102 ]
1103 }
1104
1105 /**
1106 * One history row: its glyph (for a tool call, the category icon in the
1107 * outcome's color), the label, and the duration drawn faint, as the HUD's
1108 * tools widget draws a finished tool.
1109 */
1110 const historyRow = (entry: ActivityEntry): Segment[] => {
1111 const mark = entryMark(style, entry)
1112 const isQuiet = entry.kind === 'thought' || entry.kind === 'end'
1113
1114 return [
1115 { text: `${mark.glyph} `, color: mark.color, isFaint: mark.isFaint },
1116 { text: printable(entry.label, MAX_LABEL_CHARS), isFaint: isQuiet, isClipped: true },
1117 { text: entry.durationMs === null ? '' : ` ${durationOf(entry.durationMs)}`, isFaint: true },
1118 ]
1119 }
1120
1121 const isWorking = headline.phase !== 'idle'
1122 const mark = headlineMark(style, headline)
1123 const tail = [headline.detail, isWorking ? elapsedOf(nowMs - headline.sinceMs) : null]
1124 .filter((part): part is string => part !== null)
1125 .map(part => `${SEPARATOR}${part}`)
1126 .join('')
1127
1128 // An agent's name takes its palette color, as the HUD's agents widget draws it.
1129 const lead: Segment[] = headline.lead === null
1130 ? []
1131 : [
1132 { text: printable(headline.lead, MAX_LABEL_CHARS), color: mark.color, isClipped: true },
1133 { text: SEPARATOR, isFaint: true },
1134 ]
1135
1136 const headlineSegments: Segment[] = [
1137 { text: ` ${mark.glyph} `, color: mark.color },
1138 ...lead,
1139 { text: printable(headline.label, MAX_LABEL_CHARS), isClipped: true },
1140 { text: tail, isFaint: true },
1141 ]
1142
1143 // One agent the headline names; more, or one beside the main loop's
1144 // work, get a row each.
1145 const isTeamListed = working.length > 1 || (working.length === 1 && headline.phase !== 'agent')
1146 const note = styleNote(source)
1147 const layout = bandLayout({
1148 maxRows: e.props.maxRows,
1149 agents: isTeamListed ? working.length : 0,
1150 entries: entries.length,
1151 isOpen,
1152 hasNote: note !== null,
1153 })
1154
1155 const listed = layout.agentRows > 0 ? working.slice(-layout.agentRows) : []
1156 const unlisted = working.length - listed.length
1157 const agentLines = listed.map(agentRow)
1158 const moreAgents: Segment[][] = layout.hasMoreAgents ? [[{ text: `… ${plural(unlisted, 'more agent')}`, isFaint: true }]] : []
1159
1160 const shown = layout.historyRows > 0 ? entries.slice(-layout.historyRows) : []
1161 const hidden = entries.length - shown.length
1162 const historyLines = shown.map(historyRow)
1163 const earlier: Segment[][] = layout.hasEarlier ? [[{ text: `… ${hidden} earlier`, isFaint: true }]] : []
1164 const empty: Segment[][] = entries.length === 0 ? [[{ text: 'No actions yet', isFaint: true }]] : []
1165 const footer: Segment[][] = layout.hasNote && note !== null ? [[{ text: note, isFaint: true, isClipped: true }]] : []
1166
1167 const teamLines = isTeamListed ? [...agentLines, ...moreAgents] : []
1168 const bodyLines = layout.isHistoryShown ? [...earlier, ...historyLines, ...empty, ...footer] : []
1169
1170 const natural = boxColumnsOf([
1171 TOGGLE_COLUMNS + columnsOf(headlineSegments),
1172 ...teamLines.map(segments => AGENT_INDENT_COLUMNS + columnsOf(segments)),
1173 ...bodyLines.map(columnsOf),
1174 ])
1175
1176 if (natural > widest) {
1177 // Drawing may not write state: the wider box is kept just after.
1178 $.clock.after(0, () => void update($, boxWidth, width => Math.max(width, natural)))
1179 }
1180
1181 const teamBox = teamLines.length > 0
1182 ? [
1183 <Box key="agents" flexDirection="column" paddingLeft={AGENT_INDENT_COLUMNS}>
1184 {teamLines.map((segments, i) => (
1185 <Box key={`agent-${i}`} flexDirection="row">
1186 {line(segments)}
1187 </Box>
1188 ))}
1189 </Box>,
1190 ]
1191 : []
1192
1193 const body = layout.isHistoryShown
1194 ? [
1195 <Box key="history" flexDirection="column" marginTop={1}>
1196 {bodyLines.map((segments, i) => (
1197 <Box key={`row-${i}`} flexDirection="row">
1198 {line(segments)}
1199 </Box>
1200 ))}hooks/activity-of.ts 120 lines1// Adapted from whats-agent-doing (MIT, Copyright (c) 2026 Tzafrir Rehan);
2// see NOTICE.
3import { clip, oneLine } from './text'
4
5const MAX_DETAIL_CHARS = 60
6
7/** `present` for what a call is doing (the headline), `past` for what it did (the history). */
8export type Tense = 'present' | 'past'
9
10/**
11 * Says in a few words what a tool call is doing, or did.
12 *
13 * @param tool the tool's name; a string, as the declared names are only the
14 * tools of the build that wrote the types (Grep and Glob are on some only)
15 * @param args the call's arguments, whole or as far as they have streamed
16 * @param tense whether the label says what the call is doing or what it did
17 * @returns a short label such as `Reading register.tsx` or `Read register.tsx`
18 */
19export function activityOf(tool: string, args: Record<string, unknown>, tense: Tense = 'present'): string {
20 const verb = (present: string, past: string): string => (tense === 'present' ? present : past)
21
22 const raw = (key: string): string => {
23 const value = args[key]
24
25 return typeof value === 'string' ? oneLine(value) : ''
26 }
27
28 const arg = (key: string): string => clip(raw(key), MAX_DETAIL_CHARS)
29
30 // The base name is taken before the cut, so a long path still names its file.
31 const file = (key: string): string => clip(baseName(raw(key)), MAX_DETAIL_CHARS)
32
33 switch (tool) {
34 case 'Bash':
35 case 'PowerShell':
36 // The model's own description says what a command is for; the command
37 // itself is the fallback.
38 return arg('description') || `${verb('Running', 'Ran')} ${arg('command')}`
39 case 'Read':
40 return `${verb('Reading', 'Read')} ${file('file_path')}`
41 case 'Edit':
42 return `${verb('Editing', 'Edited')} ${file('file_path')}`
43 case 'Write':
44 return `${verb('Writing', 'Wrote')} ${file('file_path')}`
45 case 'NotebookEdit':
46 return `${verb('Editing', 'Edited')} ${file('notebook_path')}`
47 case 'Grep':
48 return `${verb('Searching', 'Searched')} for "${arg('pattern')}"`
49 case 'Glob':
50 return `${verb('Finding', 'Found')} files ${arg('pattern')}`
51 case 'WebFetch':
52 return `${verb('Fetching', 'Fetched')} ${hostOf(arg('url'))}`
53 case 'WebSearch':
54 return `${verb('Searching', 'Searched')} the web for "${arg('query')}"`
55 case 'Agent':
56 case 'Task':
57 return `${verb('Running', 'Ran')} an agent: ${arg('description')}`
58 case 'TodoWrite':
59 return `${verb('Updating', 'Updated')} the todo list`
60 case 'AskUserQuestion':
61 return verb('Waiting for your answer', 'Asked you a question')
62 case 'Skill':
63 return `${verb('Using', 'Used')} the ${arg('skill')} skill`
64 }
65
66 if (tool.startsWith('mcp__')) {
67 const [, server = '', name = ''] = tool.split('__')
68
69 return `${verb('Using', 'Used')} ${server}: ${name}`
70 }
71
72 return `${verb('Using', 'Used')} ${tool}`
73}
74
75/**
76 * The string arguments a tool call's input has streamed so far, read from
77 * its partial JSON: enough to name the file or command before it is whole.
78 */
79export function partialArgsOf(json: string): Record<string, unknown> {
80 const args: Record<string, unknown> = {}
81
82 for (const match of json.matchAll(/"([a-z_]+)"\s*:\s*"((?:[^"\\]|\\.)*)/g)) {
83 const [, key = '', raw = ''] = match
84
85 args[key] ??= unescaped(raw)
86 }
87
88 return args
89}
90
91export function isShellTool(tool: string): boolean {
92 return tool === 'Bash' || tool === 'PowerShell'
93}
94
95export function isAgentTool(tool: string): boolean {
96 return tool === 'Agent' || tool === 'Task'
97}
98
99function unescaped(raw: string): string {
100 try {
101 return JSON.parse(`"${raw}"`) as string
102 } catch {
103 // A partial escape at the stream's edge: the raw text reads well enough.
104 return raw
105 }
106}
107
108function baseName(path: string): string {
109 return path.split(/[\\/]/).pop() || path
110}
111
112function hostOf(url: string): string {
113 try {
114 return new URL(url).host
115 } catch {
116 // Not a whole URL yet (still streaming): show what there is.
117 return url
118 }
119}
120hooks/live.ts 373 lines1// Adapted from whats-agent-doing's register.tsx (MIT, Copyright (c) 2026
2// Tzafrir Rehan); see NOTICE.
3import type { Timer, TurnCompleteReason } from 'claude-code'
4
5import type { ActivityEntry, ActivityNow, ActivityOutcome } from '../types'
6import { activityOf, isAgentTool, isShellTool, partialArgsOf } from './activity-of'
7import { elapsedOf, plural, sizeOf, thoughtOf } from './text'
8
9/** The headline before any turn. */
10export const IDLE: ActivityNow = {
11 phase: 'idle',
12 label: 'Idle, waiting for your prompt',
13 detail: null,
14 sinceMs: 0,
15 tool: null,
16 colorIndex: null,
17 lead: null,
18 lastOutcome: null,
19}
20
21const MAX_THOUGHT_CHARS = 70
22
23/** A tool input this large is worth a size beside its label. */
24const LARGE_INPUT_CHARS = 1024
25
26export const STARTING_LABEL = 'Getting started'
27
28/** The headline while the model writes a call its input does not name yet. */
29export const PREPARING_LABEL = 'Preparing the next action'
30
31export const UNNAMED_AGENT = 'Agent'
32
33/** What the model is doing in the main loop, between and around tool calls. */
34export type ModelPhase = 'idle' | 'requesting' | 'thinking' | 'writing' | 'composing' | 'compacting'
35
36/**
37 * A tool call in flight: the main loop's, or a subagent's (`agentId`). An
38 * `ask` verdict (at `askedMs`) puts it to the mode's decider; it awaits your
39 * approval once that turns out to be the dialog, until it is seen running or
40 * ends. `isPutToYou` stays set once it was taken for the dialog. `waitedMs`
41 * is the approval wait already over, which its timer and duration leave out.
42 */
43export type Call = {
44 tool: string
45 label: string
46 startMs: number
47 agentId: string | null
48 isAwaitingApproval: boolean
49 isPutToYou: boolean
50 askedMs: number | null
51 waitedMs: number
52}
53
54/** A call that has just started, not put to approval. */
55export function callOf(tool: string, label: string, startMs: number, agentId: string | null): Call {
56 return { tool, label, startMs, agentId, isAwaitingApproval: false, isPutToYou: false, askedMs: null, waitedMs: 0 }
57}
58
59/**
60 * Forgets the main loop's calls, as a turn's start or end does. A background
61 * agent's calls outlive the main turn and stay, an approval wait included.
62 */
63export function dropMainCalls(live: Live): void {
64 for (const [id, call] of live.calls) {
65 if (call.agentId === null) {
66 live.calls.delete(id)
67 }
68 }
69}
70
71/** Ends a call's approval wait at `nowMs`, adding it to the time the call leaves out. */
72export function endWait(call: Call, nowMs: number): void {
73 if (call.askedMs !== null) {
74 call.waitedMs += Math.max(0, nowMs - call.askedMs)
75 call.askedMs = null
76 }
77}
78
79/**
80 * The agents tail-claude-hud's agents widget colors: Agent calls in the order
81 * the main conversation made them, the newest AGENT_ORDER_WINDOW kept, each
82 * taking the palette slot of its position. An agent met with no Agent call
83 * seen joins the list by its id.
84 */
85export const AGENT_ORDER_WINDOW = 10
86
87/** The HUD's agent palette has eight colors; a position wraps at that. */
88const AGENT_PALETTE_SIZE = 8
89
90/**
91 * A subagent at work, from its spawn (or its first call, when the spawn came
92 * before a reload) to its stop. `toolUseId` names the Agent call that started
93 * it; `activeMs` is when it last did something; `colorKey` its entry in the
94 * agent order, which picks its color.
95 */
96export type Agent = {
97 name: string
98 label: string
99 startMs: number
100 activeMs: number
101 toolUseId: string | null
102 isBackground: boolean
103 colorKey: string
104}
105
106/**
107 * The module's live view of the session, from which the headline is drawn:
108 * started over on a reload, while the headline and history in `$.state` stay.
109 */
110export type Live = {
111 calls: Map<string, Call>
112 agents: Map<string, Agent>
113 agentNames: Map<string, string>
114 described: Map<string, string>
115 agentOrder: readonly string[]
116 isRestored: boolean
117 phase: ModelPhase
118 phaseStartMs: number
119 turnStartMs: number | null
120 turnActions: number
121 isDeclinedLast: boolean
122 isFirstRequest: boolean
123 resultsToReview: number
124 thought: string
125 thoughtWords: number
126 isThoughtInWord: boolean
127 replyWords: number
128 isReplyInWord: boolean
129 composing: { tool: string; head: string; chars: number } | null
130 lastTurn: { label: string; outcome: ActivityOutcome } | null
131 lastPublishMs: number
132 trailing: Timer | null
133 isSweeping: boolean
134 ticker: Timer | null
135}
136
137export function createLive(): Live {
138 return {
139 calls: new Map(),
140 agents: new Map(),
141 agentNames: new Map(),
142 described: new Map(),
143 agentOrder: [],
144 isRestored: false,
145 phase: 'idle',
146 phaseStartMs: 0,
147 turnStartMs: null,
148 turnActions: 0,
149 isDeclinedLast: false,
150 isFirstRequest: false,
151 resultsToReview: 0,
152 thought: '',
153 thoughtWords: 0,
154 isThoughtInWord: false,
155 replyWords: 0,
156 isReplyInWord: false,
157 composing: null,
158 lastTurn: null,
159 lastPublishMs: 0,
160 trailing: null,
161 isSweeping: false,
162 ticker: null,
163 }
164}
165
166/** The headline as the live view has it. */
167export function current(live: Live): ActivityNow {
168 const base = { ...IDLE, lastOutcome: live.lastTurn?.outcome ?? null }
169
170 if (live.phase === 'compacting') {
171 return { ...base, phase: 'compacting', label: 'Compacting the conversation', sinceMs: live.phaseStartMs }
172 }
173
174 const open = [...live.calls.entries()].filter(([, call]) => call.agentId === null)
175 const [newestId, newest] = open.at(-1) ?? []
176
177 if (newestId !== undefined && newest !== undefined) {
178 const more = open.length > 1 ? ` (+${open.length - 1} more)` : ''
179 const call = { ...base, sinceMs: newest.startMs + newest.waitedMs, tool: newest.tool }
180
181 if (newest.isAwaitingApproval) {
182 return { ...call, sinceMs: newest.askedMs ?? call.sinceMs, phase: 'approval', label: `Waiting for your approval: ${newest.label}${more}` }
183 }
184
185 if (newest.tool === 'AskUserQuestion') {
186 return { ...call, phase: 'question', label: newest.label }
187 }
188
189 const inner = isAgentTool(newest.tool) ? agentUnder(live, newestId) : undefined
190
191 return inner === undefined
192 ? { ...call, phase: 'tool', label: `${newest.label}${more}` }
193 : {
194 ...call,
195 phase: 'agent',
196 lead: newest.label,
197 label: `${agentLabel(live, inner)}${more}`,
198 colorIndex: colorIndexOf(live, inner.colorKey),
199 }
200 }
201
202 if (live.phase === 'idle' && live.agents.size > 0) {
203 // Between turns, background agents may still be at work.
204 const working = [...live.agents.entries()]
205 const sinceMs = Math.min(...working.map(([, agent]) => agent.startMs))
206 const [busiestId, busiest] = working.reduce((a, b) => (b[1].activeMs > a[1].activeMs ? b : a))
207 const isAlone = working.length === 1
208
209 return {
210 ...base,
211 phase: 'agent',
212 lead: isAlone ? busiest.name : null,
213 label: isAlone
214 ? agentLabel(live, { ...busiest, id: busiestId })
215 : `${plural(working.length, 'agent')} working`,
216 sinceMs,
217 tool: 'Agent',
218 colorIndex: isAlone ? colorIndexOf(live, busiest.colorKey) : null,
219 }
220 }
221
222 const sinceMs = live.phaseStartMs
223
224 switch (live.phase) {
225 case 'requesting':
226 return {
227 ...base,
228 phase: 'requesting',
229 label: live.isFirstRequest
230 ? 'Reading your prompt'
231 : live.resultsToReview > 0
232 ? `Reviewing the results of ${plural(live.resultsToReview, 'action')}`
233 : 'Waiting for the model',
234 sinceMs,
235 }
236 case 'thinking': {
237 const snippet = thoughtOf(live.thought, MAX_THOUGHT_CHARS)
238 const label = snippet ? `Thinking: ${snippet}` : 'Thinking'
239
240 return { ...base, phase: 'thinking', label, sinceMs }
241 }
242 case 'writing':
243 return {
244 ...base,
245 phase: 'writing',
246 label: 'Writing the reply',
247 detail: plural(live.replyWords, 'word'),
248 sinceMs,
249 }
250 case 'composing':
251 return { ...base, ...composingHeadline(live), phase: 'composing', sinceMs }
252 case 'idle':
253 return live.lastTurn === null ? base : { ...base, label: `Idle · last turn ${live.lastTurn.label}` }
254 }
255}
256
257/**
258 * A call being written is named once its input says what it does: a file
259 * tool by its path, a command by its description, which streams after the
260 * command itself. Until then the headline says a call is being prepared,
261 * under the running glyph.
262 */
263function composingHeadline(live: Live): { label: string; detail: string | null; tool: string | null } {
264 const { composing } = live
265 const args = composing ? partialArgsOf(composing.head) : {}
266 const isDescribed = composing !== null && (isShellTool(composing.tool)
267 ? typeof args['description'] === 'string'
268 : Object.keys(args).length > 0)
269
270 if (!composing || !isDescribed) {
271 return { label: PREPARING_LABEL, detail: null, tool: null }
272 }
273
274 const detail = composing.chars >= LARGE_INPUT_CHARS ? sizeOf(composing.chars) : null
275
276 return { label: activityOf(composing.tool, args), detail, tool: composing.tool }
277}
278
279/** The agent an Agent call started, or the one that last did something. */
280function agentUnder(live: Live, toolUseId: string): (Agent & { id: string }) | undefined {
281 const working = [...live.agents.entries()].map(([id, agent]) => ({ ...agent, id }))
282
283 return working.find(agent => agent.toolUseId === toolUseId)
284 ?? working.reduce<(Agent & { id: string }) | undefined>((a, b) => (a === undefined || b.activeMs > a.activeMs ? b : a), undefined)
285}
286
287/**
288 * What an agent is doing: waiting on approval for its newest call that
289 * waits, else its latest action. Derived on each read, so a second request
290 * never stacks a second prefix.
291 */
292export function agentLabel(live: Live, agent: Agent & { id: string }): string {
293 const waiting = waitingCallOf(live, agent.id)
294
295 return waiting === undefined ? agent.label : `Waiting for your approval: ${waiting.label}`
296}
297
298/** Whether a call of the agent's waits on approval. */
299export function isAgentWaiting(live: Live, agentId: string): boolean {
300 return waitingCallOf(live, agentId) !== undefined
301}
302
303function waitingCallOf(live: Live, agentId: string): Call | undefined {
304 return [...live.calls.values()].reverse().find(call => call.agentId === agentId && call.isAwaitingApproval)
305}
306
307/** The order with `key` added at its end, once, the newest window kept. */
308export function withAgentKey(order: readonly string[], key: string): readonly string[] {
309 return order.includes(key) ? order : [...order, key].slice(-AGENT_ORDER_WINDOW)
310}
311
312/**
313 * The order once an agent first met by its id is known by its Agent call:
314 * the id's entry goes, and the call keeps (or takes) its own place.
315 */
316export function withAgentCall(order: readonly string[], agentId: string, toolUseId: string): readonly string[] {
317 return withAgentKey(order.filter(key => key !== agentId), toolUseId)
318}
319
320/** An agent's palette slot: its position in the agent order, wrapped at the palette size. */
321export function colorIndexOf(live: Live, key: string): number {
322 return Math.max(0, live.agentOrder.indexOf(key)) % AGENT_PALETTE_SIZE
323}
324
325/**
326 * How a turn ended: the engine's reason, or `declined` when its last call was
327 * one you declined and nothing followed it.
328 */
329export type TurnEnd = TurnCompleteReason | 'declined'
330
331export function turnEndOf(reason: TurnCompleteReason, isDeclinedLast: boolean): TurnEnd {
332 return isDeclinedLast && (reason === 'answer' || reason === 'aborted') ? 'declined' : reason
333}
334
335/**
336 * How a turn ended, with its time counted as the running timer counts it:
337 * the idle headline's words, and, capitalized, the turn's last history row.
338 */
339export function turnEndLabel(end: TurnEnd, durationMs: number, actions: number): string {
340 const took = elapsedOf(durationMs)
341
342 switch (end) {
343 case 'aborted':
344 return `interrupted after ${took}`
345 case 'error':
346 return `stopped on an error after ${took}`
347 case 'refusal':
348 return 'declined the request'
349 case 'declined':
350 return `stopped at a declined call after ${took}`
351 case 'answer':
352 return `done in ${took}, ${plural(actions, 'action')}`
353 }
354}
355
356export function outcomeOf(end: TurnEnd): ActivityOutcome {
357 switch (end) {
358 case 'aborted':
359 return 'interrupted'
360 case 'error':
361 return 'error'
362 case 'refusal':
363 case 'declined':
364 return 'denied'
365 case 'answer':
366 return 'ok'
367 }
368}
369
370export function entryOf(kind: ActivityEntry['kind'], label: string, outcome: ActivityOutcome = 'ok'): ActivityEntry {
371 return { kind, label, durationMs: null, outcome, tool: null }
372}
373hooks/layout.ts 109 lines1/** Rows every box takes: the top and bottom border and the headline. */
2const FIXED_ROWS = 3
3
4/** The blank row between the headline (and agents) and the history. */
5const HISTORY_GAP_ROWS = 1
6
7export const MAX_AGENT_ROWS = 6
8export const MAX_HISTORY_ROWS = 12
9
10/** What the band shows, given the rows it may take. */
11export type BandLayout = {
12 /** Agent rows drawn, newest last. */
13 agentRows: number
14 /** Whether a `… N more agents` row follows them. */
15 hasMoreAgents: boolean
16 /** Whether the history is drawn: it is open and at least one row fits. */
17 isHistoryShown: boolean
18 /** History entries drawn, newest last. */
19 historyRows: number
20 /** Whether an `… N earlier` row leads them. */
21 hasEarlier: boolean
22 /** Whether the built-in-look note closes the history. */
23 hasNote: boolean
24}
25
26/**
27 * Fits the band into `maxRows`, counting every row it draws: the frame and
28 * headline, each agent row and the `more agents` row, the history's gap,
29 * its `earlier` row, its entries or `No actions yet`, and the note. A tree
30 * taller than `maxRows` scrolls in the band, so nothing past it is drawn.
31 */
32export function bandLayout(input: {
33 maxRows: number
34 agents: number
35 entries: number
36 isOpen: boolean
37 hasNote: boolean
38}): BandLayout {
39 let room = Math.max(0, input.maxRows - FIXED_ROWS)
40
41 const agentCap = Math.min(MAX_AGENT_ROWS, room)
42 const hasMoreAgents = input.agents > agentCap && agentCap > 0
43 const agentRows = hasMoreAgents ? agentCap - 1 : Math.min(input.agents, agentCap)
44
45 room -= agentRows + (hasMoreAgents ? 1 : 0)
46
47 // The history needs its gap and one row at least.
48 const isHistoryShown = input.isOpen && room >= HISTORY_GAP_ROWS + 1
49
50 if (!isHistoryShown) {
51 return { agentRows, hasMoreAgents, isHistoryShown, historyRows: 0, hasEarlier: false, hasNote: false }
52 }
53
54 room -= HISTORY_GAP_ROWS
55
56 // The note gives way before the entries do.
57 const hasNote = input.hasNote && room >= 2
58 room -= hasNote ? 1 : 0
59
60 const historyCap = Math.min(MAX_HISTORY_ROWS, room)
61 const hasEarlier = input.entries > historyCap
62 const historyRows = hasEarlier ? Math.max(0, historyCap - 1) : input.entries
63
64 return { agentRows, hasMoreAgents, isHistoryShown, historyRows, hasEarlier, hasNote }
65}
66
67/**
68 * Columns from the box's left edge to the headline label: the show/hide
69 * arrow, a space, the glyph and a space. Agent rows are indented by this, so
70 * they start under the label.
71 */
72export const AGENT_INDENT_COLUMNS = 4
73
74/** Columns the frame adds to its widest row: a border and a padding column each side. */
75const FRAME_COLUMNS = 4
76
77/**
78 * A run of text in a row, drawn in `color` (default foreground when absent),
79 * faint, and, when `isClipped`, cut short on a narrow screen rather than
80 * wrapped.
81 */
82export type Segment = {
83 text: string
84 color?: string | undefined
85 isFaint?: boolean | undefined
86 isClipped?: boolean
87}
88
89/**
90 * The columns a row's segments take. A glyph is one column, as the engine
91 * and tmux count Nerd Font icons, and an emoji drawn as one is two.
92 */
93export function columnsOf(segments: readonly Segment[]): number {
94 let columns = 0
95
96 for (const segment of segments) {
97 for (const char of segment.text) {
98 columns += /\p{Emoji_Presentation}/u.test(char) ? 2 : 1
99 }
100 }
101
102 return columns
103}
104
105/** The box's width, frame included, for rows of the given widths. */
106export function boxColumnsOf(rows: readonly number[]): number {
107 return Math.max(0, ...rows) + FRAME_COLUMNS
108}
109hooks/marks.ts 142 lines1// The glyph and color each state is drawn with: tail-claude-hud's icons and
2// semantic colors where the HUD has one, plain text marks where it has none.
3import type { ActivityAgent, ActivityEntry, ActivityNow, ActivityOutcome, TchStyle, TchStyleSource } from '../types'
4import { agentColor, color, icon, toolIcon } from './style'
5import { printable } from './text'
6
7const MAX_NOTE_CHARS = 120
8
9/** History marks for rows that are no tool call: a prompt (default text) and a compaction (faint). */
10const PROMPT_GLYPH = '›'
11const COMPACT_GLYPH = '⇣'
12
13/**
14 * The reply's speech bubble, which the HUD's icon set has no key for: the
15 * Nerd Font comment icon (nf-fa-comment), a narrow quote mark otherwise.
16 */
17const REPLY_GLYPHS: Readonly<Record<string, string>> = { nerdfont: '\uf075', unicode: '❝', ascii: '"' }
18const REPLY_FALLBACK = '❝'
19
20/** The tool that puts a question to you, drawn with the needs-you bell wherever it shows. */
21const QUESTION_TOOL = 'AskUserQuestion'
22
23/**
24 * A glyph, the color it is drawn in (`undefined`: default foreground), and
25 * whether it is drawn faint.
26 */
27export type Mark = { glyph: string; color: string | undefined; isFaint?: boolean }
28
29/** The headline's glyph: what kind of work, in the HUD's color for it. */
30export function headlineMark(style: TchStyle, headline: ActivityNow): Mark {
31 const running = color(style, 'running')
32
33 switch (headline.phase) {
34 case 'idle':
35 return headline.lastOutcome === null
36 ? { glyph: icon(style, 'clock'), color: color(style, 'muted') }
37 : outcomeMark(style, headline.lastOutcome)
38 case 'thinking':
39 return { glyph: icon(style, 'thinking'), color: color(style, 'thinking') }
40 case 'writing':
41 return { glyph: replyGlyph(style), color: running }
42 case 'tool':
43 case 'composing':
44 return headline.tool === null
45 ? { glyph: icon(style, 'running'), color: running }
46 : { glyph: toolIcon(style, headline.tool), color: running }
47 case 'agent':
48 return {
49 glyph: icon(style, 'task'),
50 color: headline.colorIndex === null ? running : agentColor(style, headline.colorIndex),
51 }
52 case 'approval':
53 case 'question':
54 return { glyph: icon(style, 'permission'), color: color(style, 'permission') }
55 case 'requesting':
56 case 'compacting':
57 return { glyph: icon(style, 'running'), color: running }
58 }
59}
60
61/** A finished thing's glyph: the HUD's check and error icons, its declined and interrupted icons muted. */
62export function outcomeMark(style: TchStyle, outcome: ActivityOutcome): Mark {
63 switch (outcome) {
64 case 'ok':
65 return { glyph: icon(style, 'check'), color: color(style, 'success') }
66 case 'error':
67 return { glyph: icon(style, 'error'), color: color(style, 'error') }
68 case 'denied':
69 return { glyph: icon(style, 'rejected'), color: color(style, 'muted') }
70 case 'interrupted':
71 return { glyph: icon(style, 'interrupted'), color: color(style, 'muted') }
72 }
73}
74
75export function entryMark(style: TchStyle, entry: ActivityEntry): Mark {
76 switch (entry.kind) {
77 case 'turn':
78 return { glyph: PROMPT_GLYPH, color: undefined }
79 case 'thought':
80 return { glyph: icon(style, 'thinking'), color: undefined, isFaint: true }
81 case 'reply':
82 return { glyph: replyGlyph(style), color: undefined, isFaint: true }
83 case 'compact':
84 return { glyph: COMPACT_GLYPH, color: undefined, isFaint: true }
85 case 'tool':
86 return entry.tool === null ? outcomeMark(style, entry.outcome) : toolMark(style, entry.tool, entry.outcome)
87 case 'end':
88 return outcomeMark(style, entry.outcome)
89 }
90}
91
92/**
93 * A finished tool's one glyph, as the HUD's tools widget draws it: the
94 * category icon in the success or error color. A declined or interrupted
95 * call shows its muted mark in the icon's place, so its row never depends
96 * on color alone.
97 */
98function toolMark(style: TchStyle, tool: string, outcome: ActivityOutcome): Mark {
99 switch (outcome) {
100 case 'ok':
101 return { glyph: entryToolIcon(style, tool), color: color(style, 'success') }
102 case 'error':
103 return { glyph: entryToolIcon(style, tool), color: color(style, 'error') }
104 case 'denied':
105 case 'interrupted':
106 return outcomeMark(style, outcome)
107 }
108}
109
110/** A history tool row's kind icon: the HUD's for the tool's category, the bell for a question to you. */
111function entryToolIcon(style: TchStyle, tool: string): string {
112 return tool === QUESTION_TOOL ? icon(style, 'permission') : toolIcon(style, tool)
113}
114
115function replyGlyph(style: TchStyle): string {
116 return REPLY_GLYPHS[style.iconMode] ?? REPLY_FALLBACK
117}
118
119/**
120 * The border's color: the needs-you color while the main loop or an agent
121 * waits on you, muted when idle, else the color of the work under way.
122 */
123export function borderColor(style: TchStyle, headline: ActivityNow, agents: readonly ActivityAgent[]): string | undefined {
124 if (headline.phase === 'approval' || headline.phase === 'question' || agents.some(agent => agent.isWaiting)) {
125 return color(style, 'permission')
126 }
127
128 switch (headline.phase) {
129 case 'idle':
130 return color(style, 'muted')
131 case 'thinking':
132 return color(style, 'thinking')
133 default:
134 return color(style, 'running')
135 }
136}
137
138/** The one line saying the HUD's look is not in use, and why. */
139export function styleNote(source: TchStyleSource | null): string | null {
140 return source?.kind === 'builtin' ? `Built-in look: ${printable(source.reason, MAX_NOTE_CHARS)}` : null
141}
142hooks/style.ts 236 lines1import type { TchStyle, TchStyleSource } from '../types'
2import { printable } from './text'
3
4/** The `style --json` schema this module reads. */
5const STYLE_SCHEMA = 1
6
7/** Where `go install` puts the HUD, which the login PATH often lacks. */
8export const GO_BIN_SUFFIX = '/go/bin/tail-claude-hud'
9
10/** The bare name, found on the session's PATH when nothing else is. */
11export const BARE_BINARY = 'tail-claude-hud'
12
13/** Icons and colors are short; anything longer is not one. */
14const MAX_ICON_CHARS = 8
15
16const MAX_REASON_CHARS = 120
17
18/**
19 * Mod color syntax the HUD emits. Any other string could make the engine
20 * refuse the whole tree, so it is dropped to the default foreground.
21 */
22const COLOR_PATTERN = /^(ansi256\(\d{1,3}\)|#[0-9a-fA-F]{6})$/
23
24/**
25 * The look drawn when the HUD binary is missing or answers badly: the HUD's
26 * own default colors with narrow unicode glyphs, which need no Nerd Font.
27 */
28export const BUILTIN_STYLE: TchStyle = {
29 iconMode: 'unicode',
30 icons: {
31 check: '✓',
32 running: '⧗',
33 clock: '◷',
34 folder: '⌂',
35 branch: '⎇',
36 read: '≡',
37 edit: '✎',
38 write: '✎',
39 bash: '$',
40 grep: '⌕',
41 glob: '⌕',
42 web: '◎',
43 task: '◆',
44 skill: '✦',
45 thinking: '∴',
46 other: '⚙',
47 error: '✗',
48 permission: '!',
49 rejected: '⊘',
50 interrupted: '■',
51 },
52 colors: {
53 running: 'ansi256(3)',
54 success: 'ansi256(2)',
55 error: 'ansi256(1)',
56 thinking: 'ansi256(3)',
57 permission: 'ansi256(1)',
58 muted: 'ansi256(8)',
59 normal: 'ansi256(2)',
60 warning: 'ansi256(3)',
61 critical: 'ansi256(1)',
62 },
63 agentColors: [
64 'ansi256(12)',
65 'ansi256(10)',
66 'ansi256(9)',
67 'ansi256(11)',
68 'ansi256(13)',
69 'ansi256(14)',
70 'ansi256(3)',
71 'ansi256(5)',
72 ],
73 toolCategories: {
74 byName: {
75 Agent: 'Task',
76 Bash: 'Bash',
77 Edit: 'Edit',
78 Glob: 'Glob',
79 Grep: 'Grep',
80 NotebookEdit: 'Write',
81 Read: 'Read',
82 Skill: 'Skill',
83 Task: 'Task',
84 WebFetch: 'Web',
85 WebSearch: 'Web',
86 Workflow: 'Task',
87 Write: 'Write',
88 },
89 default: 'Other',
90 icons: {
91 Bash: 'bash',
92 Edit: 'edit',
93 Glob: 'glob',
94 Grep: 'grep',
95 Read: 'read',
96 Skill: 'skill',
97 Task: 'task',
98 Thinking: 'thinking',
99 Web: 'web',
100 Write: 'write',
101 },
102 },
103}
104
105/** The semantic color names the box draws with. */
106export type ColorName = 'running' | 'success' | 'error' | 'thinking' | 'permission' | 'muted'
107
108/** The icon keys the box draws with, beyond the per-category ones. */
109export type IconName =
110 | 'check'
111 | 'running'
112 | 'clock'
113 | 'task'
114 | 'thinking'
115 | 'error'
116 | 'permission'
117 | 'rejected'
118 | 'interrupted'
119 | 'write'
120 | 'other'
121
122/**
123 * The icon for a tool: its HUD category (Read, Bash, Web, ...) mapped to
124 * that category's icon key, `other` for tools the HUD does not categorize.
125 */
126export function toolIcon(style: TchStyle, tool: string): string {
127 const category = style.toolCategories.byName[tool] ?? style.toolCategories.default
128 const key = style.toolCategories.icons[category] ?? 'other'
129
130 return icon(style, key)
131}
132
133export function icon(style: TchStyle, key: IconName | string): string {
134 return style.icons[key] || BUILTIN_STYLE.icons[key] || '•'
135}
136
137/** A semantic color, or `undefined` for the terminal's default foreground. */
138export function color(style: TchStyle, name: ColorName): string | undefined {
139 return style.colors[name] || undefined
140}
141
142/** The palette color of the agent spawned `colorIndex`-th in the session. */
143export function agentColor(style: TchStyle, colorIndex: number): string | undefined {
144 const palette = style.agentColors.length > 0 ? style.agentColors : BUILTIN_STYLE.agentColors
145
146 return palette[colorIndex % palette.length] || undefined
147}
148
149/** A loaded look and where it came from. */
150export type LoadedStyle = { style: TchStyle; source: TchStyleSource }
151
152/**
153 * Reads `style --json` output into a look, the built-in one filling any key
154 * the output lacks. `null` when it is not JSON or not schema 1.
155 */
156export function parseStyle(stdout: string): TchStyle | null {
157 let raw: unknown
158
159 try {
160 raw = JSON.parse(stdout)
161 } catch {
162 return null
163 }
164
165 if (!isRecord(raw) || raw['schema'] !== STYLE_SCHEMA) {
166 return null
167 }
168
169 const categories = isRecord(raw['toolCategories']) ? raw['toolCategories'] : {}
170 const agentColors = Array.isArray(raw['agentColors'])
171 ? raw['agentColors'].filter((one): one is string => typeof one === 'string').map(colorOf)
172 : []
173
174 return {
175 iconMode: typeof raw['iconMode'] === 'string' ? raw['iconMode'] : BUILTIN_STYLE.iconMode,
176 icons: { ...BUILTIN_STYLE.icons, ...stringsOf(raw['icons'], iconOf) },
177 colors: { ...BUILTIN_STYLE.colors, ...stringsOf(raw['colors'], colorOf) },
178 agentColors: agentColors.length > 0 ? agentColors : BUILTIN_STYLE.agentColors,
179 toolCategories: {
180 byName: { ...BUILTIN_STYLE.toolCategories.byName, ...stringsOf(categories['byName'], plainOf) },
181 default: typeof categories['default'] === 'string' ? categories['default'] : BUILTIN_STYLE.toolCategories.default,
182 icons: { ...BUILTIN_STYLE.toolCategories.icons, ...stringsOf(categories['icons'], plainOf) },
183 },
184 }
185}
186
187export function builtin(reason: string, binary: string, preset: string): LoadedStyle {
188 return {
189 style: BUILTIN_STYLE,
190 source: { kind: 'builtin', reason: printable(reason, MAX_REASON_CHARS), binary, preset },
191 }
192}
193
194function isRecord(value: unknown): value is Record<string, unknown> {
195 return typeof value === 'object' && value !== null && !Array.isArray(value)
196}
197
198/** The string entries of `value`, each passed through `clean`; others dropped. */
199function stringsOf(value: unknown, clean: (text: string) => string): Record<string, string> {
200 if (!isRecord(value)) {
201 return {}
202 }
203
204 const out: Record<string, string> = {}
205
206 for (const [key, entry] of Object.entries(value)) {
207 if (typeof entry === 'string') {
208 out[key] = clean(entry)
209 }
210 }
211
212 return out
213}
214
215/** A color the engine accepts, or empty for the default foreground. */
216function colorOf(text: string): string {
217 return COLOR_PATTERN.test(text) ? text : ''
218}
219
220function iconOf(text: string): string {
221 return printable(text, MAX_ICON_CHARS)
222}
223
224function plainOf(text: string): string {
225 return printable(text, MAX_REASON_CHARS)
226}
227
228/**
229 * Whether to ask the HUD for its look: always, unless the stored look came
230 * from the HUD run with this binary and preset. The built-in look is never
231 * kept, so a binary that gains `style` later is picked up.
232 */
233export function isStyleStale(stored: TchStyleSource | null, binary: string, preset: string): boolean {
234 return !(stored?.kind === 'hud' && stored.binary === binary && stored.preset === preset)
235}
236hooks/waiting.ts 22 lines1// The approval breadcrumb tail-claude-hud's `permission` widget reads: one
2// file per session waiting on an approval, which other sessions' status lines
3// show as an alert. The HUD's own `hook permission-request` writes the same file.
4
5/** Where the HUD keeps breadcrumbs, under the home folder. */
6const WAITING_DIR_SUFFIX = '/.config/tail-claude-hud/waiting'
7
8/** The breadcrumb file of a session. */
9export function waitingPathOf(home: string, sessionId: string): string {
10 return `${home}${WAITING_DIR_SUFFIX}/${sessionId}`
11}
12
13/**
14 * The breadcrumb's JSON, as the HUD writes it: the session, its project (the
15 * working directory's last component, empty at a root) and the tool waiting.
16 */
17export function breadcrumbOf(sessionId: string, cwd: string, tool: string): string {
18 const project = cwd.split('/').filter(part => part !== '').at(-1) ?? ''
19
20 return JSON.stringify({ session_id: sessionId, project, tool_name: tool })
21}
22hooks/text.ts 160 lines1// Portions adapted from whats-agent-doing (MIT, Copyright (c) 2026 Tzafrir
2// Rehan); see NOTICE.
3
4/** Collapses runs of whitespace, newlines included, to single spaces. */
5export function oneLine(text: string): string {
6 return text.replace(/\s+/g, ' ').trim()
7}
8
9/** Cuts `text` to `max` characters, marking the cut with an ellipsis. */
10export function clip(text: string, max: number): string {
11 return text.length > max ? `${text.slice(0, max - 1)}…` : text
12}
13
14/**
15 * A label safe to draw: control, format and line-separator characters out
16 * (the engine refuses a whole tree holding one), whitespace collapsed, and
17 * cut to `max`. Labels carry paths, commands and thoughts the model wrote.
18 */
19export function printable(text: string, max: number): string {
20 const spaced = text.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').replace(/\p{Cf}/gu, '')
21
22 return clip(oneLine(spaced), max)
23}
24
25/** A sentence this short has barely started; its predecessor gives context. */
26const SHORT_SENTENCE_CHARS = 25
27
28/**
29 * The sentence Claude is thinking right now: the tail of the thought so far,
30 * with the sentence before it when the current one has barely started.
31 *
32 * @param thought the thinking block's text so far
33 * @param max the longest snippet, cut from the front
34 */
35export function thoughtOf(thought: string, max: number): string {
36 const flat = oneLine(thought.replace(/[*_`#>]/g, ''))
37
38 if (flat === '') {
39 return ''
40 }
41
42 const sentences = flat.split(/(?<=[.!?:])\s+/)
43 const last = sentences.at(-1) ?? ''
44 const before = sentences.at(-2)
45 const tail = last.length < SHORT_SENTENCE_CHARS && before !== undefined ? `${before} ${last}` : last
46
47 return tail.length > max ? `…${tail.slice(-(max - 1))}` : tail
48}
49
50export function plural(count: number, word: string): string {
51 return `${count} ${word}${count === 1 ? '' : 's'}`
52}
53
54/** `840 B`, `2.3 KB`, `1.1 MB`. */
55export function sizeOf(chars: number): string {
56 if (chars < 1024) {
57 return `${chars} B`
58 }
59
60 if (chars < 1024 * 1024) {
61 return `${(chars / 1024).toFixed(1)} KB`
62 }
63
64 return `${(chars / 1024 / 1024).toFixed(1)} MB`
65}
66
67/**
68 * A finished duration as tail-claude-hud's widgets print it:
69 * `<0.1s`, `0.6s`, `12.3s`, `1m 48s`, `1h 3m`.
70 */
71export function durationOf(ms: number): string {
72 const whole = Math.floor(ms)
73
74 if (whole <= 0) {
75 return '0.0s'
76 }
77
78 if (whole < 100) {
79 return '<0.1s'
80 }
81
82 if (whole < 60_000) {
83 const seconds = Math.floor(whole / 1000)
84 const tenths = Math.floor((whole % 1000) / 100)
85
86 return tenths === 0 ? `${seconds}s` : `${seconds}.${tenths}s`
87 }
88
89 if (whole < 3_600_000) {
90 const minutes = Math.floor(whole / 60_000)
91 const seconds = Math.floor((whole % 60_000) / 1000)
92
93 return `${minutes}m ${seconds}s`
94 }
95
96 const hours = Math.floor(whole / 3_600_000)
97 const minutes = Math.floor((whole % 3_600_000) / 60_000)
98
99 return `${hours}h ${minutes}m`
100}
101
102/** Under this, a running timer shows tenths of a second. */
103export const TENTHS_UNDER_MS = 10_000
104
105/**
106 * A running timer: tenths under ten seconds (`4.2s`), whole seconds to a
107 * minute, then minutes and seconds.
108 */
109export function elapsedOf(ms: number): string {
110 const clamped = Math.max(0, ms)
111
112 if (clamped < TENTHS_UNDER_MS) {
113 return `${(Math.floor(clamped / 100) / 10).toFixed(1)}s`
114 }
115
116 const total = Math.floor(clamped / 1000)
117
118 if (total < 60) {
119 return `${total}s`
120 }
121
122 const hours = Math.floor(total / 3600)
123 const minutes = Math.floor((total % 3600) / 60)
124
125 return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m ${total % 60}s`
126}
127
128/**
129 * Counts the words a streamed chunk adds, carrying whether the text so far
130 * ends inside a word, so a reply is never counted again from its start.
131 *
132 * @param chunk the newly streamed text
133 * @param isInWord whether the text before it ended inside a word
134 */
135export function wordsAdded(chunk: string, isInWord: boolean): { added: number; isInWord: boolean } {
136 const words = chunk.match(/\S+/g)?.length ?? 0
137 const continues = isInWord && /^\S/.test(chunk)
138
139 return {
140 added: continues ? words - 1 : words,
141 isInWord: chunk === '' ? isInWord : /\S$/.test(chunk),
142 }
143}
144
145/**
146 * A prompt as the history lists it. A turn the engine started with a tagged
147 * message (`<agent-message …>`, `<task-notification>`) is named by its tag,
148 * since the markup says nothing to a reader.
149 */
150export function promptLabel(text: string, max: number): string {
151 const flat = oneLine(text)
152 const tag = /^<([a-z][a-z-]*)[\s>]/.exec(flat)?.[1]
153
154 return tag === undefined ? clip(flat, max) : capitalized(tag.replace(/-/g, ' '))
155}
156
157export function capitalized(text: string): string {
158 return text.charAt(0).toUpperCase() + text.slice(1)
159}
160types/index.d.ts 106 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
18export type ActivityOutcome = 'ok' | 'error' | 'denied' | 'interrupted'
19
20/**
21 * The box's headline: the phase, its words, and when it began. `tool` is the
22 * tool the headline is about (its category picks the icon); `colorIndex` the
23 * agent palette slot when the headline names an agent, and `lead` the agent's
24 * name, drawn in that color before `label`; `detail` a count or size drawn
25 * faint after it (`120 words`); `lastOutcome` how the previous turn ended,
26 * for the idle glyph.
27 */
28export type ActivityNow = {
29 phase: ActivityPhase
30 label: string
31 detail: string | null
32 sinceMs: number
33 tool: string | null
34 colorIndex: number | null
35 lead: string | null
36 lastOutcome: ActivityOutcome | null
37}
38
39/**
40 * A subagent at work: what it was asked to do, its latest action, when it
41 * started, its palette slot (its spawn order in the session), and whether a
42 * call of its waits on your approval.
43 */
44export type ActivityAgent = {
45 name: string
46 label: string
47 sinceMs: number
48 colorIndex: number
49 isWaiting: boolean
50}
51
52/**
53 * One row of the expanded box: a prompt that started a turn, a stretch of
54 * thinking, a reply, a tool call, a compaction, or how a turn ended. `tool`
55 * names the tool of a `tool` row.
56 */
57export type ActivityEntry = {
58 kind: 'turn' | 'thought' | 'reply' | 'tool' | 'compact' | 'end'
59 label: string
60 durationMs: number | null
61 outcome: ActivityOutcome
62 tool: string | null
63}
64
65/**
66 * The look tail-claude-hud draws with, as `tail-claude-hud style --json`
67 * prints it (schema 1). Colors are mod color strings (`ansi256(N)`,
68 * `#rrggbb`), or empty for the terminal's default foreground.
69 */
70export type TchStyle = {
71 iconMode: string
72 icons: Readonly<Record<string, string>>
73 colors: Readonly<Record<string, string>>
74 agentColors: readonly string[]
75 toolCategories: {
76 byName: Readonly<Record<string, string>>
77 default: string
78 icons: Readonly<Record<string, string>>
79 }
80}
81
82/**
83 * Where the box's look came from: the HUD binary run with `preset` (empty
84 * for none), or the built-in look with the reason that binary could not
85 * supply one.
86 */
87export type TchStyleSource =
88 | { kind: 'hud'; binary: string; preset: string }
89 | { kind: 'builtin'; reason: string; binary: string; preset: string }
90
91declare module 'claude-code' {
92 interface PluginState {
93 'tch-activity': {
94 now: ActivityNow
95 history: readonly ActivityEntry[]
96 agents: readonly ActivityAgent[]
97 isExpanded: boolean
98 width: number
99 style: TchStyle | null
100 styleSource: TchStyleSource | null
101 agentOrder: readonly string[]
102 lastTurn: { label: string; outcome: ActivityOutcome } | null
103 }
104 }
105}
106