Side pane with the project tree: files Claude reads, edits or creates light up, and turn green once committed.

My Claude Code mods, as a plugin marketplace (alex-mods). One folder per mod under plugins/.
| Mod | What it does |
|---|---|
| savvy-progress | Ember progress bar above the prompt for a batch of subagents, and an Agents pane: crab mascot per agent, live status, per-agent progress bar, model, tokens, estimated cost, elapsed time. |
| file-tree | Files pane with the project tree: files Claude reads (blue), modifies (orange) or creates (purple) light up with a shimmer, and turn green once committed, in every repo they live in. |
| tool-skins | Reskins the transcript's tool rows: an icon and a palette colour per tool (dracula skin from kevinvn1709/vscode-dracula-color-theme), the row's result still drawn by Claude Code. |
| cache-tax | Prompt-cache state in the status line; stops a message that would re-read the whole conversation on a cold cache and shows what it costs; /keepwarm keeps the cache warm while you are away. |
claude plugin marketplace add E:/WS/claude-mods
claude plugin install savvy-progress@alex-mods --scope user
claude plugin install file-tree@alex-mods --scope user
claude plugin install cache-tax@alex-mods --scope user
claude plugin install tool-skins@alex-mods --scope user
Installed this way, Claude Code reads the plugin from this folder, so an edit here reaches a session with /reload-plugins (no reinstall, no version bump).
/plugin install savvy-progress --marketplace Fraktalis/claude-mods
Then update later with claude plugin update savvy-progress@alex-mods after pushing a new version.
claude plugin validate plugins/savvy-progress
claude plugin test plugins/savvy-progress
Bump version in the mod's .claude-plugin/plugin.json for each release that GitHub installs should pick up.
/savvy opens the Agents pane; /savvy-skin <type|*> <crab|developer|designer|researcher|architect|ghost> picks a mascot.progress tool (mcp__savvy-progress__progress); each subagent is asked to report done/total/step with it. The main loop can also call it, with title to name the batch.hooks/register.tsx, PRICES); edit it when prices change./tree opens the Files pane (it also opens by itself on the first file Claude edits or creates); /tree-clear forgets what was touched.git commit run by Claude re-checks git status in every repository holding a modified or new file; commits made in a terminal are picked up on the next prompt.node_modules, .git and the like are listed dimmed and stay closed. Touched only hides the rest. Files outside the session's folder are listed under Elsewhere.🔥 cache 42m while warm, 🧊 cache cold · next msg ≈ $2.40 once the TTL has passed since the main conversation's last request.hooks/register.ts (PRICES), same table as savvy-progress./cache shows the details; /keepwarm on|off sends a one-line ping ~1m30 before expiry, at most maxPings times in a row (never while Claude works or while you type). Each ping costs a cache read and a one-word reply.ttl (1h or 5m), minTokens, maxPings. The TTL is not detected: switch it to 5m in usage overage.ToolUse): icon + tool name in its palette colour, the call's argument (last 3 path segments, or the one-line command), … running, ✗ error, Interrupted. The result under the row (ToolResult: diffs, Bash output) is still Claude Code's own./skin lists skins, /skin <name> switches, /skin off hands the rows back; the choice is kept across sessions. dracula is on by default.dracula comes from themes/dracula-color-theme.json of github.com/kevinvn1709/vscode-dracula-color-theme, a Darcula-style palette despite its name: keyword #CC7832 → Bash, function #FFC66D → Edit, string #6A8759 → Write, number #6897BB → Read, type #4EC9B0 → Grep/Glob, property #9876AA → Agent, info #6796e6 → web tools, debug #b267e6 → MCP tools, on #212122.THEMES (hooks/register.tsx). The name claude-skins is reserved for Anthropic, hence tool-skins.hooks/register.tsx 334 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileState, Touch } from '../types'
5
6const PANE = 'file-tree'
7const root = atom({ plugin: 'file-tree', key: 'root' } as const, '')
8const touched = atom({ plugin: 'file-tree', key: 'touched' } as const, {})
9const expanded = atom({ plugin: 'file-tree', key: 'expanded' } as const, {})
10const touchedOnly = atom({ plugin: 'file-tree', key: 'touchedOnly' } as const, false)
11
12// ---------- pure helpers (tested in tree.test.ts) ----------
13
14/** Absolute, '/'-separated, `.`/`..` resolved; a relative path is taken from `base`. */
15export const norm = (p: string, base: string) => {
16 let s = p.replace(/\\/g, '/')
17 if (!/^([A-Za-z]:\/|\/)/.test(s)) s = `${base.replace(/\\/g, '/').replace(/\/$/, '')}/${s}`
18 const parts: string[] = []
19 for (const seg of s.split('/')) {
20 if (seg === '.' || (seg === '' && parts.length > 0)) continue
21 if (seg === '..') {
22 if (parts.length > 1) parts.pop()
23 continue
24 }
25 parts.push(seg)
26 }
27 const out = parts.join('/')
28 return /^[a-z]:/.test(out) ? out[0]!.toUpperCase() + out.slice(1) : out
29}
30
31/** The record key: Windows paths compare without case. */
32export const keyOf = (absPath: string) => (/^[A-Za-z]:/.test(absPath) ? absPath.toLowerCase() : absPath)
33
34export type Op = 'read' | 'edit' | 'create'
35
36/** A file's state after an operation: reads never downgrade, edits to a new file keep it new. */
37export const nextState = (prev: FileState | undefined, op: Op): FileState => {
38 if (op === 'read') return prev ?? 'read'
39 if (op === 'create') return 'created'
40 return prev === 'created' ? 'created' : 'modified'
41}
42
43/** Keys of the paths `git status --porcelain -z` reports dirty, resolved from the repo top. */
44export const parsePorcelain = (stdout: string, top: string) => {
45 const dirty = new Set<string>()
46 const parts = stdout.split('\0')
47 for (let i = 0; i < parts.length; i++) {
48 const entry = parts[i]!
49 if (entry.length < 4) continue
50 const xy = entry.slice(0, 2)
51 dirty.add(keyOf(norm(entry.slice(3), top)))
52 if (xy.includes('R') || xy.includes('C')) i++ // a rename's source path follows
53 }
54 return dirty
55}
56
57/** The folder holding a normalized path. */
58export const parentOf = (absPath: string) => {
59 const cut = absPath.lastIndexOf('/')
60 return cut <= 0 ? '/' : /^[A-Za-z]:$/.test(absPath.slice(0, cut)) ? `${absPath.slice(0, cut)}/` : absPath.slice(0, cut)
61}
62
63export const isCommitCommand = (cmd: string) => /(^|[;&|]\s*|\s)git(\s+-\S+(\s+\S+)?)*\s+commit\b/.test(cmd)
64
65// ---------- look ----------
66
67const LOOK: Record<FileState, { glyph: string; color: string; label: string }> = {
68 read: { glyph: '◦', color: '#6cb6ff', label: 'read' },
69 modified: { glyph: '●', color: '#e8743b', label: 'modified' },
70 created: { glyph: '+', color: '#d2a8ff', label: 'new' },
71 committed: { glyph: '✓', color: '#3fb950', label: 'committed' },
72}
73
74// heavy folders are listed dimmed; like every folder they only open by themselves when they lead to a touched file
75const HEAVY = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next', 'target', '.cache', '.turbo'])
76const PER_DIR = 80
77const MAX_ROWS = 400
78const RECENT_MS = 1800
79const INDENT = ' '
80
81// ---------- project root ----------
82
83/** The session's working directory, looked up once and kept (session.start may not have run, e.g. after a reload). */
84async function rootOf($: EngineInterface): Promise<string> {
85 const known = await read($, root)
86 if (known) return known
87 const cwd = norm(await $.session.cwd(), '/')
88 await update($, root, () => cwd)
89 return cwd
90}
91
92// ---------- git ----------
93
94/**
95 * Turns modified/new files that git no longer reports dirty into committed, in
96 * every repository that holds one (the session's project and anything "elsewhere").
97 */
98async function refreshCommitted($: EngineInterface) {
99 const all = await read($, touched)
100 const pending = Object.entries(all).filter(([, t]) => t.state === 'modified' || t.state === 'created')
101 if (pending.length === 0) return
102
103 // the repository of each folder holding a pending file, looked up once per folder
104 const topOfDir = new Map<string, string | null>()
105 for (const [, t] of pending) {
106 const dir = parentOf(t.path)
107 if (topOfDir.has(dir)) continue
108 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir, timeoutMs: 5000 }).catch(() => null)
109 topOfDir.set(dir, top && top.exitCode === 0 ? norm(top.stdout.trim(), dir) : null)
110 }
111
112 // one git status per repository
113 const clean = new Set<string>()
114 for (const top of new Set([...topOfDir.values()].filter((t): t is string => !!t))) {
115 const status = await $.process
116 .run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: top, timeoutMs: 10_000 })
117 .catch(() => null)
118 if (!status || status.exitCode !== 0) continue
119 const dirty = parsePorcelain(status.stdout, top)
120 const topKey = keyOf(top)
121 for (const [k] of pending) if (k.startsWith(`${topKey}/`) && !dirty.has(k)) clean.add(k)
122 }
123 if (clean.size === 0) return
124
125 await update($, touched, cur => {
126 const next = { ...cur }
127 for (const k of clean) {
128 const t = next[k]
129 // only still-pending files: one touched again meanwhile keeps its new state
130 if (t && (t.state === 'modified' || t.state === 'created')) next[k] = { ...t, state: 'committed' }
131 }
132 return next
133 })
134}
135
136/** Records an operation on a file. */
137async function mark($: EngineInterface, path: string, op: Op) {
138 const cwd = await rootOf($)
139 const abs = norm(path, cwd)
140 const key = keyOf(abs)
141 const now = await $.clock.now()
142 await update($, touched, all => {
143 const prev = all[key]
144 return { ...all, [key]: { state: nextState(prev?.state, op), at: now, ops: (prev?.ops ?? 0) + 1, path: abs } }
145 })
146}
147
148// ---------- hooks ----------
149
150export const register: Register = on => {
151 let hasOpened = false
152
153 on('session.start', async ($, e, next) => {
154 await $.command.register({ name: 'tree', description: 'Open the File Tree pane' })
155 await $.command.register({ name: 'tree-clear', description: 'Forget which files Claude touched this session' })
156 await rootOf($)
157 return next(e)
158 })
159
160 on('command.run', { command: 'tree' }, async $ => {
161 hasOpened = true
162 await refreshCommitted($)
163 await $.ui.open({ id: PANE, title: 'Files' })
164 return { text: 'File Tree opened.' }
165 })
166
167 on('command.run', { command: 'tree-clear' }, async $ => {
168 await update($, touched, () => ({}))
169 return { text: 'File Tree cleared.' }
170 })
171
172 // commits made outside Claude (a terminal) show up on the next prompt
173 on('prompt.submit', async ($, e, next) => {
174 void refreshCommitted($)
175 return next(e)
176 })
177
178 on('tool.call', async ($, e, next) => {
179 const input = e as unknown as { file_path?: string; notebook_path?: string; command?: string }
180 const path = input.file_path ?? input.notebook_path
181 const tool = e.tool as string
182 const cwd = await rootOf($)
183 // a Write to a path that did not exist creates the file
184 const existed = tool === 'Write' && path ? await $.fs.exists(norm(path, cwd)).catch(() => true) : true
185
186 const ran = await next(e)
187 if (('deny' in ran && !!ran.deny) || (ran as { isError?: boolean }).isError) return ran
188
189 let op: Op | null = null
190 if (path && tool === 'Read') op = 'read'
191 else if (path && tool === 'Write') op = existed ? 'edit' : 'create'
192 else if (path && (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit')) op = 'edit'
193 if (path && op) await mark($, path, op)
194 if (op && op !== 'read' && !hasOpened) {
195 hasOpened = true
196 void $.ui.open({ id: PANE, title: 'Files' })
197 }
198 if (tool === 'Bash' && input.command && isCommitCommand(input.command)) await refreshCommitted($)
199 return ran
200 })
201
202 // ---------- pane ----------
203
204 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
205 const { Box, Text, Button } = $.ui.resolve(e)
206 // drawing is pure: read the root, never store it from here
207 const cwd = (await read($, root)) || norm(await $.session.cwd(), '/')
208 const all = await read($, touched)
209 const open = await read($, expanded)
210 const onlyTouched = await read($, touchedOnly)
211 const now = await $.clock.now()
212 const hasClient = e.surface === 'terminal' || e.surface === 'desktop'
213
214 const keys = Object.keys(all)
215 const leadsToTouched = (dirKey: string) => keys.some(k => k.startsWith(`${dirKey}/`))
216 const isOpen = (dirKey: string) => open[dirKey] ?? leadsToTouched(dirKey)
217 const toggle = (dirKey: string, now: boolean) => () => update($, expanded, m => ({ ...m, [dirKey]: !now }))
218
219 const name = (label: string, key: string, t: Touch | undefined) => {
220 if (!t) return <Text dimColor>{label}</Text>
221 const color = LOOK[t.state].color
222 if (hasClient && now - t.at < RECENT_MS && (e.surface === 'terminal' || e.surface === 'desktop')) {
223 const { Client } = $.ui.resolve(e)
224 return <Client key={`s:${key}`} module="./shimmer.tsx" props={{ text: label, color, pulse: t.ops, age: Math.max(0, now - t.at) }} />
225 }
226 return <Text color={color}>{label}</Text>
227 }
228
229 const rows: unknown[] = []
230 let isCut = false
231
232 const walk = async (dir: string, depth: number): Promise<void> => {
233 if (depth > 12) return
234 const entries = await $.fs.list(dir).catch(() => [])
235 const items = entries
236 .filter(en => en.kind !== 'other' || en.isLink)
237 .map(en => {
238 const abs = `${dir}/${en.name}`
239 return { name: en.name, abs, key: keyOf(abs), isDir: en.kind === 'dir' }
240 })
241 .filter(it => {
242 const hot = it.isDir ? leadsToTouched(it.key) : !!all[it.key]
243 return onlyTouched ? hot : true
244 })
245 .sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name))
246
247 const shown = items.filter((it, i) => i < PER_DIR || (it.isDir ? leadsToTouched(it.key) : !!all[it.key]))
248 const pad = INDENT.repeat(depth)
249 for (const it of shown) {
250 if (rows.length >= MAX_ROWS) {
251 isCut = true
252 return
253 }
254 if (it.isDir) {
255 const isDirOpen = isOpen(it.key)
256 const count = keys.filter(k => k.startsWith(`${it.key}/`)).length
257 rows.push(
258 <Box key={`d:${it.key}`} flexDirection="row">
259 <Text>{pad}</Text>
260 <Button
261 key={`b:${it.key}`}
262 plain
263 dimColor={HEAVY.has(it.name)}
264 label={`${isDirOpen ? '▾' : '▸'} ${it.name}`}
265 onPress={toggle(it.key, isDirOpen)}
266 />
267 {count > 0 && <Text color="#e8743b"> {count}</Text>}
268 </Box>,
269 )
270 if (isDirOpen) await walk(it.abs, depth + 1)
271 } else {
272 const t = all[it.key]
273 rows.push(
274 <Box key={`f:${it.key}`} flexDirection="row">
275 <Text>{pad}</Text>
276 <Text color={t ? LOOK[t.state].color : 'subtle'}>{t ? LOOK[t.state].glyph : ' '} </Text>
277 {name(it.name, it.key, t)}
278 </Box>,
279 )
280 }
281 }
282 const hidden = items.length - shown.length
283 if (hidden > 0) rows.push(<Text key={`m:${dir}`} dimColor>{`${pad}… ${hidden} more`}</Text>)
284 }
285
286 if (cwd) await walk(cwd, 0)
287
288 const rootKey = keyOf(cwd)
289 const elsewhere = Object.entries(all).filter(([k]) => !k.startsWith(`${rootKey}/`))
290 const counts = (['modified', 'created', 'committed', 'read'] as const).map(s => [s, Object.values(all).filter(t => t.state === s).length] as const)
291 const summary = counts.filter(([, n]) => n > 0).map(([s, n]) => `${n} ${LOOK[s].label}`).join(' · ')
292 const projectName = cwd.split('/').pop() || cwd
293
294 return (
295 <Box flexDirection="column">
296 <Box flexDirection="row" justifyContent="space-between" gap={1}>
297 <Text bold wrap="truncate-end">
298 {projectName}
299 </Text>
300 <Box flexDirection="row" gap={1}>
301 <Button key="only" plain label={onlyTouched ? 'All files' : 'Touched only'} onPress={() => update($, touchedOnly, v => !v)} />
302 <Button key="clear" plain label="Clear" onPress={() => update($, touched, () => ({}))} />
303 </Box>
304 </Box>
305 <Text dimColor wrap="truncate-end">
306 {summary || 'Nothing touched yet'}
307 </Text>
308 <Box flexDirection="row" gap={1} marginBottom={1}>
309 {(Object.keys(LOOK) as FileState[]).map(s => (
310 <Text key={`l:${s}`} color={LOOK[s].color}>
311 {LOOK[s].glyph} {LOOK[s].label}
312 </Text>
313 ))}
314 </Box>
315 {rows as never}
316 {isCut && <Text dimColor>… tree cut at {MAX_ROWS} rows; use Touched only</Text>}
317 {elsewhere.length > 0 && (
318 <Box flexDirection="column" marginTop={1}>
319 <Text dimColor>Elsewhere · {elsewhere.length}</Text>
320 {elsewhere
321 .sort(([, a], [, b]) => b.at - a.at)
322 .map(([k, t]) => (
323 <Box key={`e:${k}`} flexDirection="row">
324 <Text color={LOOK[t.state].color}>{LOOK[t.state].glyph} </Text>
325 {name(t.path, k, t)}
326 </Box>
327 ))}
328 </Box>
329 )}
330 </Box>
331 )
332 })
333}
334hooks/shimmer.tsx 62 lines1import type { ClientModule } from 'claude-code'
2
3type Props = { text: string; color: string; pulse: number; age: number }
4type Ref = { start: number; pulse: number; t: number }
5type State = { r: Ref }
6
7const DURATION = 1800
8
9const rgb = (c: string) => {
10 const n = parseInt(c.slice(1, 7), 16)
11 return [(n >> 16) & 255, (n >> 8) & 255, n & 255] as const
12}
13const mix = (a: string, b: string, k: number) => {
14 const [r1, g1, b1] = rgb(a)
15 const [r2, g2, b2] = rgb(b)
16 const h = (x: number, y: number) => Math.round(x + (y - x) * Math.max(0, Math.min(1, k))).toString(16).padStart(2, '0')
17 return `#${h(r1, r2)}${h(g1, g2)}${h(b1, b2)}`
18}
19
20/**
21 * A file name that shimmers for a moment after Claude touches it: a bright band
22 * sweeps across the letters, then the name settles in its state colour. Only
23 * colours change, so the name never changes width.
24 */
25const Shimmer: ClientModule<Props, State> = (props, surface) => {
26 const { Box, Text } = surface.elements
27 let st = surface.state
28 if (!st) {
29 const r: Ref = { start: Date.now() - props.age, pulse: props.pulse, t: 0 }
30 st = { r }
31 surface.setState(st)
32 surface.every(50, () => {
33 if (Date.now() - r.start < DURATION) {
34 r.t += 1
35 surface.setState({ r })
36 }
37 })
38 }
39 const r = st.r
40 if (props.pulse !== r.pulse) {
41 // touched again: replay the sweep
42 r.pulse = props.pulse
43 r.start = Date.now() - props.age
44 }
45 const k = (Date.now() - r.start) / DURATION
46 if (k >= 1) return <Text color={props.color}>{props.text}</Text>
47
48 const chars = [...props.text]
49 const head = k * (chars.length + 6) - 3
50 return (
51 <Box flexDirection="row" flexShrink={0}>
52 {chars.map((c, i) => (
53 <Text key={`c${i}`} color={mix(props.color, '#ffffff', Math.max(0, 1 - Math.abs(i - head) / 3) * 0.85)} bold>
54 {c}
55 </Text>
56 ))}
57 </Box>
58 )
59}
60
61export default Shimmer
62types/index.d.ts 20 lines1export type FileState = 'read' | 'modified' | 'created' | 'committed'
2
3/** `path` is the spelling to show; the record's key is the normalized form. */
4export type Touch = { state: FileState; at: number; ops: number; path: string }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'file-tree': {
9 /** The session's working directory, '/'-separated. */
10 root: string
11 /** Files Claude touched this session, by normalized absolute path. */
12 touched: Record<string, Touch>
13 /** Folders the person opened (true) or closed (false); absent follows the auto rule. */
14 expanded: Record<string, boolean>
15 /** Show only folders that lead to touched files. */
16 touchedOnly: boolean
17 }
18 }
19}
20