側邊欄的檔案清單:這次對話新建、修改、刪掉了哪些檔案,各改了幾行。/files 開或關

一個 Claude Code 的 mod。側邊欄列出這次對話新建、修改、刪掉了哪些檔案,各改了幾行。/files 開或關、/files clear 清空。
English summary at the end.
側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。
用 marketplace(推薦)
claude plugin marketplace add jessetsai1024/claude-files
claude plugin install files@claude-files
然後在對話裡打 /reload-plugins,或重開 Claude Code。
或者 clone 下來接捷徑(之後 git pull 就是更新)
macOS/Linux:
git clone https://github.com/jessetsai1024/claude-files.git
cd claude-files && ./install.sh
Windows(原生版,在 PowerShell 裡):
git clone https://github.com/jessetsai1024/claude-files.git
cd claude-files
.\install.ps1
原理:放在 ~/.claude/skills/files/ 底下的 plugin 會被 Claude Code 自動載入,腳本只是建一個捷徑指回這個 repo(Windows 用目錄接合點,不需要管理員權限)。PowerShell 說不准跑腳本就先 Set-ExecutionPolicy -Scope Process Bypass。裝完關掉所有 Claude Code 視窗再重開。
移除:./uninstall.sh 或 .\uninstall.ps1,只拿掉捷徑。
settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。claude plugin validate .、claude plugin test .;第一次載入後 .claude-plugin/types/ 會出現型別檔,之後 tsc -p . 可以做型別檢查(那個資料夾是引擎寫的,已在 .gitignore)。2026 年 10 月 2 日到 3 日之間做的,作者是 Jesse 與螢(鏡 螢,號石火,一個 Claude 分身)。原本六個 mod 放在同一個 repo claude-mods,10 月 6 日拆成一個 mod 一個 repo,舊 repo 已移除。MIT 授權。
files is a mod for Claude Code: A pane listing every file this session created, edited or deleted, with line counts. /files toggles, /files clear resets. The pane opens by itself in a new session only when the terminal is wide enough (144 columns, or 110 once you have opened it by hand); narrower than that it waits for the command.
Install with claude plugin marketplace add jessetsai1024/claude-files then claude plugin install files@claude-files; or clone and run ./install.sh (macOS/Linux) or .\install.ps1 (native Windows, junction, no admin), which links the repo into ~/.claude/skills/files so git pull is the update. Requires Claude Code ≥ 2.1.287. UI text is Traditional Chinese. Windows has unit tests but no on-device test yet. Split out of a former six-mod repo on 2026-10-06. MIT.
hooks/register.ts 325 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { changeOf, commandChangesOf, isRoot, keyOf, linesOf, normalize, pathsIn } from './view'
4import type { Change, FileRow, Line, Seen } from './view'
5
6const PANE = 'files'
7const REFRESH_MS = 2000
8const ROOT_MARKERS = ['.git', '.claude-plugin', 'package.json', 'pyproject.toml', 'Cargo.toml', 'CLAUDE.md', 'MEMORY.md', 'handoff.md']
9const MAX_ROOT_DEPTH = 8
10
11/** 清單上的一個檔案,mod 記在記憶體裡的樣子;畫的時候再把 agentId 換成幫手種類。 */
12type Entry = Omit<FileRow, 'who'> & { agentId?: string }
13
14/** mod 在記憶體裡記的全部東西;register 每次載入建一份新的。 */
15type State = {
16 home: string
17 cwd: string
18 entries: Map<string, Entry>
19 // 資料夾對到它的專案資料夾,找過就記著
20 roots: Map<string, string>
21}
22
23/** 查一批路徑現在的樣子:一般檔案是修改時間,資料夾是 'dir',不存在、其他種類、查失敗都是 null。 */
24async function seenOf($: EngineInterface, paths: readonly string[]): Promise<Map<string, Seen>> {
25 const stats = await Promise.all(
26 paths.map(path =>
27 $.fs.stat(path).then(
28 (stat): Seen => (stat.kind === 'file' ? stat.mtimeMs : stat.kind === 'dir' ? 'dir' : null),
29 () => null,
30 ),
31 ),
32 )
33
34 return new Map(paths.map((path, at) => [path, stats[at] ?? null]))
35}
36
37/**
38 * 找一個檔案屬於哪個資料夾:從它所在的資料夾往上找,第一個有 .git、package.json、CLAUDE.md 這類記號的就是;
39 * 在 cwd 裡面的一律算 cwd。找到家目錄或根目錄還沒有,就用它所在的資料夾。
40 */
41async function rootOf($: EngineInterface, state: State, path: string): Promise<string> {
42 const dir = normalize(`${path}/..`)
43
44 if (state.cwd !== '' && (keyOf(dir) === keyOf(state.cwd) || keyOf(dir).startsWith(`${keyOf(state.cwd)}/`))) {
45 return state.cwd
46 }
47
48 const cached = state.roots.get(dir)
49
50 if (cached !== undefined) {
51 return cached
52 }
53
54 let at = dir
55
56 for (let depth = 0; depth < MAX_ROOT_DEPTH && !isRoot(at) && at !== state.home; depth += 1) {
57 const marks = await Promise.all(ROOT_MARKERS.map(name => $.fs.exists(`${at}/${name}`).catch(() => false)))
58
59 if (marks.some(Boolean)) {
60 state.roots.set(dir, at)
61
62 return at
63 }
64
65 at = normalize(`${at}/..`)
66 }
67
68 state.roots.set(dir, dir)
69
70 return dir
71}
72
73/**
74 * 把一次改動記進清單:已經有的累加次數和行數,沒有的新增一筆。
75 * 清單用 keyOf 當索引,所以 Windows 上只差大小寫的路徑會併成同一筆。刪掉的(isGone)標成刪掉,其他的標回還在。
76 */
77async function record(
78 $: EngineInterface,
79 state: State,
80 change: Change,
81 viaCommand: boolean,
82 agentId: string | undefined,
83): Promise<void> {
84 const path = normalize(change.path)
85 const now = await $.clock.now()
86 const known = state.entries.get(keyOf(path))
87 const entry: Entry = known ?? {
88 path,
89 root: await rootOf($, state, path),
90 kind: change.isNew ? 'new' : 'changed',
91 added: 0,
92 removed: 0,
93 hasCounts: false,
94 count: 0,
95 lastAt: now,
96 isGone: false,
97 }
98
99 entry.count += 1
100 entry.lastAt = now
101 entry.isGone = change.isGone === true
102
103 if (change.isDir === true) {
104 entry.isDir = true
105 } else {
106 delete entry.isDir
107 }
108
109 if (!viaCommand) {
110 entry.added += change.added
111 entry.removed += change.removed
112 entry.hasCounts = true
113 }
114
115 if (agentId === undefined) {
116 delete entry.agentId
117 } else {
118 entry.agentId = agentId
119 }
120
121 state.entries.set(keyOf(path), entry)
122}
123
124/** 檢查清單上每個檔案還在不在硬碟上。 */
125async function checkGone($: EngineInterface, state: State): Promise<void> {
126 const entries = [...state.entries.values()]
127 const exists = await Promise.all(entries.map(entry => $.fs.exists(entry.path).catch(() => true)))
128
129 entries.forEach((entry, at) => {
130 entry.isGone = exists[at] === false
131 })
132}
133
134/** 側邊欄現在有沒有開著、看得到。 */
135async function isShown($: EngineInterface): Promise<boolean> {
136 return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
137}
138
139/**
140 * 【職責】把檔案清單面板接上 Claude Code:提供 /files,在側邊欄列出這次對話新建、修改、刪掉了哪些檔案,
141 * 各加減幾行、改過幾次、是不是幫手改的。讀工具呼叫的參數與結果、查檔案的修改時間和存不存在;
142 * 不讀檔案內容、不改任何東西、不連網路、不寫檔。
143 * 【何時能呼叫】引擎載入這個 mod 時呼叫一次;重新載入會再呼叫,清單從空的開始。
144 * 【行為】有人在用的 session(不是 claude -p)一開始就自己打開側邊欄;終端機不夠寬時先等著,
145 * 寬度夠了才出現(主人自己開過的 110 格,沒開過的 144 格,這是系統的規定)。
146 * /files:側邊欄沒開就開、開著就關。/files close:關掉。/files clear:清空清單。/files 數字:用那個寬度(格數)開。
147 * Write、Edit、NotebookEdit 成功時記下檔案和加減行數(主對話和幫手的都算)。
148 * Bash 和 PowerShell(Windows)跑之前先從指令裡猜出看起來像檔案路徑的字、記下修改時間,跑完再比一次:
149 * 新出現或時間變了的記成「指令改的」,不算行數;本來在、跑完不見的檔案或資料夾記成刪掉,
150 * 就算它本來不在清單上也會新增一筆(資料夾的路徑結尾加「/」)。指令出錯也照樣比對(改到一半的檔案也算)。
151 * 放到背景跑的指令,跑完前就比完了,所以抓不到;檔名在執行時才組出來的(萬用字元、迴圈、腳本裡)也抓不到。
152 * Windows 路徑不分大小寫,只差大小寫的算同一個檔案。
153 * 被擋下的工具呼叫不記;Write、Edit、NotebookEdit 出錯的也不記。
154 * 側邊欄開著時每 2 秒檢查一次清單上的檔案還在不在,不在的標成刪掉(但留在清單上),又出現了就恢復。
155 * /clear 之後清單清空。這個 mod 只看工具呼叫,不改它、不擋它,工具的結果原樣交回去。
156 */
157export const register: Register = on => {
158 const state: State = { home: '', cwd: '', entries: new Map(), roots: new Map() }
159
160 on('session.start', async ($, e, next) => {
161 await $.command.register({
162 name: 'files',
163 description: '側邊欄的檔案清單:這次對話新建、修改、刪掉了哪些檔案;/files 開或關、/files clear 清空',
164 immediate: true,
165 })
166 // Windows 沒有 HOME,家目錄在 USERPROFILE;存成 normalize 過的寫法,跟其他路徑比對才對得上
167 const rawHome = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
168 state.home = rawHome === '' ? '' : normalize(rawHome)
169 state.cwd = normalize(e.cwd)
170
171 // 一開 session 就自己打開;不是主人叫的,終端機要夠寬才放得出來,不夠寬就先等著,不用等它
172 if (e.isInteractive) {
173 void $.ui.open({ id: PANE, title: '檔案' })
174 }
175
176 $.clock.every(REFRESH_MS, () => {
177 void isShown($).then(async shown => {
178 if (shown) {
179 await checkGone($, state)
180 $.ui.invalidate('ui.render')
181 }
182 })
183 })
184
185 return next(e)
186 })
187
188 on('classic.SessionStart', { source: ['clear'] }, ($, e, next) => {
189 state.entries.clear()
190 $.ui.invalidate('ui.render')
191
192 return next(e)
193 })
194
195 on('tool.call', async ($, e, next) => {
196 let candidates: string[] = []
197 let before = new Map<string, Seen>()
198 // Windows 沒裝 Git Bash(或主人自己開了 CLAUDE_CODE_USE_POWERSHELL_TOOL)時,跑指令的工具叫 PowerShell;
199 // 這台機器的型別表裡沒有它,所以用字串比、自己讀 command
200 const tool: string = e.tool
201 const command: unknown = (e as { command?: unknown }).command
202
203 if ((tool === 'Bash' || tool === 'PowerShell') && typeof command === 'string') {
204 try {
205 candidates = pathsIn(command, state.cwd, state.home, tool === 'PowerShell' ? 'powershell' : 'bash')
206 before = await seenOf($, candidates)
207 } catch {
208 // 猜路徑或查時間出錯就不比了,指令照跑
209 candidates = []
210 }
211 }
212
213 const ran = await next(e)
214
215 try {
216 // 被擋下的沒有跑;指令出錯(結束代碼不是 0)時檔案可能已經改了,照樣比對
217 if (ran.deny === undefined) {
218 if (candidates.length > 0) {
219 for (const change of commandChangesOf(before, await seenOf($, candidates))) {
220 await record($, state, change, true, e.agentId)
221 }
222 } else if (ran.isError !== true) {
223 const change = changeOf(e.tool, e, ran.result)
224
225 if (change !== null) {
226 await record($, state, change, false, e.agentId)
227 }
228 }
229
230 $.ui.invalidate('ui.render')
231 }
232 } catch {
233 // 記錄失敗不影響工具的結果
234 }
235
236 return ran
237 })
238
239 on('command.run', { command: 'files' }, async ($, e) => {
240 const arg = e.args.trim()
241 const wanted = Number.parseInt(arg, 10)
242 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
243
244 if (arg === 'clear') {
245 state.entries.clear()
246 $.ui.invalidate('ui.render')
247
248 return { text: '檔案清單清空了。' }
249 }
250
251 if (arg === 'close' || (arg === '' && isUp)) {
252 await $.ui.close({ id: PANE })
253
254 return {}
255 }
256
257 await checkGone($, state)
258
259 const opened = await $.ui.open(
260 Number.isInteger(wanted) && wanted > 0
261 ? { id: PANE, title: '檔案', columns: wanted }
262 : { id: PANE, title: '檔案' },
263 )
264
265 if (!opened.isPlaced) {
266 return { text: `側邊欄沒有被放出來:${opened.reason}` }
267 }
268
269 // 重新打開時引擎可能直接拿上次畫好的結果來用,所以自己要求重畫
270 $.ui.invalidate('ui.render')
271
272 return {}
273 })
274
275 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
276 const { Box, Text } = $.ui.resolve(e)
277 const hasHelpers = [...state.entries.values()].some(entry => entry.agentId !== undefined)
278 const typeOf = new Map(hasHelpers ? (await $.agent.list()).map(agent => [agent.id, agent.type]) : [])
279 const rows: FileRow[] = [...state.entries.values()].map(({ agentId, ...row }) =>
280 agentId === undefined ? row : { ...row, who: typeOf.get(agentId) ?? '幫手' },
281 )
282 const lineOf = (segments: Line) =>
283 Text({
284 wrap: 'truncate-end',
285 children:
286 segments.length === 0
287 ? [' ']
288 : segments
289 .filter(segment => segment.text !== '')
290 .map(segment =>
291 Text({
292 ...(segment.color === undefined ? {} : { color: segment.color }),
293 ...(segment.bold === true ? { bold: true } : {}),
294 ...(segment.dim === true ? { dimColor: true } : {}),
295 children: [segment.text],
296 }),
297 ),
298 })
299
300 return Box({
301 flexDirection: 'column',
302 children: linesOf(rows, state.cwd, state.home, await $.clock.now(), e.props.bodyColumns).map(lineOf),
303 })
304 })
305}
306
307// #region AI-NOTES
308// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
309// 2026-10-03 Bash 的比對是在 await next(e) 前後各 stat 一次;指令裡猜出來的路徑最多 40 個,平行查,
310// 每個 Bash 呼叫多花兩輪 stat。前後的記錄都包 try,出錯也一定把 ran 原樣交回去。
311// 2026-10-03 cwd 用 session.start 給的 e.cwd;Bash 工具每次跑完會把目錄重設回這裡,所以指令裡的 cd 只影響那一次,
312// pathsIn 才會把每個 cd 目標也拿來接相對路徑。
313// 2026-10-03 已知漏抓(主人實測回報):檔名在執行時才組出來的指令(for 迴圈 touch "$i.txt"、{1..100}.txt、rm /tmp/a*),
314// 和「全部刪掉,除了 X」(find/xargs)都抓不到。
315// 討論過的修法:會改檔的指令(rm、mv、cp、touch、>、python…)前後各讀一次 cwd 與 cd 目標(只一層)比對。
316// 前一版「往下兩層、每個指令都做」被主人以效能否決。主人決定先用一陣子再說,還沒改;FileChanged 要先指定檔案,不適合。
317// 2026-10-03 晚上補了兩個洞,都不多花 stat:指令前在、指令後不見的檔案記成刪掉(以前直接跳過,不在清單上的就消失了);
318// 資料夾 stat 成 'dir',被 rm -rf 整個刪掉也記一筆。觸發案例:rm jev-eli5-v1.html 與 rm -rf /tmp/jevsrc 都沒出現。
319// 2026-10-03 Windows:Git Bash 找得到時工具叫 Bash(路徑可能寫成 /c/Users/…,pathsIn 會換成 C:/),
320// 找不到或設了 CLAUDE_CODE_USE_POWERSHELL_TOOL=1 時叫 PowerShell(從 2.1.288 執行檔字串查到的名字,mac 的型別表沒有它)。
321// 兩者都沒在 Windows 實機跑過,只有單元測試。
322// 2026-10-03 「刪掉」只在側邊欄開著時每 2 秒、或打 /files 打開時檢查;關著的時候不查,打開時才補上。
323// 2026-10-03 結構照已刪掉的 crew mod(2026-10-02 寫、10-03 刪):$ 只傳給檔案最上層的 function(validate 的規定),畫面全是 Text。
324// #endregion
325hooks/view.ts 562 lines1/** 一行裡的一小段字,帶自己的顏色與粗細。 */
2export type Segment = {
3 /** 要顯示的字。 */
4 text: string
5 /** 顏色,`#rrggbb`;沒給就是終端機預設字色。 */
6 color?: string
7 /** 粗體。 */
8 bold?: boolean
9 /** 暗一階,給次要資訊用。 */
10 dim?: boolean
11 /** 放不下要裁的時候留結尾、開頭補「…」(檔案路徑用);只影響排版,畫的時候不看。 */
12 keepTail?: boolean
13}
14
15/** 畫面上的一行:由左到右排的幾段字;空陣列是空白行。 */
16export type Line = Segment[]
17
18/** 一次工具呼叫對一個檔案做的改動。 */
19export type Change = {
20 /** 檔案的絕對路徑。 */
21 path: string
22 /** 這次是新建的(原本沒有這個檔案)。 */
23 isNew: boolean
24 /** 加了幾行。 */
25 added: number
26 /** 刪了幾行。 */
27 removed: number
28 /** 這次被刪掉了(指令跑之前在、跑完不在);沒寫就是沒刪。 */
29 isGone?: boolean
30 /** 這是資料夾不是檔案(目前只有刪掉資料夾會出現);沒寫就是檔案。 */
31 isDir?: boolean
32}
33
34/** 指令前後查到的一個路徑:一般檔案是它的修改時間(毫秒),資料夾是 'dir',不存在或查不到是 null。 */
35export type Seen = number | 'dir' | null
36
37/** 清單上的一個檔案,畫面要的全部資料。 */
38export type FileRow = {
39 /** 檔案的絕對路徑。 */
40 path: string
41 /** 它屬於哪個資料夾(分組用):專案資料夾,找不到就是它所在的資料夾。 */
42 root: string
43 /** new 是這次對話新建的,changed 是本來就有、被改過的。 */
44 kind: 'new' | 'changed'
45 /** 用 Write、Edit 改的加了幾行(指令改的不算)。 */
46 added: number
47 /** 用 Write、Edit 改的刪了幾行(指令改的不算)。 */
48 removed: number
49 /** 有沒有用 Write、Edit 改過;false 表示只被指令改過,行數不知道。 */
50 hasCounts: boolean
51 /** 改過幾次。 */
52 count: number
53 /** 最後一次是哪種幫手改的;主對話改的是 undefined。 */
54 who?: string
55 /** 最後一次改的時間,毫秒。 */
56 lastAt: number
57 /** 已經不在硬碟上了。 */
58 isGone: boolean
59 /** 這一行是資料夾(被指令整個刪掉的那種),畫的時候路徑後面加「/」;沒寫就是檔案。 */
60 isDir?: boolean
61}
62
63const NEW = '#5ad67d'
64const CHANGED = '#f7d154'
65const GONE = '#ff6b6b'
66const DIM = '#6b7280'
67const HELPER = '#ff7eb6'
68const MAX_CANDIDATES = 40
69// 換目錄的指令(bash 和 PowerShell),比對時一律小寫
70const CD_VERBS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl'])
71// 會讓檔案或資料夾消失的指令;出現時沒有副檔名的單字也當路徑查
72const REMOVE_VERBS = new Set(['rm', 'rmdir', 'unlink', 'trash', 'mv', 'del', 'erase', 'rd', 'remove-item', 'ri', 'move', 'move-item', 'mi'])
73
74/**
75 * 【行為】一段字在終端機佔幾格寬:中日韓文字、全形標點、表情符號算 2 格,其他算 1 格。
76 * 跟 ctx-panel 的同名函式一樣。
77 */
78export function widthOf(text: string): number {
79 let width = 0
80
81 for (const glyph of text) {
82 const code = glyph.codePointAt(0) ?? 0
83 const isWide =
84 (code >= 0x1100 && code <= 0x115f) ||
85 (code >= 0x2e80 && code <= 0xa4cf) ||
86 (code >= 0xac00 && code <= 0xd7a3) ||
87 (code >= 0xf900 && code <= 0xfaff) ||
88 (code >= 0xfe30 && code <= 0xfe4f) ||
89 (code >= 0xff00 && code <= 0xff60) ||
90 (code >= 0xffe0 && code <= 0xffe6) ||
91 (code >= 0x1f300 && code <= 0x1faff) ||
92 (code >= 0x20000 && code <= 0x3fffd)
93 width += isWide ? 2 : 1
94 }
95
96 return width
97}
98
99/**
100 * 【行為】把一段字裁到最多 max 格寬。超過時留開頭、結尾補「…」;keepTail 為 true 時改成留結尾、開頭補「…」。
101 * 本來就放得下就原樣回傳。max 小於 1 回空字串。
102 */
103export function fit(text: string, max: number, keepTail = false): string {
104 if (widthOf(text) <= max) {
105 return text
106 }
107
108 if (max < 1) {
109 return ''
110 }
111
112 const glyphs = Array.from(text)
113 let kept = ''
114
115 if (keepTail) {
116 for (let at = glyphs.length - 1; at >= 0; at -= 1) {
117 const next = (glyphs[at] ?? '') + kept
118
119 if (widthOf(next) > max - 1) {
120 break
121 }
122
123 kept = next
124 }
125
126 return `…${kept}`
127 }
128
129 for (const glyph of glyphs) {
130 if (widthOf(kept + glyph) > max - 1) {
131 break
132 }
133
134 kept += glyph
135 }
136
137 return `${kept}…`
138}
139
140/** 【行為】把「過了多久」寫成白話:不到 1 秒是「剛剛」,再來是「12 秒前」「3 分前」「2 小時前」。 */
141export function agoOf(ms: number): string {
142 const seconds = Math.floor(Math.max(0, ms) / 1000)
143
144 if (seconds < 1) {
145 return '剛剛'
146 }
147
148 if (seconds < 60) {
149 return `${seconds} 秒前`
150 }
151
152 if (seconds < 3600) {
153 return `${Math.floor(seconds / 60)} 分前`
154 }
155
156 return `${Math.floor(seconds / 3600)} 小時前`
157}
158
159/** 【行為】判斷是不是絕對路徑:以「/」或「\\」開頭,或 Windows 的「C:\\」「C:/」這種磁碟機代號開頭。 */
160export function isAbsolute(path: string): boolean {
161 return path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:([\\/]|$)/.test(path)
162}
163
164/** 【行為】判斷是不是根目錄:「/」,或 Windows 的「C:/」(normalize 過的寫法)。 */
165export function isRoot(path: string): boolean {
166 return path === '/' || /^[A-Za-z]:\/$/.test(path)
167}
168
169/**
170 * 【行為】拿來比對「是不是同一個檔案」的寫法,路徑要先 normalize 過。
171 * 磁碟機代號開頭的(Windows)整串換成小寫,因為 Windows 不分大小寫,C:/Users/A.txt 跟 c:/users/a.txt 是同一個;
172 * 其他路徑原樣回傳。只拿來比對和當清單的索引,畫面上顯示的還是原本的寫法。
173 */
174export function keyOf(path: string): string {
175 return /^[A-Za-z]:\//.test(path) ? path.toLowerCase() : path
176}
177
178/**
179 * 【行為】把路徑整理乾淨:Windows 的「\\」先換成「/」、連續的「/」併成一個、「.」拿掉、「..」往上一層、
180 * 結尾的「/」拿掉(根目錄除外)。Windows 的磁碟機代號留在開頭(C:\\Users\\a\\..\\b → C:/Users/b),
181 * 往上超過磁碟機就停在「C:/」。相對路徑整理到空的回「.」。
182 */
183export function normalize(path: string): string {
184 const posix = path.replace(/\\/g, '/')
185 const drive = /^[A-Za-z]:(?=\/|$)/.exec(posix)?.[0] ?? ''
186 const rest = posix.slice(drive.length)
187 const isAbs = drive !== '' || rest.startsWith('/')
188 const parts: string[] = []
189
190 for (const part of rest.split('/')) {
191 if (part === '' || part === '.') {
192 continue
193 }
194
195 if (part === '..') {
196 parts.pop()
197 } else {
198 parts.push(part)
199 }
200 }
201
202 const body = parts.join('/')
203
204 return isAbs ? `${drive}/${body}` : body || '.'
205}
206
207/** 【行為】把一段文字的行數算出來:空字串 0 行,結尾的換行不多算一行。 */
208export function lineCountOf(text: string): number {
209 if (text === '') {
210 return 0
211 }
212
213 return text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
214}
215
216/** 【行為】數一份 structuredPatch([{ lines: ['+…', '-…', ' …'] }])裡加了幾行、刪了幾行;格式不對就當 0。 */
217export function patchCountOf(patch: unknown): { added: number; removed: number } {
218 let added = 0
219 let removed = 0
220
221 if (Array.isArray(patch)) {
222 for (const hunk of patch) {
223 const lines = (hunk as { lines?: unknown } | null)?.lines
224
225 if (Array.isArray(lines)) {
226 for (const line of lines) {
227 if (typeof line === 'string' && line.startsWith('+')) {
228 added += 1
229 } else if (typeof line === 'string' && line.startsWith('-')) {
230 removed += 1
231 }
232 }
233 }
234 }
235 }
236
237 return { added, removed }
238}
239
240/**
241 * 【行為】從 Write、Edit、NotebookEdit 的參數和結果讀出這次改了哪個檔案、是不是新建、加減幾行。
242 * Write 的結果 type 是 create 算新建,加的行數是整個內容的行數;update 照 structuredPatch 數。
243 * Edit 照 structuredPatch 數。NotebookEdit 把新內容的行數算加、舊內容的行數算刪。
244 * 其他工具、結果格式不對、找不到路徑,回 null。
245 */
246export function changeOf(tool: string, args: Readonly<Record<string, unknown>>, result: unknown): Change | null {
247 if (typeof result !== 'object' || result === null) {
248 return null
249 }
250
251 const r = result as Record<string, unknown>
252 const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
253
254 if (tool === 'Write') {
255 const path = text(r.filePath) ?? text(args.file_path)
256
257 if (path === undefined) {
258 return null
259 }
260
261 if (r.type === 'create') {
262 return { path, isNew: true, added: lineCountOf(text(r.content) ?? ''), removed: 0 }
263 }
264
265 return { path, isNew: false, ...patchCountOf(r.structuredPatch) }
266 }
267
268 if (tool === 'Edit') {
269 const path = text(r.filePath) ?? text(args.file_path)
270
271 return path === undefined ? null : { path, isNew: false, ...patchCountOf(r.structuredPatch) }
272 }
273
274 if (tool === 'NotebookEdit') {
275 const path = text(r.notebook_path) ?? text(args.notebook_path)
276
277 return path === undefined
278 ? null
279 : {
280 path,
281 isNew: false,
282 added: lineCountOf(text(r.new_source) ?? ''),
283 removed: lineCountOf(text(r.old_source) ?? ''),
284 }
285 }
286
287 return null
288}
289
290/**
291 * 【行為】從一段指令裡找出看起來像檔案路徑的字,回傳整理過的絕對路徑(不重複,最多 40 個)。
292 * 看起來像路徑:含「/」,或結尾是「.副檔名」(副檔名以英文字母開頭,所以 v2.1.287 這種版本號不算)。網址、萬用字元、「-」開頭的選項、變數不算。
293 * 指令裡有刪除或搬走的動詞(rm、rmdir、mv、del、rd、Remove-Item、Move-Item 這類,不分大小寫)時,
294 * 沒有「/」也沒有副檔名的單字(rm -rf build 的 build)也算,因為被刪的常常是資料夾。
295 * 「~」換成 home;相對路徑同時用 cwd 和指令裡每個「cd 資料夾」(也認 pushd、Set-Location)去接,因為不知道它是在哪一步之後用的。
296 * shell 是 'bash'(預設)時,帶「\\」的字只認磁碟機代號開頭的(C:\\a\\b),其他當作跳脫字元不算;
297 * 是 'powershell' 時「\\」就是路徑分隔(.\\src\\a.ts 也算)。
298 * cwd 是磁碟機代號開頭時(Windows 上的 Git Bash),「/c/Users/…」這種寫法換成「C:/Users/…」。
299 * 回傳時都整理成「/」隔開;Windows 路徑只差大小寫的算同一個,留第一次出現的寫法。
300 * 找不到就回空陣列。這是猜的,不保證抓到全部,也可能多抓不存在的路徑(呼叫端去查存不存在)。
301 */
302export function pathsIn(command: string, cwd: string, home: string, shell: 'bash' | 'powershell' = 'bash'): string[] {
303 const tokens = command
304 .split(/[\s'"`=(),;|&<>{}[\]]+/)
305 .map(token => token.replace(/^[:@]+|[.:]+$/g, ''))
306 // bash 裡有「\\」的通常是跳脫字元,不當路徑;只有 Windows 磁碟機代號開頭(C:\\…)的例外
307 .filter(
308 token =>
309 token !== '' &&
310 !token.startsWith('-') &&
311 !token.includes('://') &&
312 !/[*?$]/.test(token) &&
313 (shell === 'powershell' || !token.includes('\\') || /^[A-Za-z]:\\/.test(token)),
314 )
315 const hasDrive = /^[A-Za-z]:\//.test(cwd)
316 const expand = (token: string) => {
317 if (token === '~' || token.startsWith('~/') || token.startsWith('~\\')) {
318 return `${home}${token.slice(1)}`
319 }
320
321 // Git Bash 的 /c/Users/… 就是 C:/Users/…
322 return hasDrive ? token.replace(/^\/([A-Za-z])(?=\/|$)/, (_, drive: string) => `${drive.toUpperCase()}:`) : token
323 }
324 const bases = [cwd]
325 const words = command.split(/\s+/)
326 const removes = words.some(word => REMOVE_VERBS.has(word.toLowerCase()))
327
328 for (let at = 0; at < words.length - 1; at += 1) {
329 if (CD_VERBS.has((words[at] ?? '').toLowerCase())) {
330 const target = expand((words[at + 1] ?? '').replace(/^['"]|['";&|]+$/g, ''))
331
332 if (target !== '' && !target.startsWith('-')) {
333 bases.push(normalize(isAbsolute(target) ? target : `${cwd}/${target}`))
334 }
335 }
336 }
337
338 const found = new Map<string, string>()
339 const add = (path: string) => {
340 if (!found.has(keyOf(path))) {
341 found.set(keyOf(path), path)
342 }
343 }
344
345 for (const raw of tokens) {
346 const token = expand(raw)
347 const isPathy =
348 token.includes('/') ||
349 token.includes('\\') ||
350 /\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(token) ||
351 (removes && !REMOVE_VERBS.has(token.toLowerCase()))
352
353 if (!isPathy || /^\d+(\.\d+)*$/.test(token)) {
354 continue
355 }
356
357 if (isAbsolute(token)) {
358 add(normalize(token))
359 } else {
360 for (const base of bases) {
361 add(normalize(`${base}/${token}`))
362 }
363 }
364
365 if (found.size >= MAX_CANDIDATES) {
366 break
367 }
368 }
369
370 return [...found.values()].slice(0, MAX_CANDIDATES)
371}
372
373/**
374 * 【行為】比對指令跑之前和之後查到的狀態(見 Seen),回傳有變動的。不知道行數,所以 added、removed 都是 0。
375 * 檔案:之前沒有、之後有的算新建;兩邊都有但修改時間不同的算修改;之前有、之後沒有的算刪掉(isGone)。
376 * 資料夾:只回報「之前是資料夾、之後不見了」,算刪掉並標 isDir;新建資料夾、資料夾裡面的變動都不回報。
377 * after 裡沒有的路徑當作不存在。
378 */
379export function commandChangesOf(before: ReadonlyMap<string, Seen>, after: ReadonlyMap<string, Seen>): Change[] {
380 const changes: Change[] = []
381
382 for (const [path, then] of before) {
383 const now = after.get(path) ?? null
384
385 if (then === 'dir' || now === 'dir') {
386 if (then === 'dir' && now === null) {
387 changes.push({ path, isNew: false, added: 0, removed: 0, isGone: true, isDir: true })
388 }
389
390 continue
391 }
392
393 if (now === null) {
394 if (then !== null) {
395 changes.push({ path, isNew: false, added: 0, removed: 0, isGone: true })
396 }
397
398 continue
399 }
400
401 if (then === null || then !== now) {
402 changes.push({ path, isNew: then === null, added: 0, removed: 0 })
403 }
404 }
405
406 return changes
407}
408
409/** 【行為】路徑開頭是 home 的換成「~」;Windows 的「\\」先換成「/」再比對。home 要是 normalize 過的。 */
410export function tildeOf(path: string, home: string): string {
411 const posix = path.replace(/\\/g, '/')
412
413 return home !== '' && (posix === home || posix.startsWith(`${home}/`)) ? `~${posix.slice(home.length)}` : path
414}
415
416/** 寬度不一定的字,左邊補空白補到 width 格寬。 */
417function padStartWidth(text: string, width: number): string {
418 return ' '.repeat(Math.max(0, width - widthOf(text))) + text
419}
420
421/** 左邊幾段字由左到右排、超過就裁;有 right 就靠右放,中間補空白,整行剛好 columns 格寬。 */
422function line(left: Segment[], right: Segment | undefined, columns: number): Line {
423 const room = right === undefined ? columns : Math.max(0, columns - widthOf(right.text) - 1)
424 const kept: Segment[] = []
425 let used = 0
426
427 for (const segment of left) {
428 if (room - used <= 0) {
429 break
430 }
431
432 const text = fit(segment.text, room - used, segment.keepTail === true)
433
434 if (text !== '') {
435 kept.push({ ...segment, text })
436 used += widthOf(text)
437 }
438 }
439
440 if (right === undefined) {
441 return kept
442 }
443
444 return [...kept, { text: ' '.repeat(Math.max(1, columns - used - widthOf(right.text))) }, right]
445}
446
447function numbersOf(row: FileRow): string {
448 if (!row.hasCounts) {
449 return '指令改的'
450 }
451
452 if (row.added === 0 && row.removed === 0) {
453 return '±0'
454 }
455
456 return [row.added > 0 ? `+${row.added}` : '', row.removed > 0 ? `−${row.removed}` : ''].filter(Boolean).join(' ')
457}
458
459function relativeOf(path: string, root: string): string {
460 return keyOf(path).startsWith(`${keyOf(root)}/`) ? path.slice(root.length + 1) : path
461}
462
463/**
464 * 【何時能呼叫】columns 至少 30 才排得好看;更窄也不會壞,只是字會被裁掉。
465 * 【行為】把檔案清單排成側邊欄的每一行,每一行都不超過 columns 格寬。
466 * 第一行是標題,右邊寫新建、修改、刪掉各幾個(刪掉的不算進前兩個)。沒有檔案時只多一行「還沒有改過檔案」。
467 * 依 root 分組:root 是 cwd 的那一組排第一、標成「這個資料夾(路徑)」,其他組照最近改動的時間排,標成資料夾路徑;
468 * 組內也是最近改的在前。每個檔案一行:「新」「改」「刪」、相對於 root 的路徑(太長留結尾;資料夾結尾加「/」;
469 * Windows 路徑比對 root 時不分大小寫)、
470 * 加減幾行(只被指令改過的寫「指令改的」)、改過幾次(幫手改的改寫幫手種類)。刪掉的整行變暗。
471 * 最後一行寫最後一次修改的是哪個檔案、多久以前(now 減 lastAt)。home 用來把路徑寫成「~」開頭。
472 */
473export function linesOf(rows: readonly FileRow[], cwd: string, home: string, now: number, columns: number): Line[] {
474 const created = rows.filter(row => !row.isGone && row.kind === 'new').length
475 const changed = rows.filter(row => !row.isGone && row.kind === 'changed').length
476 const gone = rows.filter(row => row.isGone).length
477 const lines: Line[] = [
478 line(
479 [{ text: '檔案', bold: true }],
480 { text: `新建 ${created}・修改 ${changed}・刪掉 ${gone}`, dim: true },
481 columns,
482 ),
483 ]
484
485 if (rows.length === 0) {
486 lines.push([], [{ text: fit('這次對話還沒有改過檔案', columns), dim: true }])
487
488 return lines
489 }
490
491 const groups = new Map<string, FileRow[]>()
492
493 for (const row of [...rows].sort((a, b) => b.lastAt - a.lastAt)) {
494 groups.set(row.root, [...(groups.get(row.root) ?? []), row])
495 }
496
497 const order = [...groups.keys()].sort((a, b) => Number(b === cwd) - Number(a === cwd))
498
499 for (const root of order) {
500 const title = root === cwd ? `這個資料夾(${tildeOf(root, home)})` : `${tildeOf(root, home)}/`
501
502 lines.push([], [{ text: fit(title, columns, root !== cwd), bold: true }])
503
504 for (const row of groups.get(root) ?? []) {
505 const mark: Segment = row.isGone
506 ? { text: '刪', color: GONE, dim: true }
507 : row.kind === 'new'
508 ? { text: '新', color: NEW }
509 : { text: '改', color: CHANGED }
510 const tail = row.who ?? `${row.count} 次`
511 const right: Segment = {
512 text: `${padStartWidth(numbersOf(row), 9)} ${padStartWidth(tail, 5)}`,
513 ...(row.isGone ? { dim: true } : {}),
514 ...(row.who === undefined || row.isGone ? {} : { color: HELPER }),
515 }
516
517 lines.push(
518 line(
519 [
520 { text: ' ' },
521 mark,
522 { text: ' ' },
523 {
524 text: `${relativeOf(row.path, root)}${row.isDir === true ? '/' : ''}`,
525 keepTail: true,
526 ...(row.isGone ? { dim: true } : {}),
527 },
528 ],
529 right,
530 columns,
531 ),
532 )
533 }
534 }
535
536 const last = rows.reduce((a, b) => (b.lastAt > a.lastAt ? b : a))
537
538 lines.push(
539 [],
540 line(
541 [{ text: '最後一次修改:', dim: true }, { text: relativeOf(last.path, last.root), keepTail: true, dim: true }],
542 { text: agoOf(now - last.lastAt), dim: true },
543 columns,
544 ),
545 )
546
547 return lines
548}
549
550// #region AI-NOTES
551// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
552// 2026-10-03 為了在原生 Windows 的 Claude Code 上跑,normalize、isAbsolute、isRoot、tildeOf、pathsIn 都認得「\\」和磁碟機代號;
553// 內部一律存成「/」隔開、C:/ 開頭的寫法。沒有 Windows 機器可實測,只有單元測試;主人在 Windows 上看到路徑怪怪的先查這裡。
554// 2026-10-03 Write、Edit 的結果格式看 .claude-plugin/types/claude-code-tools/index.d.ts(Write 有 type create|update、
555// 兩者都有 structuredPatch,lines 以「+」「-」「 」開頭);NotebookEdit 沒有 patch,只能用新舊內容的行數粗算。
556// 2026-10-03 pathsIn 是猜的:同一個相對路徑會用 cwd 和每個 cd 目標各接一次,多抓的不存在路徑由呼叫端 stat 過濾。
557// 主人的 Python heredoc 寫法(p='register.ts'、expanduser('~/.claude/settings.json'))都切得出來,是刻意照這個測的。
558// 2026-10-03 有刪除動詞時連 ls、git 這種單字也會被當路徑去 stat(rm -rf build 的 build 沒副檔名,分不出來),
559// 不存在就是兩邊都 null、不回報,只多花幾次 stat;上限還是 40 個。
560// 2026-10-03 widthOf、fit、agoOf 從 ~/Workspace/projects/ctx-panel 複製(另一份來源 crew 已在 2026-10-03 刪掉);mod 之間不能互相 import。
561// #endregion
562