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

作者:LazyJerry
最後更新:2026-10-14
Claude Code mod(以 plugin 形式載入的 hook 模組)收藏。每個子資料夾是一個獨立的 mod。
| Mod | 用途 |
|---|---|
| changed-files | 列出本 session 每一輪改到的檔案與增刪行數 |
追蹤 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。
/changed-files。看到「變更檔案面板已打開。」代表指令已註冊;輸入框打到 /changed-files 卻顯示 No matching commands,代表 mod 還沒載入。/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 型別),不放進這個資料夾,也不用手動建立。
hooks/register.tsx 288 lines1import { 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}
288hooks/lib.ts 102 lines1import 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 )
102types/index.d.ts 26 lines1export 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