SLOPSHOPPER

changed-files

A pane listing the files Claude modified during the session

newpaneguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · changed-files
│ ┃ Changed files ✕ › fix the failing auth test and add an audit log call │ ┃ 3 files changed │ ┃ src/cache.ts ×1 ⏺ Read(src/auth.ts) │ ┃ src/audit.ts ×1 ⎿ Read 6 lines │ ┃ src/auth.ts ×1 ⏺ 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 │ │ › /changed-files │ ⎿ changed-files: Changed files pane opened (3 files). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changed files
3 files changed src/cache.ts ×1 src/audit.ts ×1 src/auth.ts ×1
README

changed-files

A pane that lists every file Claude modified during the session, so you can see what changed without reading the transcript.

What it does

  • Records files written by Edit, Write and NotebookEdit (only when the call succeeded).
  • Also records files changed through 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.
  • Shows a pane titled "Changed files": N files changed, then one line per file (path relative to the session folder, ×N change count), most recent first.
  • The pane opens when the session starts.

Commands

CommandEffect
/changed-filesOpen the pane.
/changed-files closeClose the pane (off also works).
/changed-files clearEmpty the list.

Install

claude --plugin-dir /path/to/ModsTools/mods/changed-files

Limits

  • Bash tracking only works inside a git repository; elsewhere only Edit/Write/NotebookEdit are seen.
  • The Bash snapshot is skipped when more than 2000 paths are dirty.
  • A Bash command that rewrites a file with identical content, or leaves it in the same dirty state, is not counted.
  • The pane shows as many files as fit its height (newest first).

Develop

claude plugin validate mods/changed-files
claude plugin test mods/changed-files   # 6 tests
Source 2 files
hooks/register.tsx 146 lines
1import { 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}
146
types/index.d.ts 8 lines
1export type ChangedFile = { path: string; tool: string; edits: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'changed-files': { files: ChangedFile[] }
6  }
7}
8