SLOPSHOPPER

changed-files

A pane pinned near the prompt listing the files each turn changed, with added and deleted line counts; collapsible, one per session.

newpanebandguardcommandstatus
v0.1.0no licenseupdated 2026-10-14lazyjerry/claude-mods/changed-files
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · changed-files
│ ┃ 變更檔案 ✕ › fix the failing auth test and add an audit log call │ ┃ 變更檔案 0 檔 +0 −0 x: [收合] │ ┃ 這個 session 還沒有改到檔案。 ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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: 這個 session 還沒有改到檔案。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 變更檔案
變更檔案 0 檔 +0 −0 x: [收合] 這個 session 還沒有改到檔案。
README

claude-mods

作者:LazyJerry

最後更新:2026-10-14

Claude Code mod(以 plugin 形式載入的 hook 模組)收藏。每個子資料夾是一個獨立的 mod。

Mod用途
changed-files列出本 session 每一輪改到的檔案與增刪行數

changed-files

追蹤 Edit、Write、NotebookEdit 與 Bash 造成的檔案變更,依輪次整理成清單,並累計 +新增 −刪除 行數。Bash 的變更用執行前後的 git diff --numstat 與未追蹤檔案比對,所以需要在 git repo 內才能計到 Bash 造成的變更。

各環境的顯示結果

環境顯示方式
終端機(fullscreen)輸入框旁的「變更檔案」面板(x 收合/展開),輸入框上方的卡片,每輪結束追加一行摘要
VS Code 擴充套件只有 /changed-files 的文字輸出可用;面板、卡片、狀態列、對話摘要與 toast 實測都不會顯示

VS Code 的結論來自實測(Output → Claude VSCode 的 log 顯示 mod 已載入、hook 有執行,但畫面沒有對應位置),不是官方文件的說明。擴充套件日後支援後可能改變。

安裝

前置條件:已安裝 Claude Code,且 claude 指令可用。

一行指令:遠端下載並安裝(建議)

不用先 clone,下載 repo 壓縮檔到暫存目錄後執行 install.sh,裝完刪掉暫存目錄(需要 curl、tar、jq):

tmp="$(mktemp -d)" && curl -fsSL https://github.com/lazyjerry/claude-mods/archive/refs/heads/main.tar.gz | tar -xz -C "$tmp" && bash "$tmp/claude-mods-main/install.sh" && rm -rf "$tmp"

不加參數會出現互動選單;要跳過選單,在 install.sh 後面加 --all 或 mod 名稱(例如 changed-files)。結果與方式三相同:mod 複製到 ~/.claude/mods,路徑補進 ~/.claude/settings.json。

不支援 curl …/install.sh | bash:腳本從自己所在的資料夾偵測 mod,只下載腳本本身會找不到任何 mod。

以下三種方式適合要自己保留原始碼、或要改 mod 的情況。

方式一:啟動時指定資料夾(單次,適合試用)

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

方式二:用環境變數永久載入(終端機與 VS Code 都適用)

在 ~/.claude/settings.json 加入 env,路徑指向本資料夾下的 changed-files:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-mods/changed-files"
  }
}

已有 env 區塊就只加這一個鍵。要同時載入多個 mod,路徑用冒號分隔(未實測,依 PATH 慣例推定)。

方式三:複製到 ~/.claude/mods

mkdir -p ~/.claude/mods
cp -R /path/to/claude-mods/changed-files ~/.claude/mods/

再用方式二指向 ~/.claude/mods/changed-files。這樣原始碼與實際載入的版本分開,改動前請先確認要改哪一份。

上面兩步可以用 install.sh 一次完成,它會複製選定的 mod 並把 CLAUDE_CODE_PLUGIN_DIRS 補進 ~/.claude/settings.json(需要 jq):

bash install.sh                  # 互動選單,挑要裝的 mod
bash install.sh changed-files    # 直接指定 mod 名稱,可多個
bash install.sh --all            # 全部安裝
bash install.sh --list           # 只列出可安裝的 mod

重跑是安全的:已安裝的副本先搬到 ~/.claude/mods/.bak/<時間戳>/ 再覆蓋,settings.json 改動前留 settings.json.bak-<時間戳>,路徑已存在就不重複加。--mods-dir、--settings 可改目的地與設定檔位置,--no-validate 略過安裝後的 claude plugin validate。

啟用後

  1. 新開一個 Claude Code 對話或重啟 session。mod 只在 session 開始時載入,已開著的對話不會套用。
  2. 輸入 /changed-files。看到「變更檔案面板已打開。」代表指令已註冊;輸入框打到 /changed-files 卻顯示 No matching commands,代表 mod 還沒載入。
  3. 請 Claude 改任一檔案,終端機看面板,VS Code 再輸入一次 /changed-files 看清單。

驗證

claude plugin validate /path/to/claude-mods/changed-files
claude plugin test /path/to/claude-mods/changed-files

驗證預期有一則 author 缺漏的警告,不影響載入。測試預期全部通過。

疑難排解

現象處理
輸入框顯示 No matching commands確認 CLAUDE_CODE_PLUGIN_DIRS 路徑存在,並新開對話
VS Code 沒有面板或卡片預期行為,改用 /changed-files
想確認 VS Code 有沒有載入命令面板執行 Claude: Show Logs,Output 選「Claude VSCode」,篩選 changed-files,應看到 plugin.register: changed-files ... admitted
Bash 的變更沒計入確認目前目錄在 git repo 內

解除安裝

移除 settings.json 的 CLAUDE_CODE_PLUGIN_DIRS(或只拿掉這個路徑),刪除複製過去的資料夾,新開對話即可。

檔案結構

changed-files/
├── .claude-plugin/plugin.json   # mod 名稱、版本與型別契約位置
├── hooks/
│   ├── hooks.json               # 指向 register.tsx
│   ├── register.tsx             # 事件 hook 與畫面
│   └── lib.ts                   # 行數計算與 git 快照比對
├── tests/changed-files.test.ts
├── tsconfig.json
└── types/index.d.ts             # 狀態值的型別契約

.claude-plugin/types/ 由引擎在載入時自動產生(API 型別),不放進這個資料夾,也不用手動建立。

Source 3 files
hooks/register.tsx 288 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChangedFile, ChangedTurn } from '../types'
5import {
6  countLines,
7  countPatch,
8  diffSnapshots,
9  mergeFile,
10  parseNumstat,
11  parseZeroList,
12  relativeTo,
13  snippet,
14  sumFiles,
15} from './lib'
16import type { Counts, Snapshot } from './lib'
17
18const PANE = 'changed-files'
19const TITLE = '變更檔案'
20const KEEP_TURNS = 30
21const PROMPT_CHARS = 48
22const MAX_ROWS = 16
23const CARD_ROWS = 3
24
25const turns = atom({ plugin: 'changed-files', key: 'turns' } as const, [])
26const current = atom({ plugin: 'changed-files', key: 'current' } as const, null)
27const isExpanded = atom({ plugin: 'changed-files', key: 'isExpanded' } as const, true)
28
29type Engine = EngineInterface
30
31// Module-level: a hot reload starts these over, and each is re-read lazily.
32let root: string | undefined
33// undefined: not checked yet; null: the session's cwd is not inside a git repo
34let gitRoot: string | null | undefined
35
36const projectRoot = async ($: Engine): Promise<string> => {
37  root ??= await $.session.root().catch(() => '')
38  return root
39}
40
41const repoRoot = async ($: Engine): Promise<string | null> => {
42  if (gitRoot !== undefined) return gitRoot
43  const ran = await $.process.run(['git', 'rev-parse', '--show-toplevel']).catch(() => undefined)
44  gitRoot = ran !== undefined && ran.exitCode === 0 ? ran.stdout.trim() : null
45  return gitRoot
46}
47
48/** The working tree against HEAD: tracked files' numstat and the untracked paths. */
49const snapshot = async ($: Engine): Promise<Snapshot | undefined> => {
50  const cwd = await repoRoot($)
51  if (cwd === null) return undefined
52  const [numstat, others] = await Promise.all([
53    $.process.run(['git', 'diff', '--numstat', '-z', 'HEAD', '--'], { cwd, timeoutMs: 10_000 }),
54    $.process.run(['git', 'ls-files', '--others', '--exclude-standard', '-z'], { cwd, timeoutMs: 10_000 }),
55  ]).catch(() => [undefined, undefined])
56  if (numstat === undefined || others === undefined) return undefined
57  return {
58    tracked: numstat.exitCode === 0 ? parseNumstat(numstat.stdout) : new Map(),
59    untracked: others.exitCode === 0 ? new Set(parseZeroList(others.stdout)) : new Set(),
60  }
61}
62
63/** Files a turn changed in the session's list, under the current turn (or an unnamed one). */
64const record = async ($: Engine, path: string, counts: Counts): Promise<void> => {
65  if (counts.added === 0 && counts.deleted === 0) return
66  const file: ChangedFile = { path: relativeTo(await projectRoot($), path), ...counts }
67  const now = await read($, current)
68  await update($, turns, list => {
69    const last = list[list.length - 1]
70    const id = now?.id ?? last?.id ?? 'session'
71    const prompt = now?.prompt ?? last?.prompt ?? '(沒有提示)'
72    if (last !== undefined && last.id === id) {
73      const turn: ChangedTurn = { ...last, files: mergeFile(last.files, file) }
74      return [...list.slice(0, -1), turn]
75    }
76    return [...list, { id, prompt, files: [file] }].slice(-KEEP_TURNS)
77  })
78  $.ui.status(await summary($))
79}
80
81/** 沒有 Pane/AbovePrompt 位置的 surface(如 VS Code)改看狀態列與指令輸出的文字版。 */
82const summary = async ($: Engine): Promise<string | undefined> => {
83  const files = (await read($, turns)).flatMap(turn => turn.files)
84  if (files.length === 0) return undefined
85  const total = sumFiles(files)
86  const paths = new Set(files.map(file => file.path)).size
87
88  return `${TITLE} ${paths} 檔 +${total.added} −${total.deleted}${total.isApprox ? ' ~' : ''}`
89}
90
91const listText = async ($: Engine): Promise<string> => {
92  const list = await read($, turns)
93  if (list.length === 0) return '這個 session 還沒有改到檔案。'
94  const head = (await summary($)) ?? TITLE
95  const rows = [...list].reverse().flatMap((turn, i) => [
96    `#${list.length - i} ${turn.prompt}`,
97    ...turn.files.map(file => `  +${file.added} −${file.deleted}  ${file.path}${file.isApprox ? ' ~' : ''}`),
98  ])
99
100  return [head, ...rows].join('\n')
101}
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    root = undefined
106    gitRoot = undefined
107    await $.command.register({
108      name: 'changed-files',
109      description: '打開本 session 的變更檔案面板(每輪的檔案與增刪行數)',
110    })
111    void $.ui.open({ id: PANE, title: TITLE, rows: MAX_ROWS }).catch(() => undefined)
112
113    return next(e)
114  })
115
116  on('command.run', { command: 'changed-files' }, async $ => {
117    await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: MAX_ROWS })
118
119    return { text: `變更檔案面板已打開。\n${await listText($)}` }
120  })
121
122  on('turn.start', async ($, e, next) => {
123    await update($, current, () => ({ id: e.turnId, prompt: snippet(e.text, PROMPT_CHARS) }))
124
125    return next(e)
126  })
127
128  // 每輪結束,這輪有改到檔案才在對話裡追加一行摘要(不改動模型的回答)
129  on('turn.complete', async ($, e, next) => {
130    const ran = await next(e)
131    const turn = (await read($, turns)).find(one => one.id === e.turnId)
132    if (turn !== undefined && turn.files.length > 0) {
133      const { added, deleted } = sumFiles(turn.files)
134      $.ui.log(`${TITLE}(本輪)${turn.files.length} 檔 +${added} −${deleted}`)    }
135
136    return ran
137  })
138
139  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
140    const ran = await next(e)
141    if (ran.deny === undefined && ran.isError === undefined && ran.result.staged !== true) {
142      await record($, e.file_path, countPatch(ran.result.structuredPatch))
143    }
144
145    return ran
146  })
147
148  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
149    const ran = await next(e)
150    if (ran.deny === undefined && ran.isError === undefined && ran.result.staged !== true) {
151      const { structuredPatch, originalFile, content } = ran.result
152      const counts: Counts =
153        structuredPatch.length > 0
154          ? countPatch(structuredPatch)
155          : {
156              added: countLines(content),
157              deleted: originalFile === null ? 0 : countLines(originalFile),
158              isApprox: originalFile !== null && originalFile !== content,
159            }
160      await record($, e.file_path, counts)
161    }
162
163    return ran
164  })
165
166  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
167    const ran = await next(e)
168    if (ran.deny === undefined && ran.isError === undefined && ran.result.error === undefined) {
169      const { edit_mode, new_source, old_source } = ran.result
170      await record($, e.notebook_path, {
171        added: edit_mode === 'delete' ? 0 : countLines(new_source),
172        deleted: countLines(old_source ?? ''),
173        isApprox: true,
174      })
175    }
176
177    return ran
178  })
179
180  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
181    const before = await snapshot($)
182    const ran = await next(e)
183    if (before === undefined || ran.deny !== undefined) return ran
184    const after = await snapshot($)
185    if (after === undefined) return ran
186    const cwd = (await repoRoot($)) ?? ''
187    const { changed, created } = diffSnapshots(before, after)
188    for (const [path, counts] of changed) {
189      await record($, `${cwd}/${path}`, { ...counts, isApprox: false })
190    }
191    for (const path of created) {
192      const text = await $.fs.read(`${cwd}/${path}`).catch(() => undefined)
193      await record($, `${cwd}/${path}`, {
194        added: text === undefined ? 0 : countLines(text),
195        deleted: 0,
196        isApprox: text === undefined,
197      })
198    }
199
200    return ran
201  })
202
203  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
204    const { Box, Text, Button } = $.ui.resolve(e)
205    const list = await read($, turns)
206    const expanded = await read($, isExpanded)
207    const allFiles = list.flatMap(turn => turn.files)
208    const total = sumFiles(allFiles)
209    const paths = new Set(allFiles.map(file => file.path)).size
210    const width = Math.max(20, e.props.bodyColumns)
211
212    const toggle = () => {
213      void update($, isExpanded, was => !was)
214      void $.ui.open({ id: PANE, title: TITLE, rows: expanded ? 1 : MAX_ROWS }).catch(() => undefined)
215    }
216
217    return (
218      <Box flexDirection="column" width={width}>
219        <Box flexDirection="row" gap={1}>
220          <Text bold>{TITLE}</Text>
221          <Text>
222            {paths} 檔 +{total.added} −{total.deleted}
223            {total.isApprox ? ' ~' : ''}
224          </Text>
225          <Button
226            key="toggle"
227            hotkey="x"
228            plain
229            dimColor
230            label={expanded ? '[收合]' : '[展開]'}
231            onPress={toggle}
232          />
233        </Box>
234        {expanded && list.length === 0 && <Text dimColor>這個 session 還沒有改到檔案。</Text>}
235        {expanded &&
236          [...list].reverse().map((turn, i) => (
237            <Box flexDirection="column">
238              <Text dimColor wrap="truncate-end">
239                #{list.length - i} {turn.prompt}
240              </Text>
241              {turn.files.map(file => (
242                <Box flexDirection="row" gap={1}>
243                  <Text color="green">{`+${file.added}`.padStart(5)}</Text>
244                  <Text color="red">{`−${file.deleted}`.padStart(5)}</Text>
245                  <Text wrap="truncate-start">
246                    {file.path}
247                    {file.isApprox ? ' ~' : ''}
248                  </Text>
249                </Box>
250              ))}
251            </Box>
252          ))}
253      </Box>
254    )
255  })
256
257  // 輸入框上方的狀態卡片:沒有 Pane 位置的 surface(如 VS Code)也能看到本 session 的變更
258  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259    const list = await read($, turns)
260    const allFiles = list.flatMap(turn => turn.files)
261    if (allFiles.length === 0) return next(e)
262
263    const { Box, Text } = $.ui.resolve(e)
264    const total = sumFiles(allFiles)
265    const paths = new Set(allFiles.map(file => file.path)).size
266    const latest = list[list.length - 1]?.files.slice(-CARD_ROWS) ?? []
267
268    return (
269      <Box flexDirection="column">
270        <Box flexDirection="row" gap={1}>
271          <Text bold>{TITLE}</Text>
272          <Text>{paths} 檔</Text>
273          <Text color="green">+{total.added}</Text>
274          <Text color="red">−{total.deleted}</Text>
275          {total.isApprox && <Text dimColor>~</Text>}
276        </Box>
277        {latest.map(file => (
278          <Box flexDirection="row" gap={1}>
279            <Text color="green">{`+${file.added}`.padStart(5)}</Text>
280            <Text color="red">{`−${file.deleted}`.padStart(5)}</Text>
281            <Text wrap="truncate-start">{file.path}</Text>
282          </Box>
283        ))}
284      </Box>
285    )
286  })
287}
288
hooks/lib.ts 102 lines
1import type { ChangedFile } from '../types'
2
3export type Counts = { added: number; deleted: number; isApprox: boolean }
4export type Numstat = { added: number; deleted: number }
5export type Snapshot = { tracked: Map<string, Numstat>; untracked: Set<string> }
6
7type Patch = readonly { lines: readonly string[] }[]
8
9/** Counts the `+` and `-` lines of a structured patch (Edit / Write results). */
10export const countPatch = (patch: Patch): Counts => {
11  let added = 0
12  let deleted = 0
13  for (const hunk of patch) {
14    for (const line of hunk.lines) {
15      if (line.startsWith('+')) added += 1
16      else if (line.startsWith('-')) deleted += 1
17    }
18  }
19  return { added, deleted, isApprox: false }
20}
21
22/** Lines of a text: none for '', a trailing newline adds no line. */
23export const countLines = (text: string): number => {
24  if (text === '') return 0
25  const body = text.endsWith('\n') ? text.slice(0, -1) : text
26  return body.split('\n').length
27}
28
29/** `git diff --numstat -z`: "added\tdeleted\tpath\0" per file; binary files read `-`. */
30export const parseNumstat = (stdout: string): Map<string, Numstat> => {
31  const map = new Map<string, Numstat>()
32  for (const row of stdout.split('\0')) {
33    if (row === '') continue
34    const [a, d, ...rest] = row.split('\t')
35    const path = rest.join('\t')
36    if (path === '' || a === undefined || d === undefined) continue
37    map.set(path, { added: a === '-' ? 0 : Number(a), deleted: d === '-' ? 0 : Number(d) })
38  }
39  return map
40}
41
42/** `git ls-files -z`: paths separated by NUL. */
43export const parseZeroList = (stdout: string): string[] =>
44  stdout.split('\0').filter(path => path !== '')
45
46/**
47 * What a Bash command changed, from two snapshots against HEAD: a tracked
48 * file's counts grew, or an untracked file appeared. A count that shrank
49 * (a commit, a revert) is clamped to 0 and dropped.
50 */
51export const diffSnapshots = (
52  before: Snapshot,
53  after: Snapshot,
54): { changed: Map<string, Numstat>; created: string[] } => {
55  const changed = new Map<string, Numstat>()
56  for (const [path, now] of after.tracked) {
57    const was = before.tracked.get(path) ?? { added: 0, deleted: 0 }
58    const added = Math.max(0, now.added - was.added)
59    const deleted = Math.max(0, now.deleted - was.deleted)
60    if (added > 0 || deleted > 0) changed.set(path, { added, deleted })
61  }
62  const created = [...after.untracked].filter(path => !before.untracked.has(path))
63  return { changed, created }
64}
65
66/** The path as shown: relative to `root` when inside it. */
67export const relativeTo = (root: string, path: string): string => {
68  const base = root.endsWith('/') ? root : `${root}/`
69  return path.startsWith(base) ? path.slice(base.length) : path
70}
71
72/** The first line of a prompt, cut to `max` characters. */
73export const snippet = (text: string, max: number): string => {
74  const line = text.split('\n').find(one => one.trim() !== '') ?? ''
75  const chars = [...line.trim()]
76  return chars.length > max ? `${chars.slice(0, max).join('')}…` : chars.join('')
77}
78
79/** Adds one change to a turn's list, summing into an existing row of the same path. */
80export const mergeFile = (files: readonly ChangedFile[], one: ChangedFile): ChangedFile[] => {
81  const index = files.findIndex(file => file.path === one.path)
82  if (index < 0) return [...files, one]
83  const was = files[index]!
84  const merged: ChangedFile = {
85    path: was.path,
86    added: was.added + one.added,
87    deleted: was.deleted + one.deleted,
88    isApprox: was.isApprox || one.isApprox,
89  }
90  return [...files.slice(0, index), merged, ...files.slice(index + 1)]
91}
92
93export const sumFiles = (files: readonly ChangedFile[]): Counts =>
94  files.reduce<Counts>(
95    (sum, file) => ({
96      added: sum.added + file.added,
97      deleted: sum.deleted + file.deleted,
98      isApprox: sum.isApprox || file.isApprox,
99    }),
100    { added: 0, deleted: 0, isApprox: false },
101  )
102
types/index.d.ts 26 lines
1export type ChangedFile = {
2  path: string
3  added: number
4  deleted: number
5  /** True when the counts were estimated (no patch available) rather than read off a diff. */
6  isApprox: boolean
7}
8
9export type ChangedTurn = {
10  id: string
11  prompt: string
12  files: ChangedFile[]
13}
14
15export type CurrentTurn = { id: string; prompt: string }
16
17declare module 'claude-code' {
18  interface PluginState {
19    'changed-files': {
20      turns: ChangedTurn[]
21      current: CurrentTurn | null
22      isExpanded: boolean
23    }
24  }
25}
26