SLOPSHOPPER

file-tree

A pane listing the files Claude reads and edits as a tree: highlighted while touched, green once committed. No tokens used.

newpaneguardcommand
v0.1.0no licenseupdated 2026-10-11KianaVega/KianaVega/mods/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 │ ┃ src/ │ ┃ audit.ts ⏺ Read(src/auth.ts) │ ┃ auth.ts ⎿ Read 6 lines │ ┃ cache.ts ⏺ Update(src/auth.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 │ │ › /file-tree │ ⎿ file-tree: File tree opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
src/ audit.ts auth.ts cache.ts
README

Claude Code mods

Nine mods for Claude Code (2.1.287 or newer), written from the walkthrough video. Each lives in mods/<name>.

Install one, or all, from a terminal session:

/plugin install <name> --marketplace KianaVega/KianaVega
ModWhat it doesExtra model usage
savvy-progressProgress bar above the prompt and a live list of subagents with model, tokens and rough costnone
tool-skinsIcons and colors for tool calls in seven themes, switch with /skin <name>none
you-should-knowA second, small model reads long answers and flags anything important you might missone small call per long answer
file-treePane showing files Claude reads and edits, green once committed (/file-tree)none
cache-taxHolds a prompt that would reload a cold cache and shows an estimate. /keepwarm pings before the cache expiresnone, except keep-warm pings
blast-radiusStops recursive deletes, git clean, hard resets and shows the affected files before you press Proceednone
reflectOffers to save corrections as a rule in CLAUDE.mdone small call when you save
terminal-browser/browse <url or file> opens a pane with a screenshot (kitty or Ghostty) and the page textnone
replay-theaterReplay button after a turn that steps through each edit as a diff (/replay)none

Notes

  • Settings such as trigger phrases, price tables, skins and which model the watcher uses are constants at the top of each hooks/register.tsx. Edit and reload.
  • Cost figures are estimates from a built-in price table.
  • terminal-browser is a viewer built from a headless screenshot and page text. It is not a live, clickable browser.
  • Mods run in the terminal and the desktop app's Code tab, not the VS Code extension.
Source 2 files
hooks/register.tsx 142 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { FileState } from '../types'
5
6const PANE = 'file-tree'
7const files = atom({ plugin: 'file-tree', key: 'files' } as const, {} as Record<string, FileState>)
8const root = atom({ plugin: 'file-tree', key: 'root' } as const, '')
9
10const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
11
12const COLORS: Record<FileState, string> = {
13  reading: 'suggestion',
14  read: 'inactive',
15  editing: 'warning',
16  changed: 'claude',
17  committed: 'success',
18}
19
20const pathOf = (e: unknown): string | undefined => {
21  const i = e as { file_path?: unknown; notebook_path?: unknown }
22  const p = i.file_path ?? i.notebook_path
23
24  return typeof p === 'string' ? p : undefined
25}
26
27const mark =
28  (path: string, state: FileState) =>
29  (list: Record<string, FileState>): Record<string, FileState> => ({ ...list, [path]: state })
30
31type Node = { children: Map<string, Node>; path?: string }
32
33const build = (paths: string[], base: string): Node => {
34  const top: Node = { children: new Map() }
35  for (const full of paths) {
36    const rel = base !== '' && full.startsWith(base + '/') ? full.slice(base.length + 1) : full
37    let node = top
38    for (const part of rel.split('/').filter(Boolean)) {
39      if (!node.children.has(part)) node.children.set(part, { children: new Map() })
40      node = node.children.get(part)!
41    }
42    node.path = full
43  }
44
45  return top
46}
47
48const flatten = (node: Node, depth: number, out: Array<{ name: string; depth: number; path?: string }>) => {
49  const names = [...node.children.keys()].sort()
50  for (const name of names) {
51    const child = node.children.get(name)!
52    out.push({ name, depth, path: child.path })
53    flatten(child, depth + 1, out)
54  }
55
56  return out
57}
58
59export const register: Register = on => {
60  on('session.start', async ($, e, next) => {
61    await update($, root, () => '')
62    const cwd = await $.session.cwd()
63    await update($, root, () => cwd)
64    await $.command.register({ name: 'file-tree', description: 'Open the file tree pane' })
65    void $.ui.open({ id: PANE, title: 'Files' })
66
67    return next(e)
68  })
69
70  on('command.run', { command: 'file-tree' }, async $ => {
71    await $.ui.open({ id: PANE, title: 'Files' })
72
73    return { text: 'File tree opened.' }
74  })
75
76  // Watch: shimmer while a file is being touched, then settle.
77  on('tool.call', async ($, e, next) => {
78    const tool = String(e.tool)
79    const path = pathOf(e)
80
81    if (path !== undefined && (tool === 'Read' || EDIT_TOOLS.has(tool))) {
82      const isEdit = EDIT_TOOLS.has(tool)
83      await update($, files, mark(path, isEdit ? 'editing' : 'reading'))
84      const ran = await next(e)
85      const hasFailed = ran.deny !== undefined || ran.isError === true
86      await update($, files, (list): Record<string, FileState> => {
87        const was = list[path]
88        const settled: FileState = hasFailed ? 'read' : isEdit || was === 'changed' ? 'changed' : 'read'
89
90        return hasFailed && was === undefined ? list : { ...list, [path]: settled }
91      })
92
93      return ran
94    }
95
96    if (tool === 'Bash' && /\bgit\s+commit\b/.test((e as { command?: string }).command ?? '')) {
97      const ran = await next(e)
98      if (ran.deny === undefined && ran.isError !== true) {
99        await update($, files, list =>
100          Object.fromEntries(
101            Object.entries(list).map(([p, s]) => [p, s === 'changed' ? 'committed' : s]),
102          ) as Record<string, FileState>,
103        )
104      }
105
106      return ran
107    }
108
109    return next(e)
110  })
111
112  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
113    const { Box, Text } = $.ui.resolve(e)
114    const list = await read($, files)
115    const base = await read($, root)
116    const rows = flatten(build(Object.keys(list), base), 0, [])
117    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
118
119    return (
120      <Box flexDirection="column">
121        {rows.length === 0 && <Text dimColor>No files touched yet.</Text>}
122        {rows.slice(0, room).map(r => {
123          const state = r.path === undefined ? undefined : list[r.path]
124
125          return (
126            <Text
127              color={state === undefined ? undefined : COLORS[state]}
128              bold={state === 'editing' || state === 'reading'}
129              dimColor={state === undefined}
130            >
131              {'  '.repeat(r.depth)}
132              {state === undefined ? r.name + '/' : r.name}
133              {state === 'committed' ? '  committed' : ''}
134            </Text>
135          )
136        })}
137        {rows.length > room && <Text dimColor>+{rows.length - room} more</Text>}
138      </Box>
139    )
140  })
141}
142
types/index.d.ts 8 lines
1export type FileState = 'reading' | 'read' | 'editing' | 'changed' | 'committed'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'file-tree': { files: Record<string, FileState>; root: string }
6  }
7}
8