Spinner says what Claude is doing, turn line counts the work, status line shows model, effort, context, limits and cost

The spinner says what Claude is doing now (Running npm test…, Reading src/a.ts…), not a random word. The turn line counts the work: ✓ Done in 1m 04s · 9 tools · 3 files changed · +48 −12. A status line shows the model, context fill, the 5-hour limit and the session cost.
hooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TurnStat } from '../types'
5import { closest, describe, diffsOf, statusLine, turnLine } from './describe'
6
7const activity = atom({ plugin: 'pulse', key: 'activity' } as const, [])
8const turns = atom({ plugin: 'pulse', key: 'turns' } as const, [])
9
10type Tally = { tools: number; files: Map<string, true>; added: number; removed: number }
11
12const fresh = (): Tally => ({ tools: 0, files: new Map(), added: 0, removed: 0 })
13
14type Usage = Parameters<typeof statusLine>[1]
15
16type Shown = { model: string; usage: Usage; effort: string | number | undefined; isOn: boolean; warnAt: number }
17
18async function showStatus($: EngineInterface, shown: Shown) {
19 if (shown.isOn) {
20 $.ui.status(statusLine(shown.model || (await $.session.model()), shown.usage, shown.effort, shown.warnAt))
21 }
22}
23
24export const register: Register = (on, options) => {
25 let cwd = ''
26 let tally = fresh()
27 let seq = 0
28 const shown: Shown = {
29 model: '',
30 usage: {},
31 effort: undefined,
32 isOn: options.statusLine !== false,
33 warnAt: Number(options.contextWarn ?? 75),
34 }
35
36 on('session.start', async ($, e, next) => {
37 const started = await next(e)
38 cwd = e.cwd
39 shown.usage = await $.session.usage()
40 await showStatus($, shown)
41
42 return started
43 })
44
45 // /clear fires no session.start, and the engine keeps reporting the last
46 // response's fill until the next turn measures the new conversation.
47 on('session.end', { reason: 'clear' }, async ($, e, next) => {
48 const ended = await next(e)
49 const usage = await $.session.usage()
50 shown.usage = { ...usage, context: { window: usage.context.window, tokens: 0, percent: 0 } }
51 await showStatus($, shown)
52
53 return ended
54 })
55
56 on('session.measure', async ($, e, next) => {
57 const measured = await next(e)
58 shown.usage = e
59 await showStatus($, shown)
60
61 return measured
62 })
63
64 // Each main-loop request names the model and effort it runs at.
65 on('turn.step', async function* ($, e, next) {
66 if (!e.agentId && (e.effort !== shown.effort || e.model !== shown.model)) {
67 shown.effort = e.effort
68 shown.model = e.model
69 await showStatus($ as EngineInterface, shown)
70 }
71
72 return yield* next(e)
73 })
74
75 on('turn.start', ($, e, next) => {
76 if (!e.agentId) {
77 tally = fresh()
78 }
79
80 return next(e)
81 })
82
83 on('tool.call', async ($, e, next) => {
84 if (e.agentId) {
85 return next(e)
86 }
87
88 seq += 1
89 const id = e.tool_use_id ?? `call-${seq}`
90 const text = describe(e as Parameters<typeof describe>[0], cwd)
91 await update($, activity, list => [...list, { id, text }])
92
93 try {
94 const ran = await next(e)
95 tally.tools += 1
96
97 for (const diff of diffsOf(e.tool, ran.result)) {
98 tally.files.set(diff.file, true)
99 tally.added += diff.added
100 tally.removed += diff.removed
101 }
102
103 return ran
104 } finally {
105 await update($, activity, list => list.filter(one => one.id !== id))
106 }
107 }).catch(($, e, next) => next(e))
108
109 on('turn.complete', async ($, e, next) => {
110 const done = await next(e)
111
112 if (!e.agentId) {
113 const stat: TurnStat = {
114 durationMs: e.durationMs,
115 tools: tally.tools,
116 files: tally.files.size,
117 added: tally.added,
118 removed: tally.removed,
119 }
120 await update($, turns, list => [...list, stat].slice(-200))
121 await update($, activity, () => [])
122 }
123
124 return done
125 })
126
127 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
128 if (options.spinner === false || e.surface !== 'terminal' || e.props.message !== null) {
129 return next(e)
130 }
131
132 const list = await read($, activity)
133 const latest = list[list.length - 1]
134 let message: string | undefined
135
136 if (e.props.mode === 'thinking') {
137 message = 'Thinking'
138 } else if (e.props.mode === 'responding') {
139 message = 'Writing reply'
140 } else if (e.props.mode === 'tool-use' && latest) {
141 message = list.length > 1 ? `${latest.text} (+${list.length - 1} more)` : latest.text
142 }
143
144 return message ? next({ ...e, props: { ...e.props, message } }) : next(e)
145 })
146
147 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
148 if (options.turnLine === false || e.surface !== 'terminal' || e.props.isExpanded || e.props.waitingFor) {
149 return next(e)
150 }
151
152 const stat = closest(await read($, turns), e.props.durationMs)
153 const { Text } = $.ui.resolve(e)
154
155 return <Text dimColor>{turnLine(e.props.durationMs, stat)}</Text>
156 })
157}
158hooks/describe.ts 269 lines1type Input = { tool: string } & Record<string, unknown>
2
3export type FileDiff = { file: string; added: number; removed: number }
4
5export type TurnStat = { durationMs: number; tools: number; files: number; added: number; removed: number }
6
7const HOME = /^\/(Users|home)\/[^/]+/
8
9export function relative(path: string, cwd: string): string {
10 if (cwd && path.startsWith(cwd + '/')) {
11 return path.slice(cwd.length + 1)
12 }
13
14 return path.replace(HOME, '~')
15}
16
17export function clip(text: string, max: number): string {
18 const line = text.split('\n')[0]!.replace(/\s+/g, ' ').trim()
19
20 return line.length > max ? line.slice(0, max - 1) + '…' : line
21}
22
23function str(input: Input, key: string): string {
24 const value = input[key]
25
26 return typeof value === 'string' ? value : ''
27}
28
29// What the main loop is doing, as one short present-tense phrase.
30export function describe(input: Input, cwd: string): string {
31 const file = relative(str(input, 'file_path') || str(input, 'notebook_path'), cwd)
32
33 switch (input.tool) {
34 case 'Read':
35 return `Reading ${file}`
36 case 'Edit':
37 case 'MultiEdit':
38 case 'NotebookEdit':
39 return `Editing ${file}`
40 case 'Write':
41 return `Writing ${file}`
42 case 'Bash':
43 return `Running ${clip(str(input, 'command'), 60)}`
44 case 'WebFetch':
45 return `Fetching ${clip(str(input, 'url').replace(/^https?:\/\//, ''), 50)}`
46 case 'WebSearch':
47 return `Searching the web for "${clip(str(input, 'query'), 40)}"`
48 case 'Agent': {
49 const type = str(input, 'subagent_type') || 'agent'
50
51 return `Waiting on ${type}: ${clip(str(input, 'description'), 40)}`
52 }
53 case 'Skill':
54 return `Using skill ${str(input, 'skill')}`
55 case 'TodoWrite':
56 case 'TaskCreate':
57 case 'TaskUpdate':
58 return 'Updating tasks'
59 }
60
61 const mcp = /^mcp__(.+?)__(.+)$/.exec(input.tool)
62
63 if (mcp) {
64 return `Calling ${mcp[1]} ${mcp[2]}`
65 }
66
67 return `Using ${input.tool}`
68}
69
70// The tool's short verb and what it acts on, for a ledger-style row.
71export function target(input: Input, cwd: string): { verb: string; what: string } {
72 const file = relative(str(input, 'file_path') || str(input, 'notebook_path'), cwd)
73
74 switch (input.tool) {
75 case 'Read':
76 case 'Write':
77 return { verb: input.tool, what: file }
78 case 'Edit':
79 case 'MultiEdit':
80 case 'NotebookEdit':
81 return { verb: 'Edit', what: file }
82 case 'Bash':
83 return { verb: 'Bash', what: clip(str(input, 'command'), 200) }
84 case 'WebFetch':
85 return { verb: 'Fetch', what: clip(str(input, 'url').replace(/^https?:\/\//, ''), 200) }
86 case 'WebSearch':
87 return { verb: 'Search', what: `"${clip(str(input, 'query'), 200)}" on the web` }
88 case 'Agent':
89 return { verb: 'Agent', what: `${str(input, 'subagent_type') || 'agent'} · ${clip(str(input, 'description'), 200)}` }
90 case 'Skill':
91 return { verb: 'Skill', what: str(input, 'skill') }
92 }
93
94 const mcp = /^mcp__(.+?)__(.+)$/.exec(input.tool)
95
96 if (mcp) {
97 return { verb: mcp[1]!, what: mcp[2]! }
98 }
99
100 const first = Object.entries(input).find(([key, value]) => key !== 'tool' && typeof value === 'string')
101
102 return { verb: input.tool, what: first ? clip(String(first[1]), 200) : '' }
103}
104
105type Ran = { result?: unknown; text?: string; isError?: boolean; deny?: string }
106
107// One plain summary of a finished call: lines read, diff size, the first error line.
108export function noteOf(tool: string, ran: Ran): string {
109 if (ran.deny !== undefined) {
110 return clip(`refused: ${ran.deny}`, 80)
111 }
112
113 if (ran.isError) {
114 const lines = (ran.text ?? '').split('\n').map(one => one.trim())
115 const code = lines.map(one => /^(?:error: )?exit code (\d+)/i.exec(one)).find(Boolean)
116 const first = lines.find(one => one && !/^(?:error: )?exit code \d+/i.test(one))
117 const parts = [code ? `exit ${code[1]}` : '', first ?? (code ? '' : 'failed')].filter(Boolean)
118
119 return clip(parts.join(' · '), 80)
120 }
121
122 const r = (ran.result ?? {}) as Record<string, unknown>
123
124 if (tool === 'Read') {
125 const file = r.file as { numLines?: number } | undefined
126
127 return typeof file?.numLines === 'number' ? `${file.numLines} lines` : ''
128 }
129
130 const diffs = diffsOf(tool, ran.result)
131
132 if (tool === 'Write' && r.type === 'create' && diffs[0]) {
133 return `new · ${diffs[0].added} lines`
134 }
135
136 if (diffs.length) {
137 const added = diffs.reduce((sum, d) => sum + d.added, 0)
138 const removed = diffs.reduce((sum, d) => sum + d.removed, 0)
139
140 return removed ? `+${added} −${removed}` : `+${added}`
141 }
142
143 return ''
144}
145
146type Hunk = { lines: string[] }
147
148function count(file: string, hunks: readonly Hunk[]): FileDiff {
149 let added = 0
150 let removed = 0
151
152 for (const hunk of hunks) {
153 for (const line of hunk.lines) {
154 if (line.startsWith('+')) added += 1
155 else if (line.startsWith('-')) removed += 1
156 }
157 }
158
159 return { file, added, removed }
160}
161
162// The files a finished call changed, with line counts, from the tool's own result.
163export function diffsOf(tool: string, result: unknown): FileDiff[] {
164 if (!result || typeof result !== 'object') {
165 return []
166 }
167
168 const r = result as Record<string, unknown>
169
170 if ((tool === 'Edit' || tool === 'Write') && typeof r.filePath === 'string') {
171 if (tool === 'Write' && r.type === 'create' && typeof r.content === 'string') {
172 return [{ file: r.filePath, added: r.content.replace(/\n$/, '').split('\n').length, removed: 0 }]
173 }
174
175 return [count(r.filePath, (r.structuredPatch as Hunk[] | undefined) ?? [])]
176 }
177
178 if (tool === 'Bash' && r.bashEditDiff && typeof r.bashEditDiff === 'object') {
179 const files = (r.bashEditDiff as { files?: { filePath: string; hunks: Hunk[] }[] }).files ?? []
180
181 return files.map(one => count(one.filePath, one.hunks))
182 }
183
184 return []
185}
186
187export function duration(ms: number): string {
188 const s = Math.round(ms / 1000)
189
190 if (s < 60) return `${s}s`
191 if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
192
193 return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
194}
195
196const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
197
198export function turnLine(durationMs: number, stat: TurnStat | undefined): string {
199 const parts = [`✓ Done in ${duration(durationMs)}`]
200
201 if (stat) {
202 parts.push(stat.tools ? plural(stat.tools, 'tool') : 'no tools')
203 if (stat.files) parts.push(`${plural(stat.files, 'file')} changed`)
204 if (stat.added || stat.removed) parts.push(`+${stat.added} −${stat.removed}`)
205 }
206
207 return parts.join(' · ')
208}
209
210// The turn whose recorded length is nearest to the line's, within 1.5 s.
211export function closest(list: readonly TurnStat[], durationMs: number): TurnStat | undefined {
212 let best: TurnStat | undefined
213 let gap = 1500
214
215 for (const stat of list) {
216 const d = Math.abs(stat.durationMs - durationMs)
217
218 if (d <= gap) {
219 best = stat
220 gap = d
221 }
222 }
223
224 return best
225}
226
227export function modelLabel(model: string): string {
228 const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(model)
229
230 return m ? `${m[1]}-${m[2]}.${m[3]}` : model.replace(/^claude-/, '')
231}
232
233type Usage = {
234 context?: { percent?: number; tokens?: number; window?: number }
235 rateLimits?: readonly { kind: string; percentUsed: number }[]
236 cost?: { usd: number }
237}
238
239// 950, 76k, 1M, 1.2M: token counts the way the status line reads them.
240export function tokenCount(n: number): string {
241 if (n < 1000) return String(n)
242 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
243
244 return `${Number((n / 1_000_000).toFixed(1))}M`
245}
246
247export function statusLine(model: string, usage: Usage, effort?: string | number, warnAt = 75): string {
248 const parts = [effort === undefined ? modelLabel(model) : `${modelLabel(model)} ${effort}`]
249 const pct = usage.context?.percent
250 const tokens = usage.context?.tokens
251 const window = usage.context?.window
252
253 if (pct !== undefined) {
254 const fill = tokens !== undefined && window ? `context ${tokenCount(tokens)}/${tokenCount(window)} (${pct}%), ${tokenCount(Math.max(0, window - tokens))} left` : `context ${pct}%`
255 parts.push(pct >= warnAt ? `${fill}, compact soon` : fill)
256 }
257
258 for (const limit of usage.rateLimits ?? []) {
259 if (limit.kind === 'five_hour') parts.push(`5h limit ${Math.round(limit.percentUsed)}%`)
260 if (limit.kind === 'seven_day') parts.push(`week limit ${Math.round(limit.percentUsed)}%`)
261 }
262
263 if (usage.cost) {
264 parts.push(`$${usage.cost.usd.toFixed(2)}`)
265 }
266
267 return parts.join(' · ')
268}
269types/index.d.ts 16 lines1export type Activity = { id: string; text: string }
2
3export type TurnStat = {
4 durationMs: number
5 tools: number
6 files: number
7 added: number
8 removed: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 pulse: { activity: Activity[]; turns: TurnStat[] }
14 }
15}
16