SLOPSHOPPER

savvy-progress

A live progress band above the prompt: what Claude does now, time, steps, files, errors, and a task bar with a time-left guess.

newbandguardcommandtoasttimer
v0.2.1no licenseupdated 2026-10-12gauravruhela07/claude-mods/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
› 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 › /savvy-progress ⎿ savvy-progress: Savvy progress band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Seven mods for Claude Code. Each one is a plugin of function hooks: it draws panes, status lines and toasts inside Claude Code, in the terminal and in the desktop app's Code tab.

ModWhat it doesHow to use it
savvy-progressA live band above the prompt while Claude works: what it is doing now ("Reading format.ts · 4s"), elapsed time, steps, files read and changed, errors, and a progress bar: the real task list when Claude keeps one, otherwise a time bar guessed from how long your past turns took (clearly labelled a guess). A one-line summary after each turn.Shows by itself. `/savvy-progress [on\off]`
skinsRe-skins the spinner (glyphs, verbs, color), the turn's closing line and the prompt hints. Skins: matrix, pirate, zen, retro, space. Your choice is remembered./skin (picker), /skin matrix, /skin off
filetreeA live file-tree pane of the project. Marks the files Claude read (R), changed (M) and created (A) this session, and opens the folders they live in./filetree, /filetree touched, /filetree all, /filetree close
cache-taxHow well the prompt cache works, and the "tax": the money paid above the ideal where every input token is a cache read. Status line cache 94% · tax $0.12, a per-turn table, and a toast with the likely cause when the cache busts./cache-tax, /cache-tax reset
blast-radiusFor each file Claude edits, the other files that import it; a flag on wide-reaching shell commands (git reset --hard, rm -rf, force push, ...). Shows only, never blocks./blast-radius, /blast-radius session, /blast-radius clear
reflectClaude critiques its own recent work: what may be wrong or missed, risky assumptions, what to check next. One button puts the follow-ups in your prompt box (it never sends them). Optional auto mode, off by default./reflect [focus], /reflect show, `/reflect auto on\off`
replay-theatreRecords the session and plays it back like a film: scene by scene, step by step, with play/pause, step, speed and a timeline./replay, /replay last, /replay copy; keys k play, j/l step, h/n scene, s speed, r restart

Install

In a Claude Code terminal session, install any mod by name:

/plugin install savvy-progress --marketplace gauravruhela07/claude-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: skins, filetree, cache-tax, blast-radius, reflect, replay-theatre.

A mod installed from a terminal at the user scope also loads in the desktop app's Code tab.

Try one from a clone, without installing

git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree

Notes

  • Cost: only reflect calls the model, and only when you run /reflect or turn auto mode on. It asks over the session's own transcript, so most of it is served from the prompt cache.
  • cache-tax prices are first-party API list prices as of 2026-10-11, in cache-tax/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.
  • blast-radius finds dependents with a text search (rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.
  • These mods use the Claude Code function-hooks API, which is early access and can change between releases. They were built and tested on Claude Code 2.1.29x.

Develop

Each mod folder holds .claude-plugin/plugin.json, hooks/ (the hooks module), types/index.d.ts (its state contract) and tests/.

claude plugin validate filetree
claude plugin test filetree
Source 3 files
hooks/register.tsx 246 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Task } from '../types'
5import { bar, clip, describe, duration, guess, isPlanTool, plural, remember, replan, savedDurations, started } from './format'
6
7const live = atom({ plugin: 'savvy-progress', key: 'live' } as const, null)
8const tasks = atom({ plugin: 'savvy-progress', key: 'tasks' } as const, [])
9const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
10const last = atom({ plugin: 'savvy-progress', key: 'last' } as const, null)
11const isHidden = atom({ plugin: 'savvy-progress', key: 'isHidden' } as const, false)
12const durations = atom({ plugin: 'savvy-progress', key: 'durations' } as const, [])
13
14const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
15const SLOW_MS = 30_000
16const LONG_TURN_MS = 60_000
17
18const isDone = (task: Task) => task.status === 'completed'
19const barWidth = (width: number) => Math.min(20, Math.max(8, Math.floor(width / 5)))
20
21export const register: Register = on => {
22  // The 1s tick only writes while a turn runs; a reload resets it, and the next tool call sets it again.
23  let isTicking = false
24
25  on('session.start', async ($, e, next) => {
26    await $.command.register({
27      name: 'savvy-progress',
28      description: 'Show or hide the savvy progress band',
29      argumentHint: '[on|off]',
30      immediate: true,
31    })
32    const saved = savedDurations(await $.store.get('durations').catch(() => undefined))
33    await update($, durations, () => saved)
34    $.clock.every(1000, async () => {
35      if (!isTicking) return
36      const t = await $.clock.now()
37      await update($, now, () => t)
38    })
39
40    return next(e)
41  })
42
43  on('command.run', { command: 'savvy-progress' }, async ($, e) => {
44    const arg = e.args.trim().toLowerCase()
45    const wants = ['on', 'show'].includes(arg) ? false : ['off', 'hide'].includes(arg) ? true : undefined
46
47    if (arg !== '' && wants === undefined) {
48      return { text: 'Use /savvy-progress (toggle), /savvy-progress on, or /savvy-progress off. Nothing changed.' }
49    }
50
51    let hidden = false
52    await update($, isHidden, was => (hidden = wants ?? !was))
53
54    return { text: hidden ? 'Savvy progress band hidden.' : 'Savvy progress band shown.' }
55  })
56
57  on('turn.start', async ($, e, next) => {
58    const t = await $.clock.now()
59    const list = await read($, tasks)
60    const isPlanDone = list.length > 0 && list.every(isDone)
61
62    if (isPlanDone) await update($, tasks, () => [])
63    await update($, now, () => t)
64    await update($, live, () => ({
65      startedAt: t,
66      steps: 0,
67      reads: 0,
68      edits: 0,
69      commands: 0,
70      agentSteps: 0,
71      errors: 0,
72      files: [],
73      running: [],
74      tasksDoneAtStart: isPlanDone ? 0 : list.filter(isDone).length,
75    }))
76    isTicking = true
77
78    return next(e)
79  })
80
81  on('tool.call', async ($, e, next) => {
82    const tool = String(e.tool)
83    const input = e as unknown as Record<string, unknown>
84    const isMain = e.agentId === undefined
85    const id = e.tool_use_id
86    const t = await $.clock.now()
87    const act = { id, label: describe(tool, input), since: t }
88
89    await update($, live, was => (was === null ? null : started(was, tool, input, act, isMain)))
90    isTicking = true
91
92    const ran = await next(e)
93    const isOk = ran.deny === undefined && ran.isError !== true
94
95    await update($, live, was =>
96      was === null
97        ? null
98        : {
99            ...was,
100            errors: was.errors + (isMain && ran.isError === true ? 1 : 0),
101            running: was.running.filter(one => one.id !== id),
102          },
103    )
104    if (isOk && isPlanTool(tool)) {
105      await update($, tasks, list => replan(list, tool, input, ran.result))
106    }
107
108    return ran
109  })
110
111  on('turn.complete', async ($, e, next) => {
112    if (e.agentId !== undefined) return next(e)
113
114    isTicking = false
115    const turn = await read($, live)
116
117    if (turn !== null) {
118      await update($, last, () => ({
119        durationMs: e.durationMs,
120        steps: turn.steps,
121        files: turn.files.length,
122        errors: turn.errors,
123        isAborted: e.isAborted,
124      }))
125      if (e.reason === 'answer' && !e.isAborted) {
126        const list = remember(await read($, durations), e.durationMs)
127        await update($, durations, () => list)
128        await $.store.set('durations', list).catch(() => undefined)
129      }
130      if (e.durationMs >= LONG_TURN_MS && !e.isAborted) {
131        $.ui.toast(`Done in ${duration(e.durationMs)} · ${plural(turn.steps, 'step')}`)
132      }
133    }
134    await update($, live, () => null)
135
136    return next(e)
137  })
138
139  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
140    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
141    if (await read($, isHidden)) return next(e)
142
143    const turn = await read($, live)
144    const plan = await read($, tasks)
145    const done = await read($, last)
146    const t = await read($, now)
147    const history = await read($, durations)
148    const isPlanOpen = plan.length > 0 && !plan.every(isDone)
149
150    if (turn === null && !isPlanOpen && done === null) return next(e)
151
152    const { Box, Text } = $.ui.resolve(e)
153    const width = Math.max(20, e.props.bodyColumns)
154    const rows = []
155
156    if (turn !== null) {
157      const elapsed = Math.max(0, t - turn.startedAt)
158      const current = turn.running[turn.running.length - 1]
159      const age = current === undefined ? 0 : Math.max(0, t - current.since)
160      const isSlow = age >= SLOW_MS
161      const more = turn.running.length > 1 ? ` +${turn.running.length - 1} more` : ''
162      const after = current === undefined ? '' : ` · ${isSlow ? 'still going, ' : ''}${duration(age)}${more}`
163      const label = current === undefined ? 'Thinking…' : current.label
164      const spin = SPINNER[Math.floor(t / 1000) % SPINNER.length] ?? '•'
165
166      rows.push(
167        <Box key="now" flexDirection="row">
168          <Text color="claude">{spin} </Text>
169          <Text bold>{clip(label, width - 3 - after.length)}</Text>
170          <Text color={isSlow ? 'warning' : 'subtle'}>{after}</Text>
171        </Box>,
172      )
173
174      const parts = [duration(elapsed), plural(turn.steps, 'step')]
175      if (turn.reads > 0) parts.push(`${turn.reads} read`)
176      if (turn.files.length > 0) parts.push(`${plural(turn.files.length, 'file')} changed`)
177      if (turn.commands > 0) parts.push(plural(turn.commands, 'command'))
178      if (turn.agentSteps > 0) parts.push(`${turn.agentSteps} by agents`)
179
180      // The bar shares line 2 with the counts: some surfaces show the band only two lines tall.
181      // A task list gives a real bar; without one, a bar from how long past turns took, said to be a guess.
182      let head: string
183      let note: string
184      let color: 'success' | 'claude' | 'warning'
185
186      if (isPlanOpen) {
187        const total = plan.length
188        const finished = plan.filter(isDone).length
189        const doneNow = finished - turn.tasksDoneAtStart
190        const left = total - finished
191        head = `${bar(finished, total, barWidth(width))} ${finished}/${total}`
192        note = doneNow > 0 && left > 0 ? `~${duration((elapsed / doneNow) * left)} left` : 'tasks'
193        color = 'success'
194      } else {
195        const est = guess(history, elapsed)
196        const percent = Math.min(97, Math.floor((elapsed / Math.max(1, est.totalMs)) * 100))
197        head = `${bar(percent, 100, barWidth(width))} ${percent}%`
198        note = est.isLonger
199          ? 'longer than usual'
200          : `~${duration(est.totalMs - elapsed)} left (${est.isLearning ? 'rough guess' : 'guess'})`
201        color = est.isLonger ? 'warning' : 'claude'
202      }
203
204      const errors = turn.errors > 0 ? ` · ${plural(turn.errors, 'error')}` : ''
205      const stats = clip(` · ${parts.join(' · ')}`, Math.max(0, width - head.length - note.length - 1 - errors.length))
206
207      rows.push(
208        <Box key="progress" flexDirection="row">
209          <Text color={color}>{head}</Text>
210          <Text> {note}</Text>
211          {stats !== '' && <Text dimColor>{stats}</Text>}
212          {errors !== '' && <Text color="error">{errors}</Text>}
213        </Box>,
214      )
215    } else if (isPlanOpen) {
216      const total = plan.length
217      const finished = plan.filter(isDone).length
218      const doing = plan.find(task => task.status === 'in_progress') ?? plan.find(task => !isDone(task))
219      const head = `${bar(finished, total, barWidth(width))} ${finished}/${total}`
220      const what = doing === undefined ? '' : doing.activeForm || doing.subject
221
222      rows.push(
223        <Box key="tasks" flexDirection="row">
224          <Text color="success">{head}</Text>
225          <Text> {clip(what, width - head.length - 1)}</Text>
226        </Box>,
227      )
228    }
229
230    if (turn === null && done !== null) {
231      const mark = done.isAborted ? '■ Stopped after' : '✓ Done in'
232      const parts = [`${mark} ${duration(done.durationMs)}`, plural(done.steps, 'step')]
233      if (done.files > 0) parts.push(`${plural(done.files, 'file')} changed`)
234      if (done.errors > 0) parts.push(plural(done.errors, 'error'))
235
236      rows.push(
237        <Box key="last" flexDirection="row">
238          <Text dimColor>{clip(parts.join(' · '), width)}</Text>
239        </Box>,
240      )
241    }
242
243    return <Box flexDirection="column">{rows}</Box>
244  })
245}
246
hooks/format.ts 191 lines
1import type { Activity, Live, Task } from '../types'
2
3type Input = Record<string, unknown>
4
5const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
6const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
7const COMMANDS = new Set(['Bash', 'PowerShell'])
8
9const str = (input: Input, key: string): string => {
10  const value = input[key]
11
12  return typeof value === 'string' ? value : ''
13}
14
15export const clip = (text: string, max: number): string => {
16  const line = text.replace(/\s+/g, ' ').trim()
17
18  if (max <= 1) return line.slice(0, Math.max(0, max))
19
20  return line.length > max ? `${line.slice(0, max - 1)}…` : line
21}
22
23export const base = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
24
25const host = (url: string): string => {
26  const match = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
27
28  return match?.[1] ?? url
29}
30
31export const duration = (ms: number): string => {
32  const seconds = Math.max(0, Math.round(ms / 1000))
33
34  if (seconds < 60) return `${seconds}s`
35
36  const minutes = Math.floor(seconds / 60)
37
38  if (minutes < 60) return `${minutes}m ${seconds % 60}s`
39
40  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
41}
42
43export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
44
45export const bar = (done: number, total: number, width: number): string => {
46  const filled = total === 0 ? 0 : Math.min(width, Math.round((done / total) * width))
47
48  return '█'.repeat(filled) + '░'.repeat(width - filled)
49}
50
51const HISTORY = 30
52const MIN_SAMPLES = 3
53const FIRST_GUESS_MS = 60_000
54
55/** The durations kept after one more answered turn: the newest `HISTORY`. */
56export const remember = (durations: number[], ms: number): number[] => [...durations, ms].slice(-HISTORY)
57
58/** Reads saved durations back, dropping anything that is not a positive number. */
59export const savedDurations = (value: unknown): number[] =>
60  Array.isArray(value) ? value.filter((ms): ms is number => typeof ms === 'number' && ms > 0).slice(-HISTORY) : []
61
62export type Guess = { totalMs: number; isLonger: boolean; isLearning: boolean }
63
64/**
65 * How long this turn will take in all, from how long past turns took. Among the past turns that
66 * ran longer than this one has so far, it takes the middle one, so a turn already 3 minutes in is
67 * compared with long turns, not quick ones. Past every turn on record, it is "longer than usual".
68 */
69export const guess = (durations: number[], elapsedMs: number): Guess => {
70  const isLearning = durations.length < MIN_SAMPLES
71  const longer = (isLearning ? [FIRST_GUESS_MS] : durations).filter(ms => ms > elapsedMs).sort((a, b) => a - b)
72  const middle = longer[Math.floor(longer.length / 2)]
73
74  return middle === undefined
75    ? { totalMs: elapsedMs * 1.25, isLonger: true, isLearning }
76    : { totalMs: middle, isLonger: false, isLearning }
77}
78
79/** Turns a tool call into a short line a person reads: "Reading register.tsx". */
80export const describe = (tool: string, input: Input): string => {
81  const file = base(str(input, 'file_path') || str(input, 'notebook_path') || str(input, 'path'))
82
83  switch (tool) {
84    case 'Read':
85      return `Reading ${file}`
86    case 'Edit':
87    case 'MultiEdit':
88    case 'NotebookEdit':
89      return `Editing ${file}`
90    case 'Write':
91      return `Writing ${file}`
92    case 'Bash':
93    case 'PowerShell':
94      return str(input, 'description') || `Running ${str(input, 'command')}`
95    case 'Grep':
96      return `Searching for "${str(input, 'pattern')}"`
97    case 'Glob':
98      return `Finding ${str(input, 'pattern')}`
99    case 'WebFetch':
100      return `Fetching ${host(str(input, 'url'))}`
101    case 'WebSearch':
102      return `Searching the web: ${str(input, 'query')}`
103    case 'Agent':
104    case 'Task':
105      return `Agent: ${str(input, 'description') || 'working'}`
106    case 'Skill':
107      return `Using skill ${str(input, 'skill')}`
108    case 'TodoWrite':
109    case 'TaskCreate':
110    case 'TaskUpdate':
111      return 'Updating the plan'
112    case 'ToolSearch':
113      return 'Loading tools'
114  }
115
116  if (tool.startsWith('mcp__')) {
117    const [, server = '', ...rest] = tool.split('__')
118    const name = rest.join('__').replace(/_/g, ' ')
119    const isOpaque = /^[0-9a-f-]{20,}$/i.test(server)
120
121    return isOpaque ? name : `${server}: ${name}`
122  }
123
124  return tool
125}
126
127/** The turn after one more tool call started. */
128export const started = (live: Live, tool: string, input: Input, act: Activity, isMain: boolean): Live => {
129  if (!isMain) return { ...live, agentSteps: live.agentSteps + 1 }
130
131  const path = str(input, 'file_path') || str(input, 'notebook_path')
132  const isEdit = EDITS.has(tool)
133
134  return {
135    ...live,
136    steps: live.steps + 1,
137    reads: live.reads + (READS.has(tool) ? 1 : 0),
138    edits: live.edits + (isEdit ? 1 : 0),
139    commands: live.commands + (COMMANDS.has(tool) ? 1 : 0),
140    files: isEdit && path !== '' && !live.files.includes(path) ? [...live.files, path] : live.files,
141    running: [...live.running, act],
142  }
143}
144
145/** The task list after a TaskCreate, TaskUpdate or TodoWrite call went through. */
146export const replan = (list: Task[], tool: string, input: Input, result: unknown): Task[] => {
147  if (tool === 'TodoWrite') {
148    const todos = Array.isArray(input.todos) ? (input.todos as Input[]) : []
149
150    return todos.map((todo, i) => ({
151      id: `todo-${i}`,
152      subject: str(todo, 'content'),
153      activeForm: str(todo, 'activeForm') || undefined,
154      status: (str(todo, 'status') || 'pending') as Task['status'],
155    }))
156  }
157
158  if (tool === 'TaskCreate') {
159    const made = (result as { task?: { id?: unknown } } | undefined)?.task
160    const id = typeof made?.id === 'string' ? made.id : `task-${list.length + 1}`
161
162    return [
163      ...list.filter(task => task.id !== id),
164      { id, subject: str(input, 'subject'), activeForm: str(input, 'activeForm') || undefined, status: 'pending' },
165    ]
166  }
167
168  if (tool === 'TaskUpdate') {
169    const id = str(input, 'taskId')
170    const status = str(input, 'status')
171
172    if (status === 'deleted') return list.filter(task => task.id !== id)
173
174    return list.map(task =>
175      task.id !== id
176        ? task
177        : {
178            ...task,
179            subject: str(input, 'subject') || task.subject,
180            activeForm: str(input, 'activeForm') || task.activeForm,
181            status: status === '' ? task.status : (status as Task['status']),
182          },
183    )
184  }
185
186  return list
187}
188
189export const isPlanTool = (tool: string): boolean =>
190  tool === 'TodoWrite' || tool === 'TaskCreate' || tool === 'TaskUpdate'
191
types/index.d.ts 45 lines
1/** One tool call that is running now, in words a person reads. */
2export type Activity = { id: string; label: string; since: number }
3
4/** The turn that runs now. */
5export type Live = {
6  startedAt: number
7  steps: number
8  reads: number
9  edits: number
10  commands: number
11  agentSteps: number
12  errors: number
13  files: string[]
14  running: Activity[]
15  tasksDoneAtStart: number
16}
17
18export type TaskStatus = 'pending' | 'in_progress' | 'completed'
19
20/** One item of Claude's task list (TaskCreate / TodoWrite). */
21export type Task = { id: string; subject: string; activeForm?: string; status: TaskStatus }
22
23/** What the last finished turn came to. */
24export type Summary = {
25  durationMs: number
26  steps: number
27  files: number
28  errors: number
29  isAborted: boolean
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'savvy-progress': {
35      live: Live | null
36      tasks: Task[]
37      now: number
38      last: Summary | null
39      isHidden: boolean
40      /** How long past answered turns took, in ms, oldest first; kept across sessions in `$.store`. */
41      durations: number[]
42    }
43  }
44}
45