SLOPSHOPPER

pulse

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

newspinnerguardstatus
v0.1.1MITupdated 2026-10-23rezaulhreza/claude-code-mods/pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulse
› 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. ✓ Done in 42s · 9 tools ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ pulse: opus-5.5 · context 97k/200k (49%), 103k left · 5h limit 31% · $0.42

Draws

Turn line
✓ Done in 42s · 9 tools
README

pulse

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.

Source 3 files
hooks/register.tsx 158 lines
1import { 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}
158
hooks/describe.ts 269 lines
1type 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}
269
types/index.d.ts 16 lines
1export 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