SLOPSHOPPER

file-tree

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

newpaneguardcommandpromptprocess
v1.0.0no licenseupdated 2026-10-09Fraktalis/claude-mods/plugins/file-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-tree
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ app Touched only Clear │ ┃ Nothing touched yet ⏺ Read(src/auth.ts) │ ┃ ◦ read ● modified + new ✓ committed ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ package.json ⎿ Added 2 lines, removed 1 line │ ┃ README.md ⏺ Bash(bun test) │ ┃ src ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tree │ ⎿ file-tree: File Tree opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
app Touched only Clear Nothing touched yet ◦ read ● modified + new ✓ committed package.json README.md src
README

claude-mods

My Claude Code mods, as a plugin marketplace (alex-mods). One folder per mod under plugins/.

ModWhat it does
savvy-progressEmber 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-treeFiles 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-skinsReskins 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-taxPrompt-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.

Install on this machine (reads straight from this folder)

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).

Install from GitHub (another machine)

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

Then update later with claude plugin update savvy-progress@alex-mods after pushing a new version.

Working on a mod

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-progress

  • /savvy opens the Agents pane; /savvy-skin <type|*> <crab|developer|designer|researcher|architect|ghost> picks a mascot.
  • Registers a 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.
  • Cost is estimated from each request's token usage at the model's list price (table in hooks/register.tsx, PRICES); edit it when prices change.

file-tree

  • /tree opens the Files pane (it also opens by itself on the first file Claude edits or creates); /tree-clear forgets what was touched.
  • States: ◦ read, ● modified, + new, ✓ committed. Reads never downgrade a state; editing a committed file makes it modified again.
  • A successful 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.
  • Folders leading to touched files open by themselves; 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-tax

  • Status line: 🔥 cache 42m while warm, 🧊 cache cold · next msg ≈ $2.40 once the TTL has passed since the main conversation's last request.
  • On a cold cache, a message whose context is above the threshold (20k tokens by default) is stopped once with the cold and warm cost; the text is put back in the box, and Enter again within 2 min sends it.
  • Cost: cold re-writes the whole context to the cache (2x input price for a 1h TTL, 1.25x for 5m); warm reads it at the cache-read price. Prices in 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.
  • Options (plugin config): ttl (1h or 5m), minTokens, maxPings. The TTL is not detected: switch it to 5m in usage overage.

tool-skins

  • Redraws each tool row's header (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.
  • A new skin is one more entry in THEMES (hooks/register.tsx). The name claude-skins is reserved for Anthropic, hence tool-skins.
Source 3 files
hooks/register.tsx 334 lines
1import { 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}
334
hooks/shimmer.tsx 62 lines
1import 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
62
types/index.d.ts 20 lines
1export 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