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

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.
| Mod | What it does | How to use it | |
|---|---|---|---|
| savvy-progress | A 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]` |
| skins | Re-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 | |
| filetree | A 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-tax | How 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-radius | For 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 | |
| reflect | Claude 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-theatre | Records 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 |
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.
git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree
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/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.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 filetreehooks/register.tsx 246 lines1import { 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}
246hooks/format.ts 191 lines1import 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'
191types/index.d.ts 45 lines1/** 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