SLOPSHOPPER

file-activity-tree

Live file tree of what Claude reads, edits and writes; committed files turn green

newpaneguardcommandprocess
v0.1.0no licenseupdated 2026-10-08Acrazie/agent-board/plugins/file-activity-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-activity-tree
│ ┃ Claude files ✕ › fix the failing auth test and add an audit log call │ ┃ 3 files · 3 changed · 3 committed │ ┃ src/ ⏺ Read(src/auth.ts) │ ┃ ✓ audit.ts committed ⎿ Read 6 lines │ ┃ ✓ auth.ts committed ⏺ Update(src/auth.ts) │ ┃ ✓ cache.ts committed ⎿ 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-activity-tree: File tree pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claude files
3 files · 3 changed · 3 committed src/ ✓ audit.ts committed ✓ auth.ts committed ✓ cache.ts committed
README

file-activity-tree

A Claude Code mod that shows, in a side pane, a live file tree of every file Claude touches in the session:

MarkColorWhen
◐ readingcyana Read is running
● editing / ● writingyellowan Edit, Write or NotebookEdit is running
M edited / M writtenorangechanged, not committed yet
✓ committedgreenthe change is committed
· readdimonly read
  • Paths are shown relative to the project root; single-child folders fold into one line (src/app/).
  • After a successful Bash command running git … commit, the mod runs git status on the changed files; a file whose working copy is clean turns green. Changing it again turns it back to orange.
  • /file-tree opens the pane and re-checks commits (use it after committing outside Claude).
  • Tracking never blocks a tool: if a hook fails, the tool call goes through untouched.

Install

At the prompt of a Claude Code terminal session:

/plugin install file-activity-tree --marketplace Acrazie/agent-board

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

Notes

  • An unrequested pane needs a terminal at least 144 columns wide; below that, open it with /file-tree.
  • Commits made outside a Claude Bash call (by you, or by a tool) are picked up on the next /file-tree.

Develop

claude --plugin-dir /path/to/agent-board/plugins/file-activity-tree
claude plugin validate plugins/file-activity-tree
claude plugin test plugins/file-activity-tree

The plugin API is early access and may change between Claude Code releases (built against 2.1.293).

Source 2 files
hooks/register.tsx 219 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileOp, TrackedFile } from '../types'
5
6const PANE = 'file-activity-tree'
7const TITLE = 'Claude files'
8const files = atom({ plugin: 'file-activity-tree', key: 'files' } as const, [])
9
10const GIT_COMMIT = /\bgit\b[^\n;&|]*\bcommit\b/
11
12const OPS: Record<string, FileOp> = {
13  Read: 'read',
14  Edit: 'edit',
15  Write: 'write',
16  NotebookEdit: 'edit',
17}
18
19function pathOf(e: Record<string, unknown>): string | undefined {
20  const path = e.file_path ?? e.notebook_path
21  return typeof path === 'string' && path.length > 0 ? path : undefined
22}
23
24function upsert(list: TrackedFile[], path: string, change: (file: TrackedFile) => TrackedFile): TrackedFile[] {
25  const found = list.find(file => file.path === path)
26  const base: TrackedFile = found ?? { path, op: 'read', isActive: false, isChanged: false, isCommitted: false }
27  return [...list.filter(file => file.path !== path), change(base)]
28}
29
30function dirname(path: string): string {
31  const cut = path.lastIndexOf('/')
32  return cut <= 0 ? '/' : path.slice(0, cut)
33}
34
35/** Paths git reports as dirty, untracked or ignored, absolute. */
36async function dirtyPaths($: EngineInterface, top: string, paths: string[]): Promise<Set<string>> {
37  const rel = paths.map(path => path.slice(top.length + 1))
38  const status = await $.process.run(
39    ['git', '-C', top, 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored', '--', ...rel],
40  )
41  if (status.exitCode !== 0) return new Set(paths)
42  const dirty = new Set<string>()
43  const parts = status.stdout.split('\0')
44  for (let i = 0; i < parts.length; i++) {
45    const entry = parts[i] ?? ''
46    if (entry.length < 4) continue
47    dirty.add(`${top}/${entry.slice(3)}`)
48    // A rename or copy carries its origin path as the next entry.
49    if (entry[0] === 'R' || entry[0] === 'C') i++
50  }
51  return dirty
52}
53
54/** Marks each changed file green once its repository has it clean. */
55async function refreshCommitted($: EngineInterface): Promise<void> {
56  const pending = (await read($, files)).filter(file => file.isChanged && !file.isCommitted && !file.isActive)
57  if (pending.length === 0) return
58
59  const byTop = new Map<string, string[]>()
60  for (const file of pending) {
61    const top = await $.process.run(['git', '-C', dirname(file.path), 'rev-parse', '--show-toplevel'])
62    if (top.exitCode !== 0) continue
63    const root = top.stdout.trim()
64    if (!file.path.startsWith(`${root}/`)) continue
65    byTop.set(root, [...(byTop.get(root) ?? []), file.path])
66  }
67
68  const clean = new Set<string>()
69  for (const [top, paths] of byTop) {
70    const dirty = await dirtyPaths($, top, paths)
71    for (const path of paths) if (!dirty.has(path)) clean.add(path)
72  }
73  if (clean.size === 0) return
74
75  await update($, files, list =>
76    list.map(file => (clean.has(file.path) && file.isChanged ? { ...file, isCommitted: true } : file)),
77  )
78}
79
80type Node = { name: string; dirs: Map<string, Node>; file?: TrackedFile }
81
82function buildTree(list: TrackedFile[], root: string): Node {
83  const top: Node = { name: '', dirs: new Map() }
84  for (const file of list) {
85    const rel = file.path.startsWith(`${root}/`) ? file.path.slice(root.length + 1) : file.path
86    const parts = rel.split('/').filter(part => part.length > 0)
87    let node = top
88    for (const part of parts.slice(0, -1)) {
89      let next = node.dirs.get(part)
90      if (!next) {
91        next = { name: part, dirs: new Map() }
92        node.dirs.set(part, next)
93      }
94      node = next
95    }
96    const name = parts[parts.length - 1] ?? rel
97    node.dirs.set(`\0${name}`, { name, dirs: new Map(), file })
98  }
99  return top
100}
101
102type Line = { depth: number; text: string; file?: TrackedFile }
103
104function flatten(node: Node, depth: number, out: Line[]): Line[] {
105  const children = [...node.dirs.values()].sort((a, b) => {
106    if (!a.file !== !b.file) return a.file ? 1 : -1
107    return a.name.localeCompare(b.name)
108  })
109  for (const child of children) {
110    if (child.file) {
111      out.push({ depth, text: child.name, file: child.file })
112      continue
113    }
114    // Fold chains of single-directory folders into one line: src/app/
115    let dir = child
116    let label = dir.name
117    while (dir.dirs.size === 1) {
118      const only = [...dir.dirs.values()][0]!
119      if (only.file) break
120      dir = only
121      label = `${label}/${dir.name}`
122    }
123    out.push({ depth, text: `${label}/` })
124    flatten(dir, depth + 1, out)
125  }
126  return out
127}
128
129type Look = { mark: string; label: string; color?: string; dim?: boolean }
130
131function lookOf(file: TrackedFile): Look {
132  if (file.isActive) {
133    if (file.op === 'read') return { mark: '◐', label: 'reading', color: 'suggestion' }
134    return { mark: '●', label: file.op === 'write' ? 'writing' : 'editing', color: 'warning' }
135  }
136  if (file.isCommitted) return { mark: '✓', label: 'committed', color: 'success' }
137  if (file.isChanged) return { mark: 'M', label: file.op === 'write' ? 'written' : 'edited', color: 'claude' }
138  return { mark: '·', label: 'read', dim: true }
139}
140
141export const register: Register = on => {
142  on('session.start', async ($, e, next) => {
143    await $.command.register({
144      name: 'file-tree',
145      description: 'Show the files Claude reads, edits and writes, committed ones in green',
146    })
147    void $.ui.open({ id: PANE, title: TITLE })
148
149    return next(e)
150  })
151
152  on('command.run', { command: 'file-tree' }, async $ => {
153    await $.ui.open({ id: PANE, title: TITLE })
154    await refreshCommitted($)
155
156    return { text: 'File tree pane opened.' }
157  })
158
159  on('tool.call', async ($, e, next) => {
160    const op = OPS[e.tool]
161    const path = op ? pathOf(e as Record<string, unknown>) : undefined
162    if (!op || !path) return next(e)
163
164    await update($, files, list => upsert(list, path, file => ({ ...file, isActive: true, op: file.isChanged && op === 'read' ? file.op : op })))
165    const ran = await next(e)
166    const isDone = ran.deny === undefined && ran.isError !== true
167    await update($, files, list =>
168      upsert(list, path, file =>
169        op !== 'read' && isDone
170          ? { ...file, isActive: false, op, isChanged: true, isCommitted: false }
171          : { ...file, isActive: false },
172      ),
173    )
174
175    return ran
176  }).catch(($, e, next) => next(e)) // tracking never blocks a tool; next replays
177
178  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
179    const ran = await next(e)
180    if (GIT_COMMIT.test(e.command) && ran.deny === undefined && ran.isError !== true) {
181      await refreshCommitted($).catch(() => undefined)
182    }
183
184    return ran
185  }).catch(($, e, next) => next(e)) // tracking never blocks a tool; next replays
186
187  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
188    const { Box, Text } = $.ui.resolve(e)
189    const list = await read($, files)
190    if (list.length === 0) return <Text dimColor>No file touched yet.</Text>
191
192    const root = await $.session.root()
193    const lines = flatten(buildTree(list, root), 0, [])
194    const room = Math.max(3, (e.viewport?.rows ?? 24) - 4)
195    const shown = lines.slice(0, room)
196    const committed = list.filter(file => file.isCommitted).length
197    const changed = list.filter(file => file.isChanged).length
198
199    return (
200      <Box flexDirection="column">
201        <Text dimColor>
202          {list.length} files · {changed} changed · {committed} committed
203        </Text>
204        {shown.map(line => {
205          const indent = '  '.repeat(line.depth)
206          if (!line.file) return <Text dimColor>{`${indent}${line.text}`}</Text>
207          const look = lookOf(line.file)
208          return (
209            <Text color={look.color} dimColor={look.dim} bold={line.file.isActive}>
210              {`${indent}${look.mark} ${line.text}  ${look.label}`}
211            </Text>
212          )
213        })}
214        {lines.length > shown.length && <Text dimColor>{`… ${lines.length - shown.length} more`}</Text>}
215      </Box>
216    )
217  })
218}
219
types/index.d.ts 21 lines
1export type FileOp = 'read' | 'edit' | 'write'
2
3export type TrackedFile = {
4  /** Absolute path, as the tool received it. */
5  path: string
6  /** The last operation Claude ran on the file. */
7  op: FileOp
8  /** A tool call on the file is running right now. */
9  isActive: boolean
10  /** Claude edited or wrote the file this session. */
11  isChanged: boolean
12  /** The last change is in a commit (HEAD of the file's repository). */
13  isCommitted: boolean
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'file-activity-tree': { files: TrackedFile[] }
19  }
20}
21