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

A Claude Code mod that shows, in a side pane, a live file tree of every file Claude touches in the session:
| Mark | Color | When |
|---|---|---|
◐ reading | cyan | a Read is running |
● editing / ● writing | yellow | an Edit, Write or NotebookEdit is running |
M edited / M written | orange | changed, not committed yet |
✓ committed | green | the change is committed |
· read | dim | only read |
src/app/).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).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).
/file-tree./file-tree.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).
hooks/register.tsx 219 lines1import { 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}
219types/index.d.ts 21 lines1export 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