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…

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:
| detail | the row draws |
|---|---|
none (the default) | the line alone |
compact | inputs cut to 10 lines, outputs to 5 |
input | inputs and the line, no outputs |
full | the engine's own row, and the line |
/tool-detail resets every row.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
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.
Once claude-mods is added as a marketplace (see its README):
claude plugin install tool-status@asemptote
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.
hooks/register.tsx 392 lines1import { 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}
392hooks/format.ts 169 lines1// 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
169hooks/line.tsx 33 lines1import 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
33types/index.d.ts 48 lines1/** 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