A live file-tree pane of the project that marks the files Claude read, changed or created this session.

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 322 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Listing, Mark, Mode } from '../types'
5import {
6 MAX_ROWS,
7 baseName,
8 buildView,
9 clip,
10 clipStart,
11 isIgnored,
12 join,
13 normalize,
14 parents,
15 relative,
16 resolve,
17 stronger,
18 toListing,
19} from './tree'
20import type { Loose, Row } from './tree'
21
22const PANE = 'filetree'
23const TITLE = 'Files'
24const USAGE = 'Use /filetree (open), /filetree touched, /filetree all, or /filetree close. Nothing changed.'
25const TRACKED = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
26
27const root = atom({ plugin: 'filetree', key: 'root' } as const, '')
28const mode = atom({ plugin: 'filetree', key: 'mode' } as const, 'all')
29const expanded = atom({ plugin: 'filetree', key: 'expanded' } as const, [])
30const listings = atom({ plugin: 'filetree', key: 'listings' } as const, {})
31const touched = atom({ plugin: 'filetree', key: 'touched' } as const, {})
32
33type Engine = EngineInterface
34
35const text = (value: unknown): string => (typeof value === 'string' ? value : '')
36
37/** Lists one folder; a folder that cannot be listed comes back with its error. */
38const fetchListing = async ($: Engine, top: string, rel: string): Promise<Listing> => {
39 try {
40 return toListing(await $.fs.list(join(top, rel)))
41 } catch {
42 return { entries: [], more: 0, error: 'Cannot list this folder' }
43 }
44}
45
46/** Loads the listings of these folders that are not cached yet. */
47const ensureLoaded = async ($: Engine, dirs: readonly string[]): Promise<void> => {
48 const top = await read($, root)
49 if (top === '') return
50 const have = await read($, listings)
51 const fresh: Record<string, Listing> = {}
52
53 for (const rel of new Set(dirs)) {
54 if (have[rel] === undefined && fresh[rel] === undefined) fresh[rel] = await fetchListing($, top, rel)
55 }
56 if (Object.keys(fresh).length > 0) await update($, listings, all => ({ ...all, ...fresh }))
57}
58
59/** Opens a closed folder (loading it the first time) or closes an open one. */
60const toggle = async ($: Engine, rel: string): Promise<void> => {
61 let isOpening = false
62
63 await update($, expanded, list => {
64 isOpening = !list.includes(rel)
65 return isOpening ? [...list, rel] : list.filter(one => one !== rel)
66 })
67 if (isOpening) await ensureLoaded($, [rel])
68}
69
70/** Reloads the root and every open folder; other cached listings are dropped and load again when opened. */
71const refresh = async ($: Engine): Promise<void> => {
72 const top = await read($, root)
73 if (top === '') return
74 const open = await read($, expanded)
75 const fresh: Record<string, Listing> = {}
76
77 for (const rel of new Set(['', ...open])) fresh[rel] = await fetchListing($, top, rel)
78 await update($, listings, () => fresh)
79}
80
81/** The mark a successful call leaves, or null when it left none (a Write held for review). */
82const markOf = (tool: string, result: unknown, existed: boolean): Mark | null => {
83 if (tool === 'Read') return 'R'
84 if (tool !== 'Write') return 'M'
85
86 const record = (typeof result === 'object' && result !== null ? result : {}) as { type?: unknown; staged?: unknown }
87
88 if (record.staged === true) return null
89 if (record.type === 'create') return 'A'
90 if (record.type === 'update') return 'M'
91
92 return existed ? 'M' : 'A'
93}
94
95// Folders to load after a touch, drained by the timer so tool calls never wait on a listing.
96// Module state: a hot reload drops what was queued, and the folders load when next opened.
97let queue: string[] = []
98let isDraining = false
99
100const drain = async ($: Engine): Promise<void> => {
101 if (isDraining || queue.length === 0) return
102 isDraining = true
103 try {
104 while (queue.length > 0) {
105 const batch = queue
106 queue = []
107 await ensureLoaded($, batch)
108 }
109 } catch {
110 // A failed load is retried the next time the folder is opened or refreshed.
111 } finally {
112 isDraining = false
113 }
114}
115
116export const register: Register = on => {
117 on('session.start', async ($, e, next) => {
118 const top = normalize(e.cwd)
119 let isMoved = false
120
121 await update($, root, was => {
122 isMoved = was !== top
123 return top
124 })
125 if (isMoved) {
126 await update($, listings, () => ({}))
127 await update($, expanded, () => [])
128 }
129 await $.command.register({
130 name: 'filetree',
131 description: 'Show the project file tree, marking files Claude read (R), changed (M) or created (A)',
132 argumentHint: '[touched|all|close]',
133 immediate: true,
134 })
135 $.clock.every(250, () => void drain($))
136
137 return next(e)
138 })
139
140 on('session.end', async ($, e, next) => {
141 if (e.reason === 'clear') {
142 queue = []
143 await update($, touched, () => ({}))
144 }
145
146 return next(e)
147 })
148
149 on('command.run', { command: 'filetree' }, async ($, e) => {
150 const word = e.args.trim().toLowerCase()
151
152 if (word !== '' && word !== 'touched' && word !== 'all' && word !== 'close') return { text: USAGE }
153
154 if (word === 'close') {
155 try {
156 await $.ui.close({ id: PANE })
157 } catch {
158 return { text: 'The file tree stays open.' }
159 }
160 return { text: 'File tree closed.' }
161 }
162
163 if (word === 'touched' || word === 'all') {
164 const wanted: Mode = word
165 await update($, mode, () => wanted)
166 }
167 await ensureLoaded($, ['', ...(await read($, expanded))])
168
169 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
170 const shown = (await read($, mode)) === 'touched' ? 'only touched files' : 'all files'
171
172 return {
173 text: opened.isPlaced ? `File tree opened (${shown}).` : `File tree opened (${shown}); ${opened.reason}`,
174 }
175 })
176
177 on('tool.call', async ($, e, next) => {
178 const tool = String(e.tool)
179 if (!TRACKED.has(tool)) return next(e)
180
181 const input = e as unknown as Record<string, unknown>
182 const raw = text(input.file_path) || text(input.notebook_path)
183 if (raw === '') return next(e)
184
185 const top = await read($, root)
186 const path = resolve(top, raw)
187 const existed = tool === 'Write' ? await $.fs.stat(path).then(() => true, () => false) : true
188 const ran = await next(e)
189
190 if (ran.deny !== undefined || ran.isError === true) return ran
191 const mark = markOf(tool, ran.result, existed)
192 if (mark === null) return ran
193
194 await update($, touched, all => ({ ...all, [path]: stronger(all[path], mark) }))
195
196 const rel = relative(top, path)
197 if (rel !== undefined && rel !== '' && !isIgnored(rel)) {
198 const dirs = parents(rel)
199 const open = await read($, expanded)
200 const missing = dirs.filter(dir => !open.includes(dir))
201
202 if (missing.length > 0) {
203 await update($, expanded, list => [...list, ...missing.filter(dir => !list.includes(dir))])
204 }
205 queue.push('', ...dirs)
206 }
207
208 return ran
209 }).catch(($, e, next) => next(e))
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const { Box, Text, Button } = $.ui.resolve(e)
213 const top = await read($, root)
214 const shown = await read($, mode)
215 const view = buildView(
216 top,
217 shown,
218 await read($, expanded),
219 await read($, listings),
220 await read($, touched),
221 MAX_ROWS,
222 )
223 const width = Math.max(20, e.props.bodyColumns)
224
225 const tone = (mark: Mark | null) =>
226 mark === 'M' ? { color: 'warning' } : mark === 'A' ? { color: 'success' } : mark === 'R' ? { dimColor: true } : {}
227
228 const line = (row: Row) => {
229 const pad = row.depth * 2
230 const room = Math.max(4, width - pad - 2)
231
232 if (row.kind === 'dir') {
233 const tail = row.count > 0 ? ` ${row.count}` : ''
234
235 return (
236 <Box key={row.key} flexDirection="row" paddingLeft={pad}>
237 <Button
238 key={`dir:${row.rel}`}
239 plain
240 label={`${row.isOpen ? '▾' : '▸'} ${clip(`${row.name}/`, room - tail.length)}`}
241 onPress={() => toggle($, row.rel)}
242 />
243 {tail !== '' && <Text color="warning">{tail}</Text>}
244 </Box>
245 )
246 }
247
248 if (row.kind === 'file') {
249 return (
250 <Box key={row.key} flexDirection="row" paddingLeft={pad}>
251 <Text {...tone(row.mark)} bold={row.mark === 'M' || row.mark === 'A'}>
252 {row.mark ?? ' '}{' '}
253 </Text>
254 <Text {...tone(row.mark)} wrap="truncate-end">
255 {clip(row.name, room)}
256 </Text>
257 </Box>
258 )
259 }
260
261 return (
262 <Box key={row.key} flexDirection="row" paddingLeft={pad + 2}>
263 <Text dimColor wrap="truncate-end">
264 {clip(row.text, Math.max(4, width - pad - 2))}
265 </Text>
266 </Box>
267 )
268 }
269
270 const group = (key: string, title: string, list: Loose[]) =>
271 list.length === 0 ? null : (
272 <Box key={key} flexDirection="column" marginTop={1}>
273 <Text bold dimColor>
274 {clip(`${title} (${list.length})`, width)}
275 </Text>
276 {list.map(one => (
277 <Box key={`${key}:${one.path}`} flexDirection="row">
278 <Text {...tone(one.mark)} bold={one.mark !== 'R'}>
279 {one.mark}{' '}
280 </Text>
281 <Text {...tone(one.mark)} wrap="truncate-start">
282 {clipStart(one.path, width - 2)}
283 </Text>
284 </Box>
285 ))}
286 </Box>
287 )
288
289 const counts = ` · ${view.changed} changed · ${view.added} new · ${view.read} read`
290 const name = clip(baseName(top) || 'project', Math.max(6, width - counts.length))
291 const isEmpty = view.rows.length === 0
292
293 return (
294 <Box flexDirection="column">
295 <Box key="header" flexDirection="row">
296 <Text bold>{name}</Text>
297 <Text dimColor wrap="truncate-end">
298 {clip(counts, Math.max(0, width - name.length))}
299 </Text>
300 </Box>
301 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1} marginBottom={1}>
302 <Button
303 key="mode"
304 label={shown === 'all' ? 'Only touched' : 'Show all'}
305 onPress={() => update($, mode, was => (was === 'all' ? 'touched' : 'all'))}
306 />
307 <Button key="collapse" label="Collapse all" onPress={() => update($, expanded, () => [])} />
308 <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
309 </Box>
310 {top === '' && <Text dimColor>Waiting for the session to start…</Text>}
311 {top !== '' && isEmpty && shown === 'touched' && (
312 <Text dimColor>{clip('Nothing in the project touched yet.', width)}</Text>
313 )}
314 {view.rows.map(line)}
315 {view.isCut && <Text dimColor>{clip(`… stopped at ${MAX_ROWS} rows; collapse some folders`, width)}</Text>}
316 {group('outside', 'Outside project', view.outside)}
317 {group('ignored', 'In ignored folders', view.ignored)}
318 </Box>
319 )
320 })
321}
322hooks/tree.ts 232 lines1import type { Entry, Listing, Mark, Mode } from '../types'
2
3/** Entries kept per folder listing; the rest is a "… N more" row. */
4export const CAP = 200
5
6/** Rows drawn at most, so a huge expanded tree stays light. */
7export const MAX_ROWS = 1500
8
9/** Names never shown as tree rows. */
10export const IGNORED = new Set([
11 '.git',
12 'node_modules',
13 'dist',
14 'build',
15 '.next',
16 '__pycache__',
17 '.venv',
18 'target',
19 '.DS_Store',
20])
21
22const DRIVE = /^[A-Za-z]:\//
23
24/** Forward slashes, no empty, `.` or `..` segments; keeps a leading `/` or `C:/`. */
25export const normalize = (path: string): string => {
26 const slashed = path.replace(/\\/g, '/')
27 const prefix = DRIVE.test(slashed) ? slashed.slice(0, 3) : slashed.startsWith('/') ? '/' : ''
28 const parts: string[] = []
29
30 for (const seg of slashed.slice(prefix.length).split('/')) {
31 if (seg === '' || seg === '.') continue
32 if (seg === '..') {
33 if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
34 else if (prefix === '') parts.push('..')
35 continue
36 }
37 parts.push(seg)
38 }
39
40 return prefix + parts.join('/')
41}
42
43const isAbsolute = (path: string): boolean => /^([\\/]|[A-Za-z]:[\\/])/.test(path)
44
45/** An absolute, normalized path for what a tool named, relative ones taken under `root`. */
46export const resolve = (root: string, path: string): string =>
47 normalize(isAbsolute(path) || root === '' ? path : `${root}/${path}`)
48
49/** The path under `root` ('' for the root itself), or undefined when it lies outside. */
50export const relative = (root: string, abs: string): string | undefined => {
51 if (root === '') return undefined
52 if (abs === root) return ''
53 const base = root.endsWith('/') ? root : `${root}/`
54
55 return abs.startsWith(base) ? abs.slice(base.length) : undefined
56}
57
58/** The absolute path of a folder given by its path under `root`. */
59export const join = (root: string, rel: string): string =>
60 rel === '' ? root : root.endsWith('/') ? `${root}${rel}` : `${root}/${rel}`
61
62export const baseName = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
63
64/** True when any folder on the path is one the tree never shows. */
65export const isIgnored = (rel: string): boolean => rel.split('/').some(seg => IGNORED.has(seg))
66
67/** The folders above a path under the root, outermost first, the root ('') left out. */
68export const parents = (rel: string): string[] => {
69 const segs = rel.split('/')
70 const out: string[] = []
71
72 for (let i = 1; i < segs.length; i++) out.push(segs.slice(0, i).join('/'))
73
74 return out
75}
76
77const compare = (a: string, b: string): number => {
78 const x = a.toLowerCase()
79 const y = b.toLowerCase()
80
81 if (x !== y) return x < y ? -1 : 1
82
83 return a < b ? -1 : a > b ? 1 : 0
84}
85
86/** Folders first, then files, each alphabetical (case folded). */
87export const sortEntries = (entries: readonly Entry[]): Entry[] =>
88 [...entries].sort((a, b) => (a.isDir !== b.isDir ? (a.isDir ? -1 : 1) : compare(a.name, b.name)))
89
90/** What the tree keeps of a raw `$.fs.list` answer. */
91export const toListing = (raw: readonly { name: string; kind: string }[]): Listing => {
92 const kept = sortEntries(
93 raw.filter(one => !IGNORED.has(one.name)).map(one => ({ name: one.name, isDir: one.kind === 'dir' })),
94 )
95
96 return { entries: kept.slice(0, CAP), more: Math.max(0, kept.length - CAP), error: null }
97}
98
99const RANK: Record<Mark, number> = { R: 1, M: 2, A: 3 }
100
101/** The mark that stays: added over modified over read. */
102export const stronger = (was: Mark | undefined, now: Mark): Mark =>
103 was !== undefined && RANK[was] >= RANK[now] ? was : now
104
105/** One line, cut to `max` cells with an ellipsis at the end. */
106export const clip = (text: string, max: number): string => {
107 const line = text.replace(/\s+/g, ' ').trim()
108
109 if (max <= 1) return line.slice(0, Math.max(0, max))
110
111 return line.length > max ? `${line.slice(0, max - 1)}…` : line
112}
113
114/** One line, cut to `max` cells from the start, so a path keeps its file name. */
115export const clipStart = (text: string, max: number): string => {
116 if (max <= 1) return text.slice(-Math.max(0, max))
117
118 return text.length > max ? `…${text.slice(text.length - max + 1)}` : text
119}
120
121export type Row =
122 | { kind: 'dir'; key: string; rel: string; name: string; depth: number; isOpen: boolean; count: number }
123 | { kind: 'file'; key: string; rel: string; name: string; depth: number; mark: Mark | null }
124 | { kind: 'note'; key: string; depth: number; text: string }
125
126export type Loose = { path: string; mark: Mark }
127
128export type View = {
129 rows: Row[]
130 outside: Loose[]
131 ignored: Loose[]
132 changed: number
133 added: number
134 read: number
135 isCut: boolean
136}
137
138/** The rows the pane draws, from what the state holds. */
139export const buildView = (
140 root: string,
141 mode: Mode,
142 expanded: readonly string[],
143 listings: Readonly<Record<string, Listing>>,
144 touched: Readonly<Record<string, Mark>>,
145 maxRows: number,
146): View => {
147 const open = new Set(expanded)
148 const view: View = { rows: [], outside: [], ignored: [], changed: 0, added: 0, read: 0, isCut: false }
149 const marks = new Map<string, Mark>()
150 const tally = new Map<string, number>()
151 const extra = new Map<string, Map<string, boolean>>()
152
153 for (const [path, mark] of Object.entries(touched)) {
154 if (mark === 'M') view.changed += 1
155 else if (mark === 'A') view.added += 1
156 else view.read += 1
157
158 const rel = relative(root, path)
159
160 if (rel === undefined || rel === '') {
161 view.outside.push({ path, mark })
162 continue
163 }
164 if (isIgnored(rel)) {
165 view.ignored.push({ path: rel, mark })
166 continue
167 }
168
169 marks.set(rel, mark)
170 const segs = rel.split('/')
171
172 for (let i = 0; i < segs.length; i++) {
173 const dir = segs.slice(0, i).join('/')
174 const kids = extra.get(dir) ?? new Map<string, boolean>()
175
176 tally.set(dir, (tally.get(dir) ?? 0) + 1)
177 kids.set(segs[i] ?? '', i < segs.length - 1)
178 extra.set(dir, kids)
179 }
180 }
181
182 const push = (row: Row): boolean => {
183 if (view.rows.length >= maxRows) {
184 view.isCut = true
185 return false
186 }
187 view.rows.push(row)
188 return true
189 }
190
191 const walk = (dir: string, depth: number): void => {
192 const listing = listings[dir]
193 const entries = [...(listing?.entries ?? [])]
194 const names = new Set(entries.map(one => one.name))
195
196 for (const [name, isDir] of extra.get(dir) ?? []) {
197 if (!names.has(name)) entries.push({ name, isDir })
198 }
199
200 if (mode === 'all') {
201 if (listing === undefined && entries.length === 0) push({ kind: 'note', key: `note:${dir}`, depth, text: 'Loading…' })
202 else if (listing?.error) push({ kind: 'note', key: `note:${dir}`, depth, text: listing.error })
203 else if (entries.length === 0) push({ kind: 'note', key: `note:${dir}`, depth, text: '(empty)' })
204 }
205
206 for (const entry of sortEntries(entries)) {
207 if (view.isCut) return
208 const rel = dir === '' ? entry.name : `${dir}/${entry.name}`
209
210 if (entry.isDir) {
211 const count = tally.get(rel) ?? 0
212 if (mode === 'touched' && count === 0) continue
213 const isOpen = open.has(rel)
214 if (!push({ kind: 'dir', key: `row:${rel}`, rel, name: entry.name, depth, isOpen, count })) return
215 if (isOpen) walk(rel, depth + 1)
216 } else {
217 const mark = marks.get(rel) ?? null
218 if (mode === 'touched' && mark === null) continue
219 if (!push({ kind: 'file', key: `row:${rel}`, rel, name: entry.name, depth, mark })) return
220 }
221 }
222
223 if (mode === 'all' && listing !== undefined && listing.more > 0) {
224 push({ kind: 'note', key: `more:${dir}`, depth, text: `… ${listing.more} more` })
225 }
226 }
227
228 walk('', 0)
229
230 return view
231}
232types/index.d.ts 28 lines1/** What this session did to a file: `R` read, `M` modified, `A` added (written new). */
2export type Mark = 'R' | 'M' | 'A'
3
4/** Which rows the tree shows: every entry, or only touched files and the folders holding them. */
5export type Mode = 'all' | 'touched'
6
7/** One entry of a folder listing; ignored names are already left out. */
8export type Entry = { name: string; isDir: boolean }
9
10/** A folder's listing as last loaded: up to 200 entries, sorted, how many more there were, and why it failed. */
11export type Listing = { entries: Entry[]; more: number; error: string | null }
12
13declare module 'claude-code' {
14 interface PluginState {
15 filetree: {
16 /** The session's working directory, normalized: the tree's root. */
17 root: string
18 mode: Mode
19 /** Folders shown open, as paths under the root (the root itself is always open). */
20 expanded: string[]
21 /** Loaded listings by path under the root ('' is the root). */
22 listings: Record<string, Listing>
23 /** Every file touched this session, by absolute path. */
24 touched: Record<string, Mark>
25 }
26 }
27}
28