SLOPSHOPPER

file-tree

A file panel that shimmers on files Claude is reading or editing and turns them green once committed.

newpaneguardcommandprocesstimer
v0.1.0no licenseupdated 2026-10-12Isaacg1000/isaac-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 │ ┃ 3 changed · 0 committed · 0 read Clear │ ┃ ▾ src/ ⏺ Read(src/auth.ts) │ ┃ ✚ audit.ts ⎿ Read 6 lines │ ┃ ● auth.ts ⏺ Update(src/auth.ts) │ ┃ ✚ cache.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 │ │ › /files │ ⎿ file-tree: File Tree opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
3 changed · 0 committed · 0 read Clear ▾ src/ ✚ audit.ts ● auth.ts ✚ cache.ts
README

isaac-mods

Eight mods for Claude Code (terminal and the desktop app's Code tab). Requires Claude Code 2.1.287 or newer.

ModWhat it doesCommands
savvy-progressProgress bar above the prompt; an Agents side panel with a pixel crab per subagent showing model, effort, task progress, context, tokens and estimated cost/savvy, /crab-style <keyword> <color> <hat>
tool-skinsThemed icons and colors for tool calls (Tokyo Night, Nord, Dracula, Gruvbox, Solarized, Catppuccin, Rose Pine)/skin <theme>
you-should-knowAfter a long reply, a Haiku side agent flags costs, risks and actions you might miss/ysk on, /ysk off, /ysk focus <text>
file-treeA panel showing which files Claude is reading or editing; turns green once committed/files
cache-taxWarns before a prompt reloads a cold prompt cache, with a token and cost estimate/keepwarm, /cachetax ttl <minutes>
blast-radiusStops rm -r, Remove-Item -Recurse, git clean -f, git reset --hard and similar, lists the files at stake, and waits for Proceed or Cancelautomatic
reflectSpots corrections in your prompts and offers to save them as rules in CLAUDE.md/reflect <rule>
terminal-browserA text-mode browser pane; Claude can open pages there too/browse <url or path>

Cost figures are estimates at API list prices. you-should-know and reflect make small Haiku calls, which count toward your usage.

Install

At a Claude Code prompt in a terminal:

/plugin install savvy-progress --marketplace Isaacg1000/isaac-mods

Answer y to add the marketplace, then pick the user scope. Repeat with each mod name you want.

Or from a shell, all at once:

claude plugin marketplace add Isaacg1000/isaac-mods
claude plugin install savvy-progress@isaac-mods --scope user
claude plugin install tool-skins@isaac-mods --scope user
claude plugin install you-should-know@isaac-mods --scope user
claude plugin install file-tree@isaac-mods --scope user
claude plugin install cache-tax@isaac-mods --scope user
claude plugin install blast-radius@isaac-mods --scope user
claude plugin install reflect@isaac-mods --scope user
claude plugin install terminal-browser@isaac-mods --scope user

Developing

Each mod lives in plugins/<name>/: hooks/register.tsx is the hooks module, types/index.d.ts its state contract. Check and test one with:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

When this folder is added as a local marketplace (claude plugin marketplace add <this folder>), edits are picked up with /reload-plugins.

Source 2 files
hooks/register.tsx 201 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileStatus, TrackedFile } from '../types'
5
6const PANE = 'file-tree'
7const files = atom({ plugin: 'file-tree', key: 'files' } as const, [] as TrackedFile[])
8const root = atom({ plugin: 'file-tree', key: 'root' } as const, '')
9
10const norm = (p: string) => p.replace(/\\/g, '/')
11
12function relative(path: string, base: string): string {
13  const p = norm(path)
14  const b = norm(base).replace(/\/$/, '')
15  return b && p.toLowerCase().startsWith(b.toLowerCase() + '/') ? p.slice(b.length + 1) : p
16}
17
18type Node = { name: string; dirs: Map<string, Node>; files: TrackedFile[] }
19
20function buildTree(list: TrackedFile[], base: string): Node {
21  const top: Node = { name: '', dirs: new Map(), files: [] }
22  for (const f of list) {
23    const parts = relative(f.path, base).split('/').filter(Boolean)
24    let node = top
25    for (const dir of parts.slice(0, -1)) {
26      let child = node.dirs.get(dir)
27      if (!child) node.dirs.set(dir, (child = { name: dir, dirs: new Map(), files: [] }))
28      node = child
29    }
30    node.files.push(f)
31  }
32  return top
33}
34
35// Collapse single-child directory chains ("src/lib/utils/").
36function flatten(node: Node, depth: number, out: Array<{ depth: number; dir?: string; file?: TrackedFile }>) {
37  for (const [, child] of [...node.dirs].sort(([a], [b]) => a.localeCompare(b))) {
38    let name = child.name
39    let cur = child
40    while (cur.files.length === 0 && cur.dirs.size === 1) {
41      const [only] = [...cur.dirs.values()]
42      name += '/' + only!.name
43      cur = only!
44    }
45    out.push({ depth, dir: name })
46    flatten(cur, depth + 1, out)
47  }
48  for (const f of [...node.files].sort((a, b) => a.path.localeCompare(b.path))) out.push({ depth, file: f })
49}
50
51const isActive = (s: FileStatus) => s === 'reading' || s === 'editing'
52
53let ticker: { cancel: () => void } | undefined
54
55async function touch($: EngineInterface, path: string, status: FileStatus) {
56  const p = norm(path)
57  await update($, files, list => {
58    const found = list.find(f => f.path.toLowerCase() === p.toLowerCase())
59    if (!found) return [...list, { path: p, status, wasCreated: false, touches: 1 }].slice(-300)
60    return list.map(f => {
61      if (f !== found) return f
62      // A file once edited stays edited after a later read.
63      const keep = (status === 'read' || status === 'reading') && (f.status === 'edited' || f.status === 'created')
64      return { ...f, status: keep && status === 'read' ? f.status : status, touches: f.touches + 1 }
65    })
66  })
67  if (isActive(status) && !ticker) ticker = $.clock.every(150, () => $.ui.invalidate('ui.render'))
68  if (!isActive(status) && !(await read($, files)).some(f => isActive(f.status))) {
69    ticker?.cancel()
70    ticker = undefined
71  }
72}
73
74export const register: Register = on => {
75  on('session.start', async ($, e, next) => {
76    const cwd = await $.session.cwd()
77    await update($, root, () => cwd)
78    await $.command.register({ name: 'files', description: 'Open the File Tree panel' })
79    return next(e)
80  })
81
82  on('command.run', { command: 'files' }, async $ => {
83    await $.ui.open({ id: PANE, title: 'Files' })
84    return { text: 'File Tree opened.' }
85  })
86
87  on('tool.call', async ($, e, next) => {
88    let path: string | undefined
89    let kind: 'read' | 'edit' | 'write' | undefined
90    if (e.tool === 'Read') (path = e.file_path), (kind = 'read')
91    else if (e.tool === 'Edit') (path = e.file_path), (kind = 'edit')
92    else if (e.tool === 'Write') (path = e.file_path), (kind = 'write')
93    else if (e.tool === 'NotebookEdit') (path = (e as { notebook_path?: string }).notebook_path), (kind = 'edit')
94
95    if (path && kind) {
96      let isNew = false
97      if (kind === 'write') isNew = !(await $.fs.exists(path).catch(() => true))
98      if ((await read($, files)).length === 0) void $.ui.open({ id: PANE, title: 'Files' })
99      await touch($, path, kind === 'read' ? 'reading' : 'editing')
100      const ran = await next(e)
101      const failed = ran.deny !== undefined || ran.isError === true
102      const p = norm(path)
103      if (kind === 'read') await touch($, path, 'read')
104      else if (failed) await touch($, path, 'read')
105      else {
106        await touch($, path, isNew ? 'created' : 'edited')
107        if (isNew) await update($, files, list => list.map(f => (f.path === p ? { ...f, wasCreated: true } : f)))
108      }
109      return ran
110    }
111
112    if ((e.tool === 'Bash' || e.tool === 'PowerShell') && /\bgit\b[^\n|;&]*\bcommit\b/.test(e.command)) {
113      const ran = await next(e)
114      if (ran.deny === undefined && !ran.isError) {
115        const base = await read($, root)
116        const status = await $.process.run(['git', 'status', '--porcelain'], { cwd: base, timeoutMs: 10000 }).catch(() => undefined)
117        const dirty = new Set(
118          (status?.exitCode === 0 ? status.stdout : '')
119            .split('\n')
120            .map(l => l.slice(3).trim().replace(/^"|"$/g, '').toLowerCase())
121            .filter(Boolean),
122        )
123        await update($, files, list =>
124          list.map(f =>
125            (f.status === 'edited' || f.status === 'created') && !dirty.has(relative(f.path, base).toLowerCase())
126              ? { ...f, status: 'committed' as const }
127              : f,
128          ),
129        )
130      }
131      return ran
132    }
133    return next(e)
134  })
135
136  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
137    const { Box, Text, Button } = $.ui.resolve(e)
138    const list = await read($, files)
139    const base = await read($, root)
140    const now = await $.clock.now()
141    const frame = Math.floor(now / 150)
142
143    if (list.length === 0) return <Text dimColor>No files touched yet.</Text>
144
145    const rows: Array<{ depth: number; dir?: string; file?: TrackedFile }> = []
146    flatten(buildTree(list, base), 0, rows)
147    const counts = {
148      edited: list.filter(f => f.status === 'edited' || f.status === 'created' || f.status === 'editing').length,
149      committed: list.filter(f => f.status === 'committed').length,
150      read: list.filter(f => f.status === 'read' || f.status === 'reading').length,
151    }
152    const shimmer = ['#D97757', '#E89B7F', '#F5C4AE', '#E89B7F']
153    const spin = ['◐', '◓', '◑', '◒']
154
155    return (
156      <Box flexDirection="column">
157        <Box flexDirection="row" justifyContent="space-between">
158          <Text dimColor>
159            {counts.edited} changed · {counts.committed} committed · {counts.read} read
160          </Text>
161          <Button key="clear" label="Clear" plain onPress={() => update($, files, () => [])} />
162        </Box>
163        {rows.map(row => {
164          const pad = '  '.repeat(row.depth)
165          if (row.dir) {
166            return (
167              <Text dimColor wrap="truncate-end">
168                {pad}▾ {row.dir}/
169              </Text>
170            )
171          }
172          const f = row.file!
173          const name = f.path.split('/').pop() ?? f.path
174          if (isActive(f.status)) {
175            const c = shimmer[frame % shimmer.length]!
176            return (
177              <Text color={c} bold wrap="truncate-end">
178                {pad}
179                {spin[frame % spin.length]} {name} {f.status === 'editing' ? '(editing)' : '(reading)'}
180              </Text>
181            )
182          }
183          const look: Record<string, [string, string, boolean]> = {
184            read: ['○', 'subtle', true],
185            edited: ['●', 'warning', false],
186            created: ['✚', 'warning', false],
187            committed: ['✓', 'success', false],
188          }
189          const [glyph, color, dim] = look[f.status] ?? ['○', 'subtle', true]
190          return (
191            <Text color={color} dimColor={dim} wrap="truncate-end">
192              {pad}
193              {glyph} {name}
194            </Text>
195          )
196        })}
197      </Box>
198    )
199  })
200}
201
types/index.d.ts 10 lines
1export type FileStatus = 'reading' | 'editing' | 'read' | 'edited' | 'created' | 'committed'
2
3export type TrackedFile = { path: string; status: FileStatus; wasCreated: boolean; touches: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'file-tree': { files: TrackedFile[]; root: string }
8  }
9}
10