SLOPSHOPPER

tool-status

Names the running tool call on the spinner, with what it is doing under it and its time, and the time since the last call while none runs. Each tool row gets a…

newspinnerrowsguardcommandtimer
v1.3.0MITupdated 2026-10-26asemptote/claude-mods/tool-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-status
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⟨Claude Code's own drawing⟩ ▣ client module ./line.tsx ⏺ 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 › /tool-detail ⎿ tool-status: Tool detail: compact (inputs cut to 10 lines, outputs to 5). ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
⟨Claude Code's own drawing⟩ ▣ client module ./line.tsx
README

tool-status

A Claude Code mod that says what the agent is doing while it works, and keeps the transcript's tool rows and other parties' messages short until you ask for more.

Spinner. While a tool runs, the spinner names it and counts its time, with what the call is doing on the line under it:

✻ Bash 12s (+1 more)
  Run the test suite

Between calls it says what the turn is doing in place of the random verb, and how long since the last call ended (✗ if it failed):

✻ Thinking… 5s since Bash (12.4s)
  Run the test suite

Subagents' spinners follow their own calls. A state the engine names (compacting, a retry) still shows as the engine draws it.

Tool rows. Every tool row gets a line naming the call, with the lines it added and removed, its time (counting up while it runs), and any failure in red:

  Edit register.tsx · +12/-3
  Run the test suite · ✗ failed after 4.2s

In the ctrl+o transcript the line opens with the call's place since your prompt; an interrupted turn's count carries on into the next prompt, and a subagent's calls are not numbered:

  3. Edit register.tsx · +12/-3

/tool-detail [none|compact|input|full] sets how much of every row shows:

detailthe row draws
none (the default)the line alone
compactinputs cut to 10 lines, outputs to 5
inputinputs and the line, no outputs
fullthe engine's own row, and the line
  • Without an argument it steps to the next one; the choice is kept across sessions.
  • Clicking a row's line steps that row alone: left click forward, right or ctrl+click back. The next /tool-detail resets every row.
  • The ctrl+o transcript follows the same choices, and shows each row of a run the main view folds into one line (Ran 2 shell commands).

Messages. In the ctrl+o transcript, a message another agent, teammate, session or channel sent folds the same way, to a line naming its sender with its first line and length; compact cuts the body to 10 lines, input and full show it whole, and a click steps it as it does a tool row:

  Explore: Found three call sites of setLoop · 42 lines

Options

requireBashDescription, off by default, refuses any Bash call that has no description and asks the model to retry with one. Turn it on in /config, or in ~/.claude/settings.json, keyed by the installed id (tool-status alone is ignored):

"pluginConfigs": { "tool-status@asemptote": { "options": { "requireBashDescription": true } } }

numberRows, on by default, numbers the rows in ctrl+o; turn it off to drop the numbers. Set it the same way.

Install

Once claude-mods is added as a marketplace (see its README):

claude plugin install tool-status@asemptote

Developing

From the repository's root:

claude plugin validate tool-status
claude plugin test tool-status

Loaded from a folder (--plugin-dir, CLAUDE_CODE_PLUGIN_DIRS), its options are keyed tool-status@inline.

Source 4 files
hooks/register.tsx 392 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput, RenderPropsOf, Timer } from 'claude-code'
3
4import type { Detail, LoopActivity, Run } from '../types'
5import {
6  DETAIL_OPTIONS,
7  DETAILS,
8  INPUT_LINES,
9  OUTPUT_LINES,
10  age,
11  compact,
12  describe,
13  diffOf,
14  firstLine,
15  lineCount,
16  numberCalls,
17  parseDetail,
18  stepDetail,
19  took,
20} from './format'
21
22const loops = atom({ plugin: 'tool-status', key: 'loops' } as const, {})
23const now = atom({ plugin: 'tool-status', key: 'now' } as const, 0)
24// Where a session starts until `/tool-detail` chooses.
25const INITIAL_DETAIL: Detail = 'none'
26const detail = atom({ plugin: 'tool-status', key: 'detail' } as const, INITIAL_DETAIL)
27const epoch = atom({ plugin: 'tool-status', key: 'epoch' } as const, 0)
28// Families: one member per tool call, by its tool_use_id.
29const VIEWS = { plugin: 'tool-status', key: 'views' } as const
30const CALLS = { plugin: 'tool-status', key: 'calls' } as const
31
32const MAIN = 'main'
33const STORE_KEY = 'detail'
34const LINE_KEY = 'tool-detail'
35const EMPTY: LoopActivity = { running: [], last: null }
36
37// Said in place of the terminal's random spinner verb (`Sauteing`), by what
38// the turn is doing.
39const MODE_LABELS: Record<RenderPropsOf['Spinner']['mode'], string> = {
40  requesting: 'Waiting for model',
41  thinking: 'Thinking',
42  responding: 'Responding',
43  'tool-input': 'Preparing tool call',
44  'tool-use': 'Running tool',
45}
46
47const MISSING_DESCRIPTION =
48  'Bash calls need a description: retry with a short description of what the command does, and give one on every Bash call.'
49
50const isBackgrounded = (result: unknown) =>
51  typeof result === 'object' && result !== null && typeof (result as { backgroundTaskId?: unknown }).backgroundTaskId === 'string'
52
53// A row's detail: what a click on it chose since the last `/tool-detail`, else
54// the command's. A row reads its own member, so a click redraws that row alone.
55async function detailOf($: EngineInterface, id: string): Promise<Detail> {
56  const view = await read($, { ...VIEWS, id })
57  return view !== undefined && view.epoch === (await read($, epoch)) ? view.detail : read($, detail)
58}
59
60async function stepRow($: EngineInterface, id: string, step: 1 | -1) {
61  const next = stepDetail(await detailOf($, id), step)
62  const current = await read($, epoch)
63  await update($, { ...VIEWS, id }, () => ({ detail: next, epoch: current }))
64}
65
66// The command's choice is every row's, the clicked ones' too, and outlives the session.
67async function setDetail($: EngineInterface, chosen: Detail) {
68  await update($, detail, () => chosen)
69  await update($, epoch, n => n + 1)
70  await $.store.set(STORE_KEY, chosen)
71}
72
73function setLoop($: EngineInterface, key: string, fn: (activity: LoopActivity) => LoopActivity | undefined) {
74  return update($, loops, all => {
75    const nextAll = { ...all }
76    const changed = fn(all[key] ?? EMPTY)
77    if (changed === undefined) delete nextAll[key]
78    else nextAll[key] = changed
79    return nextAll
80  })
81}
82
83// Ticks `now` once a second while some loop runs a tool or sits idle after
84// one, so spinners and running rows move; stops itself once none does. A
85// reload cancels the timer along with this variable.
86let ticker: Timer | undefined
87
88async function tick($: EngineInterface) {
89  const all = await read($, loops)
90  if (!Object.values(all).some(a => a.running.length > 0 || a.last !== null)) {
91    ticker?.cancel()
92    ticker = undefined
93    return
94  }
95  const t = await $.clock.now()
96  await update($, now, () => t)
97}
98
99const ensureTicker = ($: EngineInterface) => {
100  ticker ??= $.clock.every(1000, () => void tick($))
101}
102
103// The main loop's calls since the prompt, each numbered as it starts. A
104// prompt counts from 1 only after an answered turn: one after an interruption
105// goes on with the count, as does one sent mid-turn, which starts no turn.
106let callCount = 0
107let isAnswered = true
108
109// Numbers for the calls this process did not see run (a resumed session's),
110// read from the transcript again only for a row it has not been asked about:
111// one chain, so the rows drawn together share one read.
112let fromTranscript: Promise<Map<string, number>> = Promise.resolve(new Map())
113const asked = new Set<string>()
114
115function transcriptNumber($: EngineInterface, id: string): Promise<number | undefined> {
116  if (!asked.has(id)) {
117    asked.add(id)
118    fromTranscript = fromTranscript.then(async known => {
119      if (known.has(id)) return known
120      try {
121        return numberCalls(await $.session.messages()).numbers
122      } catch {
123        return known
124      }
125    })
126  }
127  return fromTranscript.then(known => known.get(id))
128}
129
130// Where the count stands at the transcript's end, so a reload or a resume
131// goes on with it; unread, it starts over.
132async function resumeCount($: EngineInterface) {
133  try {
134    const numbered = numberCalls(await $.session.messages())
135    callCount = numbered.count
136    isAnswered = numbered.isAnswered
137    fromTranscript = Promise.resolve(numbered.numbers)
138  } catch {
139    // The transcript unread, the next prompt counts from 1.
140  }
141}
142
143const SEPARATOR: Run = { text: ' · ', color: null, dim: true }
144const dim = (text: string): Run => ({ text, color: null, dim: true })
145
146// A row's line, which a click steps. A `Client` tells the buttons apart;
147// mobile's table answers `Client` too, but the surface refuses it, so there
148// and elsewhere a Button steps forward.
149function lineOf($: EngineInterface, e: RenderInput, runs: Run[]) {
150  const elements = $.ui.resolve(e)
151  if ((e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in elements) {
152    const { Client } = elements
153    return <Client key={LINE_KEY} module="./line.tsx" props={{ runs }} />
154  }
155  const { Button, Text } = elements
156  return (
157    <Button key={LINE_KEY} plain onPress={() => stepRow($, e.requestId, 1)}>
158      {runs.map((run, i) => (
159        <Text key={String(i)} color={run.color ?? undefined} dimColor={run.dim}>
160          {run.text}
161        </Text>
162      ))}
163    </Button>
164  )
165}
166
167export const register: Register = (on, options) => {
168  const requireBashDescription = options.requireBashDescription === true
169  const numberRows = options.numberRows === true
170
171  on('session.start', async ($, e, next) => {
172    const stored = await $.store.get(STORE_KEY)
173    const chosen = (typeof stored === 'string' && parseDetail(stored)) || INITIAL_DETAIL
174    await update($, detail, () => chosen)
175    if (numberRows) await resumeCount($)
176    await $.command.register({
177      name: 'tool-detail',
178      description: 'How much of each tool call the transcript shows: none, compact, input or full; the next one without an argument',
179      argumentHint: `[${DETAIL_OPTIONS}]`,
180      immediate: true,
181    })
182    return next(e)
183  })
184
185  on('command.run', { command: 'tool-detail' }, async ($, e) => {
186    const args = e.args.trim()
187    const chosen = args === '' ? stepDetail(await read($, detail), 1) : parseDetail(args)
188    if (chosen === undefined) return { text: `Usage: /tool-detail [${DETAIL_OPTIONS}]` }
189    await setDetail($, chosen)
190    return { text: `Tool detail: ${chosen} (${DETAILS[chosen]}).` }
191  })
192
193  // A line's click, from its `Client`: `{ step: 1 }` or `{ step: -1 }`.
194  on('ui.message', async ($, e, next) => {
195    if (e.element !== LINE_KEY) return next(e)
196    const step = (e.data as { step?: unknown } | null)?.step
197    if (step === 1 || step === -1) await stepRow($, e.requestId, step)
198    return {}
199  })
200
201  on('turn.start', async ($, e, next) => {
202    if (e.agentId === undefined && e.text !== '' && isAnswered) callCount = 0
203    await setLoop($, e.agentId ?? MAIN, () => undefined)
204    return next(e)
205  })
206
207  on('tool.call', async ($, e, next) => {
208    // Numbered first, so parallel calls take theirs in the order they were
209    // made, and a refused call, whose row is drawn too, takes one.
210    const n = e.agentId === undefined ? ++callCount : undefined
211    const own = (e as { description?: unknown }).description
212    if (requireBashDescription && e.tool === 'Bash' && (typeof own !== 'string' || own.trim() === '')) {
213      return { deny: MISSING_DESCRIPTION }
214    }
215
216    const key = e.agentId ?? MAIN
217    const id = e.tool_use_id
218    const { label, detail: what = null } = describe(e.tool, e)
219    const startedAt = await $.clock.now()
220    await setLoop($, key, a => ({ ...a, running: [...a.running, { id, label, detail: what, startedAt }] }))
221    const numbered = n === undefined ? {} : { n }
222    await update($, { ...CALLS, id }, () => ({ startedAt, endedAt: null, ...numbered }))
223    ensureTicker($)
224
225    let isErrored = true
226    let backgrounded = false
227    try {
228      const ran = await next(e)
229      isErrored = ran.deny !== undefined || ran.isError === true
230      backgrounded = isBackgrounded(ran.result)
231      return ran
232    } finally {
233      const endedAt = await $.clock.now()
234      const ms = endedAt - startedAt
235      await update($, now, () => endedAt)
236      await update($, { ...CALLS, id }, () => ({ startedAt, endedAt, ...numbered }))
237      // One write makes the call the idle line as it leaves the running list,
238      // so the spinner never names the call before it in between.
239      await setLoop($, key, a => ({
240        running: a.running.filter(c => c.id !== id),
241        last: { label, detail: what, endedAt, ms, isErrored, isBackgrounded: backgrounded },
242      }))
243    }
244  })
245    // A fault here must never cost the call, nor leave it on the spinner.
246    .catch(async ($, e, next) => {
247      await setLoop($, e.agentId ?? MAIN, a => ({ ...a, running: a.running.filter(c => c.id !== e.tool_use_id) }))
248      return next(e)
249    })
250
251  on('turn.complete', async ($, e, next) => {
252    if (e.agentId === undefined) isAnswered = !e.isAborted
253    await setLoop($, e.agentId ?? MAIN, () => undefined)
254    return next(e)
255  })
256
257  // The spinner names the newest running call's tool and its time, with what
258  // it is doing whole on the line under it; while none runs, what the turn is
259  // doing and how long since the last call ended.
260  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
261    const all = await read($, loops)
262    const activity = all[e.requestId] ?? all[MAIN]
263    const under = async (message: string, what: string | null) => {
264      const line = await next({ ...e, props: { ...e.props, message, suffix: '' } })
265      if (what === null) return line
266      const { Box, Text } = $.ui.resolve(e)
267      return (
268        <Box flexDirection="column">
269          {line}
270          <Box paddingLeft={2}>
271            <Text bold color="claude">
272              {what}
273            </Text>
274          </Box>
275        </Box>
276      )
277    }
278
279    const current = activity?.running.at(-1)
280    if (activity !== undefined && current !== undefined) {
281      const others = activity.running.length - 1
282      const elapsed = age((await read($, now)) - current.startedAt)
283      return under(`${current.label} ${elapsed}${others > 0 ? ` (+${others} more)` : ''}`, current.detail)
284    }
285
286    // A state the engine names (compacting, a retry) outranks the idle line.
287    if (e.props.message !== null) return next(e)
288    // The desktop's word is already the step's own description.
289    const lead = e.surface === 'terminal' ? MODE_LABELS[e.props.mode] : e.props.word
290    const last = activity?.last
291    if (last == null) return next({ ...e, props: { ...e.props, message: lead } })
292    const since = age((await read($, now)) - last.endedAt)
293    const how = [took(last.ms), ...(last.isBackgrounded ? ['backgrounded'] : [])].join(', ')
294    const ellipsis = lead.endsWith('…') ? '' : '…'
295    return under(`${lead}${ellipsis} ${since} since ${last.isErrored ? '✗ ' : ''}${last.label} (${how})`, last.detail)
296  })
297
298  // Every tool row gets a line under it, in every detail and on every surface,
299  // so a click can always step it: the call named as the spinner did, the
300  // lines a file change added and removed, its time (counting while it runs),
301  // and a failure in red. In `none` that line is the row, ctrl+o included,
302  // where it opens with the call's place since the prompt.
303  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
304    const { tool, input, output, isRunning, isErrored, isInterrupted } = e.props
305    const shown = await detailOf($, e.requestId)
306    const record = await read($, { ...CALLS, id: e.requestId })
307    const ms = record?.endedAt != null ? record.endedAt - record.startedAt : undefined
308    // From the stored result, so rows of a resumed session count too.
309    const diff = !isRunning && !isInterrupted && !isErrored ? diffOf(output) : undefined
310    const n = numberRows && e.props.isExpanded === true ? (record?.n ?? (await transcriptNumber($, e.requestId))) : undefined
311
312    const name = describe(tool, input).text
313    const parts: Run[][] = [
314      n === undefined
315        ? [{ text: `  ${name}`, color: 'claude', dim: false }]
316        : [dim(`  ${n}. `), { text: name, color: 'claude', dim: false }],
317    ]
318    if (diff !== undefined) {
319      parts.push([
320        { text: `+${diff.added}`, color: 'success', dim: false },
321        dim('/'),
322        { text: `-${diff.removed}`, color: 'error', dim: false },
323      ])
324    }
325    if (isRunning) parts.push([dim(record ? age((await read($, now)) - record.startedAt) : 'running')])
326    else if (isInterrupted) {
327      if (shown === 'none') parts.push([dim('interrupted')])
328    } else if (isErrored) {
329      parts.push([{ text: ms === undefined ? '✗ failed' : `✗ failed after ${took(ms)}`, color: 'error', dim: false }])
330    } else if (ms !== undefined && ms >= 1000) parts.push([dim(took(ms))])
331    if (isBackgrounded(output)) parts.push([dim('backgrounded')])
332    const runs = parts.flatMap((part, i) => (i === 0 ? part : [SEPARATOR, ...part]))
333
334    const line = lineOf($, e, runs)
335    if (shown === 'none') return line
336
337    const { output: _output, ...withoutOutput } = e.props
338    const props =
339      shown === 'compact'
340        ? {
341            ...e.props,
342            input: compact(input, INPUT_LINES),
343            ...(output !== undefined && { output: compact(output, OUTPUT_LINES) }),
344          }
345        : shown === 'input'
346          ? withoutOutput
347          : e.props
348    const { Box } = $.ui.resolve(e)
349    return (
350      <Box flexDirection="column">
351        {await next({ ...e, props })}
352        {line}
353      </Box>
354    )
355  })
356
357  // A message another party sent (an agent, a teammate, a session, a channel)
358  // folds in ctrl+o as a tool row does, to a line naming the sender with the
359  // message's first line; `compact` cuts the body, `input` and `full` show it
360  // whole. The main view draws its own one line.
361  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
362    const { text, from, isExpanded } = e.props
363    if (!isExpanded || from === undefined) return next(e)
364    const shown = await detailOf($, e.requestId)
365    const count = lineCount(text)
366    const runs: Run[] = [{ text: `  ${from.name}: ${firstLine(text) ?? ''}`, color: 'claude', dim: false }]
367    if (count > 1) runs.push(SEPARATOR, dim(`${count} lines`))
368    const line = lineOf($, e, runs)
369    if (shown === 'none') return line
370    const props = shown === 'compact' ? { ...e.props, text: compact(text, INPUT_LINES) as string } : e.props
371    const { Box } = $.ui.resolve(e)
372    return (
373      <Box flexDirection="column">
374        {await next({ ...e, props })}
375        {line}
376      </Box>
377    )
378  })
379
380  // The result under a row: cut in `compact`, gone in `input` and `none`.
381  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
382    const shown = await detailOf($, e.requestId)
383    if (shown === 'full') return next(e)
384    if (shown === 'compact') return next({ ...e, props: { ...e.props, output: compact(e.props.output, OUTPUT_LINES) } })
385    const { Box } = $.ui.resolve(e)
386    return <Box />
387  })
388
389  // The line closing a turn (`Baked for 3s`) samples a random verb too.
390  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => next({ ...e, props: { ...e.props, word: 'Worked' } }))
391}
392
hooks/format.ts 169 lines
1// What the hooks draw, from plain values: no `$`, so the tests call it directly.
2
3import type { Detail } from '../types'
4
5const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
6
7/** A call as the spinner and its row name it. */
8export type Described = {
9  /** The tool, an MCP tool as `server:tool`: the spinner's first line. */
10  label: string
11  /** What the call is doing (its description, else its most telling argument): the line under it. */
12  detail: string | undefined
13  /** The row's line: the description alone, else the label with its argument. */
14  text: string
15}
16
17const MAX_LENGTH = 160
18const PATH_ARGS = ['file_path', 'notebook_path']
19const TEXT_ARGS = ['pattern', 'url', 'query', 'skill', 'subject', 'command', 'prompt', 'message']
20
21// The first non-blank line, its runs of space collapsed, cut past MAX_LENGTH.
22export const firstLine = (value: unknown): string | undefined => {
23  if (typeof value !== 'string') return undefined
24  const line = value.split('\n').find(l => l.trim() !== '')
25  if (line === undefined) return undefined
26  const flat = line.replace(/\s+/g, ' ').trim()
27  return flat.length > MAX_LENGTH ? `${flat.slice(0, MAX_LENGTH - 1)}…` : flat
28}
29
30export const toolLabel = (tool: string): string => {
31  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
32  return mcp ? `${mcp[1]}:${mcp[2]}` : tool
33}
34
35export function describe(tool: string, input: unknown): Described {
36  const label = toolLabel(tool)
37  const args = isRecord(input) ? input : {}
38  const own = firstLine(args.description)
39  if (own !== undefined) return { label, detail: own, text: own }
40  for (const key of PATH_ARGS) {
41    const path = firstLine(args[key])
42    if (path !== undefined) {
43      const name = path.split(/[\\/]/).pop() || path
44      return { label, detail: name, text: `${label} ${name}` }
45    }
46  }
47  for (const key of TEXT_ARGS) {
48    const text = firstLine(args[key])
49    if (text !== undefined) return { label, detail: text, text: `${label} ${text}` }
50  }
51  return { label, detail: undefined, text: label }
52}
53
54/** How long ago, or how long so far: whole seconds, then minutes, then hours. */
55export function age(ms: number): string {
56  const s = Math.max(0, Math.floor(ms / 1000))
57  if (s < 60) return `${s}s`
58  if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
59  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
60}
61
62/** How long a finished call ran: tenths under ten seconds, where most calls land. */
63export const took = (ms: number): string => (ms < 10_000 ? `${(Math.max(0, ms) / 1000).toFixed(1)}s` : age(ms))
64
65export type Diff = { added: number; removed: number }
66
67export const lineCount = (text: string) => (text === '' ? 0 : text.replace(/\r?\n$/, '').split(/\r?\n/).length)
68
69const counted = (hunks: unknown[]): Diff => {
70  const diff = { added: 0, removed: 0 }
71  for (const hunk of hunks) {
72    if (!isRecord(hunk) || !Array.isArray(hunk.lines)) continue
73    for (const line of hunk.lines) {
74      if (typeof line !== 'string') continue
75      if (line.startsWith('+')) diff.added++
76      else if (line.startsWith('-')) diff.removed++
77    }
78  }
79  return diff
80}
81
82// The lines a call added and removed, read from the shape of its stored result
83// whatever the tool: a `structuredPatch` (Edit, Write), a created file's
84// content, a Bash call's `bashEditDiff`.
85export function diffOf(output: unknown): Diff | undefined {
86  if (!isRecord(output) || output.staged === true || typeof output.error === 'string') return undefined
87  if (Array.isArray(output.structuredPatch) && output.structuredPatch.length > 0) return counted(output.structuredPatch)
88  if (output.type === 'create' && typeof output.content === 'string') return { added: lineCount(output.content), removed: 0 }
89  const bash = output.bashEditDiff
90  if (isRecord(bash) && Array.isArray(bash.files) && bash.files.length > 0) {
91    return counted(bash.files.flatMap(file => (isRecord(file) && Array.isArray(file.hunks) ? file.hunks : [])))
92  }
93  return undefined
94}
95
96/** A transcript row as `$.session.messages()` gives it, as much as numbering reads. */
97export type Row = {
98  role: 'user' | 'assistant'
99  text: string
100  toolUses: ReadonlyArray<{ tool_use_id: string }>
101  toolResults?: readonly unknown[]
102}
103
104/** The transcript's calls numbered, and where the count stands at its end. */
105export type Numbered = {
106  numbers: Map<string, number>
107  count: number
108  /** Whether the last turn was answered, so the next prompt counts from 1. */
109  isAnswered: boolean
110}
111
112const INTERRUPTED = '[Request interrupted by user'
113
114// Each call's place among the calls since its prompt, from 1, read from the
115// transcript for the calls this process did not see run. A prompt starts a
116// count only after an answer (or opening the transcript): one after a tool
117// result or an interruption goes on with the count, as it does live.
118export function numberCalls(rows: readonly Row[]): Numbered {
119  const numbers = new Map<string, number>()
120  let count = 0
121  let isAnswered = true
122  for (const row of rows) {
123    if (row.role === 'assistant') {
124      for (const use of row.toolUses) numbers.set(use.tool_use_id, ++count)
125      isAnswered = row.toolUses.length === 0
126    } else if ((row.toolResults?.length ?? 0) > 0 || row.text.startsWith(INTERRUPTED)) isAnswered = false
127    else if (isAnswered && row.text.trim() !== '') count = 0
128  }
129  return { numbers, count, isAnswered }
130}
131
132// Each multi-line string, and each run of strings (a hunk's lines, a list of
133// files), cut to `max` lines, the last saying how many are left out. The cut
134// keeps the value's shape, so the tool's own renderer still draws it.
135export function compact(value: unknown, max: number): unknown {
136  if (typeof value === 'string') {
137    const lines = value.split('\n')
138    if (lines.length <= max) return value
139    return [...lines.slice(0, max - 1), `… +${lines.length - max + 1} lines`].join('\n')
140  }
141  if (Array.isArray(value)) {
142    if (value.length > max && value.every(item => typeof item === 'string')) {
143      // A leading space keeps the marker a context line inside a diff hunk.
144      return [...value.slice(0, max - 1), ` … +${value.length - max + 1} more`]
145    }
146    return value.map(item => compact(item, max))
147  }
148  if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, compact(item, max)]))
149  return value
150}
151
152export const INPUT_LINES = 10
153export const OUTPUT_LINES = 5
154
155/** In the order `/tool-detail` and a click step through them, least first. */
156export const DETAILS: Record<Detail, string> = {
157  none: 'the line alone',
158  compact: `inputs cut to ${INPUT_LINES} lines, outputs to ${OUTPUT_LINES}`,
159  input: 'inputs and the line, no outputs',
160  full: 'tool rows drawn in full',
161}
162const CYCLE = Object.keys(DETAILS) as Detail[]
163export const DETAIL_OPTIONS = CYCLE.join('|')
164
165export const parseDetail = (text: string): Detail | undefined => CYCLE.find(detail => detail === text.trim().toLowerCase())
166
167export const stepDetail = (detail: Detail, step: 1 | -1): Detail =>
168  CYCLE[(CYCLE.indexOf(detail) + step + CYCLE.length) % CYCLE.length] ?? detail
169
hooks/line.tsx 33 lines
1import type { ClientModule } from 'claude-code'
2
3import type { LineProps } from '../types'
4
5// A tool row's line, drawn as a region of its own so a click says which button
6// pressed it: left steps the row's detail forward, right or ctrl+left back,
7// posted to the hooks module, which stores it. Inverted while the pointer is
8// over it, as a Button is: every enter and leave sets it, since both can come
9// in one frame, before the redraw that would renew `isHovered` here. One outer
10// Text, so a long line wraps as one run.
11const Line: ClientModule<LineProps, boolean> = ({ runs }, surface) => {
12  const isHovered = surface.state === true
13  surface.onPointer(event => {
14    if (event.type === 'enter' || event.type === 'leave') surface.setState(event.type === 'enter')
15    else if (event.type === 'down') {
16      if (event.button === 'right' || (event.button === 'left' && event.ctrl)) surface.post({ step: -1 })
17      else if (event.button === 'left') surface.post({ step: 1 })
18    }
19  })
20  const { Text } = surface.elements
21  return (
22    <Text>
23      {runs.map((run, i) => (
24        <Text key={String(i)} color={run.color ?? undefined} dimColor={run.dim} inverse={isHovered}>
25          {run.text}
26        </Text>
27      ))}
28    </Text>
29  )
30}
31
32export default Line
33
types/index.d.ts 48 lines
1/** How much of a tool call its transcript row draws, least first. */
2export type Detail = 'none' | 'compact' | 'input' | 'full'
3
4/** A call in flight, as its loop's spinner names it. */
5export type RunningCall = { id: string; label: string; detail: string | null; startedAt: number }
6/** The call a loop finished last, which its spinner names while nothing runs. */
7export type FinishedCall = {
8  label: string
9  detail: string | null
10  endedAt: number
11  ms: number
12  isErrored: boolean
13  isBackgrounded: boolean
14}
15export type LoopActivity = { running: RunningCall[]; last: FinishedCall | null }
16
17/** A click's choice for one row, standing while `epoch` is unchanged. */
18export type RowView = { detail: Detail; epoch: number }
19/**
20 * When a call ran, and its place among the main loop's calls since the prompt
21 * (`n`, from 1; absent on a subagent's), which the row's props do not say.
22 */
23export type CallRecord = { startedAt: number; endedAt: number | null; n?: number }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'tool-status': {
28      /** Per loop: `main`, or a subagent's id. */
29      loops: Record<string, LoopActivity>
30      /** The clock as last ticked, so spinners and running rows redraw their times. */
31      now: number
32      /** `/tool-detail`'s choice, every row's unless a click chose another. */
33      detail: Detail
34      /** Bumped by each `/tool-detail`, so earlier clicks lapse. */
35      epoch: number
36      /** Per tool call, by its tool_use_id. */
37      views: StateFamily<RowView>
38      /** Per tool call, by its tool_use_id. */
39      calls: StateFamily<CallRecord>
40    }
41  }
42}
43
44/** One run of a row's line, as `Text` draws it. */
45export type Run = { text: string; color: 'claude' | 'success' | 'error' | null; dim: boolean }
46/** What the line's `Client` draws. */
47export type LineProps = { runs: Run[] }
48