SLOPSHOPPER

tail-claude-hud activity

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…

newbandrowsguardprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tch-activity
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ▸ ✓ Idle · last turn done in 0.7s, 9 actions │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

tch-activity

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.

Install

/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.

Where the look comes from

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:

  1. The binary option (/plugin → tch-activity → configure, or pluginConfigs."tch-activity".options.binary in settings).
  2. ~/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.
  3. 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.

What it shows

StateHeadline
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 toolthe tool's category icon in the running color, then its label: 󰯠 Wait five seconds,  Reading app.ts, 󰖟 Fetching example.org
Parallel callsthe 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 turnsIdle · 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.

Data and privacy

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.

Develop

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.

FileWhat it does
hooks/register.tsxThe 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.tsThe live view of the session and the headline it yields (pure)
hooks/marks.tsThe glyph and color for each state, outcome and history row (pure)
hooks/style.tsThe built-in look, parsing style --json, and icon and color lookups (pure)
hooks/activity-of.tsA tool call, whole or streaming, as a short label
hooks/text.tsPrintable labels, thought snippets, word counts, sizes, durations
hooks/waiting.tsThe approval breadcrumb's path and contents (pure)
types/index.d.tsThe $.state contract
tests/The turn state by state, the history toggle, hostile labels, approvals, agents and their palette, the HUD look and its fallback

License

MIT. Portions are adapted from whats-agent-doing by Tzafrir Rehan (MIT); see NOTICE.

Source 9 files
hooks/register.tsx 1229 lines
1// 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 lines
1// 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}
120
hooks/live.ts 373 lines
1// 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}
373
hooks/layout.ts 109 lines
1/** 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}
109
hooks/marks.ts 142 lines
1// 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}
142
hooks/style.ts 236 lines
1import 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}
236
hooks/waiting.ts 22 lines
1// 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}
22
hooks/text.ts 160 lines
1// 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}
160
types/index.d.ts 106 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
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