SLOPSHOPPER

files

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

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-05jessetsai1024/claude-files
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · files
│ ┃ files ✕ › fix the failing auth test and add an audit log call │ ┃ 檔案 新建 0・修… │ ┃ ⏺ 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 │ │ › /files │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · files
檔案 新建 0・修改 0・刪掉 0 這次對話還沒有改過檔案
README

files:這次對話動了哪些檔案

一個 Claude Code 的 mod。側邊欄列出這次對話新建、修改、刪掉了哪些檔案,各改了幾行。/files 開或關、/files clear 清空。

English summary at the end.

側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。

需要什麼

  • Claude Code 2.1.287 以上(mod 功能 2026-10-01 起預設開放)。
  • 不用 Node、不用裝套件。mod 跑在 Claude Code 自己的引擎裡。

安裝

用 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,只拿掉捷徑。

注意

  • 不要同時用兩種方式載入同一個 mod(marketplace 裝了就不要再接捷徑;settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。
  • Windows 還沒實機跑過。路徑處理有單元測試,但作者手邊沒有 Windows 機器。有問題請開 issue,附 Claude Code 版本和畫面。
  • 想改:直接改檔案,存檔後 Claude Code 會熱重載。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 授權。


English

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.

Source 2 files
hooks/register.ts 325 lines
1import 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
325
hooks/view.ts 562 lines
1/** 一行裡的一小段字,帶自己的顏色與粗細。 */
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