A pane listing the files Claude modified during the session

A pane that lists every file Claude modified during the session, so you can see what changed without reading the transcript.
Edit, Write and NotebookEdit (only when the call succeeded).Bash: in a git repository, the mod snapshots the dirty, untracked and deleted files (by content hash) before and after each command and records the difference.N files changed, then one line per file (path relative to the session folder, ×N change count), most recent first.| Command | Effect |
|---|---|
/changed-files | Open the pane. |
/changed-files close | Close the pane (off also works). |
/changed-files clear | Empty the list. |
claude --plugin-dir /path/to/ModsTools/mods/changed-files
claude plugin validate mods/changed-files
claude plugin test mods/changed-files # 6 testshooks/register.tsx 146 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChangedFile } from '../types'
5
6const PANE = 'changed-files'
7const TITLE = 'Changed files'
8const files = atom({ plugin: 'changed-files', key: 'files' } as const, [])
9
10const relative = (path: string, cwd: string) =>
11 path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
12
13async function record($: EngineInterface, path: string, tool: string) {
14 const cwd = await $.session.cwd()
15 const shown = relative(path, cwd)
16 await update($, files, list => {
17 const found = list.find(one => one.path === shown)
18 const rest = list.filter(one => one.path !== shown)
19 const edits = (found?.edits ?? 0) + 1
20
21 return [...rest, { path: shown, tool, edits }]
22 })
23}
24
25// Above this many dirty paths, a snapshot is skipped rather than hashing them all.
26const SNAPSHOT_LIMIT = 2000
27
28const split = (stdout: string) => stdout.split('\0').filter(Boolean)
29
30// The repo's dirty, untracked and deleted files, absolute path → content hash
31// ('deleted' for a removed file); null outside a git repository.
32async function snapshot($: EngineInterface): Promise<Map<string, string> | null> {
33 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
34 if (top.exitCode !== 0) return null
35 const root = top.stdout.trim()
36 const listed = await $.process.run(
37 ['git', 'ls-files', '-z', '-m', '-o', '-d', '--exclude-standard'],
38 { cwd: root },
39 )
40 const gone = await $.process.run(['git', 'ls-files', '-z', '-d'], { cwd: root })
41 if (listed.exitCode !== 0 || gone.exitCode !== 0) return null
42 const deleted = new Set(split(gone.stdout))
43 const present = [...new Set(split(listed.stdout))].filter(path => !deleted.has(path))
44 if (present.length + deleted.size > SNAPSHOT_LIMIT) return null
45
46 const files = new Map<string, string>()
47 for (const path of deleted) files.set(`${root}/${path}`, 'deleted')
48 if (present.length === 0) return files
49 const absolute = present.map(path => `${root}/${path}`)
50 const hashed = await $.process.run(['git', 'hash-object', '--', ...absolute], { cwd: root })
51 if (hashed.exitCode !== 0) return null
52 const hashes = hashed.stdout.trim().split('\n')
53 absolute.forEach((path, i) => files.set(path, hashes[i] ?? ''))
54
55 return files
56}
57
58const changedBetween = (before: Map<string, string>, after: Map<string, string>) => [
59 ...[...after].filter(([path, hash]) => before.get(path) !== hash).map(([path]) => path),
60 ...[...before.keys()].filter(path => !after.has(path)),
61]
62
63export const register: Register = on => {
64 on('session.start', async ($, e, next) => {
65 await $.command.register({
66 name: 'changed-files',
67 description: 'Show the files modified in this session (close | clear)',
68 })
69 void $.ui.open({ id: PANE, title: TITLE })
70
71 return next(e)
72 })
73
74 on('command.run', { command: 'changed-files' }, async ($, e) => {
75 const arg = e.args.trim()
76 if (arg === 'close' || arg === 'off') {
77 await $.ui.close({ id: PANE })
78 return { text: 'Changed files pane closed.' }
79 }
80 if (arg === 'clear') {
81 await update($, files, () => [])
82 return { text: 'Changed files list cleared.' }
83 }
84 await $.ui.open({ id: PANE, title: TITLE })
85 const count = (await read($, files)).length
86
87 return { text: `Changed files pane opened (${count} file${count === 1 ? '' : 's'}).` }
88 })
89
90 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
91 const ran = await next(e)
92 if (ran.deny === undefined && ran.isError !== true) await record($, e.file_path, 'Edit')
93
94 return ran
95 })
96
97 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
98 const ran = await next(e)
99 if (ran.deny === undefined && ran.isError !== true) await record($, e.file_path, 'Write')
100
101 return ran
102 })
103
104 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
105 const ran = await next(e)
106 if (ran.deny === undefined && ran.isError !== true) await record($, e.notebook_path, 'NotebookEdit')
107
108 return ran
109 })
110
111 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
112 const before = await snapshot($)
113 const ran = await next(e)
114 if (before === null || ran.deny !== undefined) return ran
115 const after = await snapshot($)
116 if (after === null) return ran
117 for (const path of changedBetween(before, after)) await record($, path, 'Bash')
118
119 return ran
120 })
121
122 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
123 const { Box, Text } = $.ui.resolve(e)
124 const list: ChangedFile[] = await read($, files)
125 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
126
127 return (
128 <Box flexDirection="column">
129 <Text bold>
130 {list.length} file{list.length === 1 ? '' : 's'} changed
131 </Text>
132 {list.length === 0 && <Text dimColor>Nothing modified yet.</Text>}
133 {list
134 .slice(-room)
135 .reverse()
136 .map(file => (
137 <Text key={`file:${file.path}`}>
138 {file.path}
139 <Text dimColor> ×{file.edits}</Text>
140 </Text>
141 ))}
142 </Box>
143 )
144 })
145}
146types/index.d.ts 8 lines1export type ChangedFile = { path: string; tool: string; edits: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'changed-files': { files: ChangedFile[] }
6 }
7}
8