右側面板列出這次對話修改與新增的檔案,並可查看今天所有對話的紀錄(不含小遊戲)

Claude Code 的兩個 mod:
/zh,Claude 的回覆和選擇題都會變成繁體中文(只翻譯最新一則回覆)。在終端機的 Claude Code 對話框輸入:
/plugin install all --marketplace justin20745413/claude-mods
第一次會問 Add marketplace?,按 y;接著選安裝範圍,直接按 Enter(user)即可。file-log 和 zh-translate 會一起裝好。
在一般終端機(shell)裡:
claude plugin install all --marketplace justin20745413/claude-mods
/plugin install no-game --marketplace justin20745413/claude-mods
會裝 file-log-nogame 和 zh-translate。file-log-nogame 跟 file-log 功能一樣,但不含貪食蛇和小恐龍,面板上也沒有 game 分頁。(file-log 和 file-log-nogame 選一個裝就好。)
/plugin install file-log --marketplace justin20745413/claude-mods
/plugin install file-log-nogame --marketplace justin20745413/claude-mods
/plugin install zh-translate --marketplace justin20745413/claude-mods
在桌面版 App(/plugin 不能用時):點對話框旁的 + → Plugins → Add marketplace,輸入 justin20745413/claude-mods,再按 Add plugin,選 all 或單一個 mod。
裝過的人要拿到新版本:
claude plugin marketplace update my-mods
claude plugin update file-log@my-mods
claude plugin update zh-translate@my-mods
(裝的是無遊戲版的話,把 file-log@my-mods 換成 file-log-nogame@my-mods。)
file-log-nogame 是由 file-log 自動產生的,不要直接改它。改完 file-log 後執行:
./scripts/make-nogame.shhooks/register.tsx 478 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { GAMES } from './features'
5import type { DayEntry, FileEntry, FileKind, FileLogTab, GameCmd, GameView } from '../types'
6
7const PANE = 'file-log-nogame'
8const TITLE = '檔案紀錄'
9
10const files = atom({ plugin: 'file-log-nogame', key: 'files' } as const, [] as FileEntry[])
11const tab = atom({ plugin: 'file-log-nogame', key: 'tab' } as const, 'session' as FileLogTab)
12const today = atom({ plugin: 'file-log-nogame', key: 'today' } as const, [] as DayEntry[])
13// The last key typed into the game controller, numbered so the board applies each once.
14const gameCmd = atom({ plugin: 'file-log-nogame', key: 'gameCmd' } as const, { seq: 0, key: '' } as GameCmd)
15// What the controller field shows: '' and ' ' in turn, so each redraw empties what was typed.
16const pad = atom({ plugin: 'file-log-nogame', key: 'pad' } as const, '')
17// What the game tab shows: its menu, or one game.
18const game = atom({ plugin: 'file-log-nogame', key: 'game' } as const, 'menu' as GameView)
19
20// The dino's field is never shorter than the dinosaur at the top of its jump (MIN_SKY in dino.tsx).
21const MIN_SKY = 14
22const bestKey = (game: string) => game + ':best'
23const GAME_NAMES = ['snake', 'dino'] as const
24// A high score is kept for 7 days from when it was set, then cleared.
25const BEST_KEEP_MS = 7 * 24 * 60 * 60 * 1000
26
27// The game's high score, or 0 once it is more than 7 days old (the old record is deleted then).
28async function readBest($: EngineInterface, gameName: string): Promise<number> {
29 const key = bestKey(gameName)
30 const stored = await $.store.get(key)
31 const now = await $.clock.now()
32 // A plain number is a record from before the 7-day rule: its 7 days start now.
33 if (typeof stored === 'number') {
34 await $.store.set(key, { score: stored, at: now })
35 return stored
36 }
37 const best = stored as { score?: unknown; at?: unknown } | undefined
38 if (!best || typeof best.score !== 'number' || typeof best.at !== 'number') return 0
39 if (now - best.at > BEST_KEEP_MS) {
40 await $.store.delete(key)
41 return 0
42 }
43 return best.score
44}
45
46async function saveBest($: EngineInterface, gameName: string, score: number): Promise<void> {
47 if (score > (await readBest($, gameName))) await $.store.set(bestKey(gameName), { score, at: await $.clock.now() })
48}
49
50async function switchTab($: EngineInterface, value: FileLogTab): Promise<void> {
51 if (value === 'today' || value === 'all') {
52 const day = await loadToday($)
53 await update($, today, () => day)
54 }
55 await update($, tab, () => value)
56 // The game tab opens on its menu and takes the keyboard at once: no click needed
57 // (clicks reach the app only in fullscreen).
58 if (value === 'game') {
59 await update($, game, () => 'menu' as const)
60 await $.ui.open({ id: PANE, title: TITLE, focus: true })
61 }
62}
63
64async function chooseGame($: EngineInterface, view: GameView): Promise<void> {
65 await update($, game, () => view)
66 // The menu's buttons are gone: hand the keyboard to the controller, or back to the menu.
67 try {
68 await $.ui.focus({ requestId: PANE, key: view === 'menu' ? 'menu-snake' : 'game-pad' })
69 } catch { }
70}
71
72// Every key typed into the controller goes to the game on screen.
73async function onPad($: EngineInterface, value: string): Promise<void> {
74 const shown = await read($, pad)
75 const typed = value.startsWith(shown) ? value.slice(shown.length) : value.slice(-1)
76 await update($, pad, p => (p === '' ? ' ' : ''))
77 for (const ch of typed) await update($, gameCmd, c => ({ seq: c.seq + 1, key: ch.toLowerCase() }))
78}
79
80// Both separators count: Windows paths use '\'.
81function lastSep(path: string): number {
82 return Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
83}
84
85function dirname(path: string): string {
86 const i = lastSep(path)
87 return i <= 0 ? '/' : path.slice(0, i)
88}
89
90function basename(path: string): string {
91 return path.slice(lastSep(path) + 1)
92}
93
94// A drive path (C:\…, c:/…) or a network path (\\server\share\…).
95function isWindowsPath(path: string): boolean {
96 return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
97}
98
99// The same Windows file can arrive as 'c:\…', 'C:\…' or 'C:/…': a Windows path is
100// recorded with '\' separators and an upper-case drive letter. Other paths are kept as is.
101function normPath(path: string): string {
102 if (!isWindowsPath(path)) return path
103 const p = path.replace(/\//g, '\\')
104 return /^[a-z]:/.test(p) ? p[0].toUpperCase() + p.slice(1) : p
105}
106
107// What two paths are compared by. Windows ignores case in file names, so a Windows
108// path compares lower-cased. macOS and Linux paths compare as they are: a volume
109// there can be case-sensitive, and the system already reports a file's own case.
110function pathKey(path: string): string {
111 return isWindowsPath(path) ? normPath(path).toLowerCase() : path
112}
113
114function samePath(a: string, b: string): boolean {
115 return pathKey(a) === pathKey(b)
116}
117
118function isUnder(path: string, dir: string): boolean {
119 const p = pathKey(path)
120 // 'C:\' and '/' end in a separator already.
121 const d = pathKey(dir).replace(/[\\/]+$/, '')
122 return p.startsWith(d + '/') || p.startsWith(d + '\\')
123}
124
125async function isWindows($: EngineInterface): Promise<boolean> {
126 return (await $.env.get('OS')) === 'Windows_NT'
127}
128
129// Files Windows would run rather than show when handed to their default app.
130const RUNNABLE = /\.(bat|cmd|exe|com|msi|ps1|vbs|vbe|js|jse|wsf|wsh|scr|lnk|reg)$/i
131
132// Opens a file in the editor this terminal runs inside (a VS Code-family app), else the default app.
133async function openFile($: EngineInterface, path: string): Promise<void> {
134 try {
135 if (!(await $.fs.exists(path))) {
136 $.ui.toast(`檔案已不存在:${path}`)
137 return
138 }
139 const termProgram = await $.env.get('TERM_PROGRAM')
140 if (await isWindows($)) {
141 // `code` is a .cmd on Windows, so it goes through cmd.
142 if (termProgram === 'vscode') {
143 const run = await $.process.run(['cmd', '/c', 'code', '-r', path], { timeoutMs: 5000 })
144 if (run.exitCode === 0) return
145 }
146 // A runnable file is shown selected in Explorer instead of being run.
147 const argv = RUNNABLE.test(path)
148 ? ['explorer', '/select,' + path]
149 : ['rundll32', 'url.dll,FileProtocolHandler', path]
150 await $.process.run(argv, { timeoutMs: 5000 })
151 return
152 }
153 const bundle = await $.env.get('__CFBundleIdentifier')
154 const argv = termProgram === 'vscode' && bundle ? ['open', '-b', bundle, path] : ['open', path]
155 const run = await $.process.run(argv, { timeoutMs: 5000 })
156 if (run.exitCode !== 0) $.ui.toast(`打不開檔案:${run.stderr.trim() || path}`)
157 } catch {
158 $.ui.toast(`打不開檔案:${path}`)
159 }
160}
161
162// Shows a folder in Finder, or Explorer on Windows.
163async function openFolder($: EngineInterface, dir: string): Promise<void> {
164 try {
165 if (!(await $.fs.exists(dir))) {
166 $.ui.toast(`資料夾已不存在:${dir}`)
167 return
168 }
169 // explorer.exe exits 1 even when it opened the folder, so its exit code says nothing.
170 if (await isWindows($)) {
171 await $.process.run(['explorer', dir], { timeoutMs: 5000 })
172 return
173 }
174 const run = await $.process.run(['open', dir], { timeoutMs: 5000 })
175 if (run.exitCode !== 0) $.ui.toast(`打不開資料夾:${run.stderr.trim() || dir}`)
176 } catch {
177 $.ui.toast(`打不開資料夾:${dir}`)
178 }
179}
180
181// Local time offset in minutes, read once from the system clock.
182let offsetMinutes: number | undefined
183
184async function localOffset($: EngineInterface): Promise<number> {
185 if (offsetMinutes !== undefined) return offsetMinutes
186 try {
187 const run = await $.process.run(['date', '+%z'], { timeoutMs: 3000 })
188 const m = /^([+-])(\d\d)(\d\d)/.exec(run.stdout.trim())
189 offsetMinutes = m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0
190 } catch {
191 offsetMinutes = 0
192 }
193 return offsetMinutes
194}
195
196function shifted(ms: number, offset: number): Date {
197 return new Date(ms + offset * 60_000)
198}
199
200function dayKey(ms: number, offset: number): string {
201 return 'day:' + shifted(ms, offset).toISOString().slice(0, 10)
202}
203
204function clockText(ms: number, offset: number): string {
205 return shifted(ms, offset).toISOString().slice(11, 16)
206}
207
208// The part of a path below dir, once isUnder(path, dir) holds.
209function below(path: string, dir: string): string {
210 return path.slice(dir.replace(/[\\/]+$/, '').length + 1)
211}
212
213function shortPath(path: string, cwd: string, home: string): string {
214 if (cwd && isUnder(path, cwd)) return below(path, cwd)
215 if (home && isUnder(path, home)) return '~/' + below(path, home)
216 return path
217}
218
219// Keep only today's records: every other day's key is deleted.
220// Runs when a session starts and on each record, so it never depends on a clean shutdown.
221async function pruneOldDays($: EngineInterface, keep: string): Promise<void> {
222 for (const key of await $.store.keys()) {
223 if (key.startsWith('day:') && key !== keep) await $.store.delete(key)
224 }
225}
226
227async function loadToday($: EngineInterface): Promise<DayEntry[]> {
228 const offset = await localOffset($)
229 const now = await $.clock.now()
230 const value = await $.store.get(dayKey(now, offset))
231 return Array.isArray(value) ? (value as DayEntry[]) : []
232}
233
234async function record($: EngineInterface, rawPath: string, kind: FileKind): Promise<void> {
235 const path = normPath(rawPath)
236 const at = await $.clock.now()
237
238 await update($, files, list => {
239 const found = list.find(f => samePath(f.path, path))
240 if (!found) return [...list, { path, kind, count: 1, at }]
241 return list.map(f => (samePath(f.path, path) ? { ...f, count: f.count + 1, at } : f))
242 })
243
244 const offset = await localOffset($)
245 const key = dayKey(at, offset)
246 const session = await $.session.id()
247 const cwd = normPath(await $.session.cwd())
248 const stored = await $.store.get(key)
249 const day: DayEntry[] = Array.isArray(stored) ? (stored as DayEntry[]) : []
250 const index = day.findIndex(d => d.session === session && samePath(d.path, path))
251 const old = index === -1 ? undefined : day[index]
252 if (old) {
253 day[index] = { ...old, at }
254 } else {
255 day.push({ path, kind, session, cwd, at })
256 }
257 await $.store.set(key, day)
258 await pruneOldDays($, key)
259 await update($, today, () => day)
260}
261
262async function home($: EngineInterface): Promise<string> {
263 return (await $.env.get('HOME')) ?? ''
264}
265
266function todayText(day: DayEntry[], offset: number, homeDir: string): string {
267 if (day.length === 0) return '今天還沒有任何對話修改或新增檔案。'
268 const bySession = new Map<string, DayEntry[]>()
269 for (const d of [...day].sort((a, b) => a.at - b.at)) {
270 bySession.set(d.session, [...(bySession.get(d.session) ?? []), d])
271 }
272 const created = day.filter(d => d.kind === 'created').length
273 const lines = [`**今天共 ${day.length} 個檔案**(新增 ${created}、修改 ${day.length - created}),${bySession.size} 個對話`, '']
274 for (const [session, list] of bySession) {
275 const cwd = list[0]?.cwd ?? ''
276 lines.push(`### 對話 ${session.slice(0, 8)} · ${shortPath(cwd, '', homeDir) || cwd}`)
277 for (const d of list) {
278 const mark = d.kind === 'created' ? '+ 新增' : '✎ 修改'
279 lines.push(`- ${clockText(d.at, offset)} ${mark} \`${shortPath(d.path, d.cwd, homeDir)}\``)
280 }
281 lines.push('')
282 }
283 return lines.join('\n')
284}
285
286export const register: Register = on => {
287 on('session.start', async ($, e, next) => {
288 await $.command.register({ name: 'files', description: '打開右側的檔案紀錄面板' })
289 await $.command.register({ name: 'files-today', description: '列出今天所有對話修改與新增的檔案' })
290 await update($, today, () => [])
291 try {
292 await pruneOldDays($, dayKey(await $.clock.now(), await localOffset($)))
293 if (GAMES) for (const name of GAME_NAMES) await readBest($, name)
294 } catch {}
295 const day = await loadToday($)
296 await update($, today, () => day)
297 void $.ui.open({ id: PANE, title: TITLE })
298 return next(e)
299 })
300
301 on('command.run', { command: 'files' }, async $ => {
302 const opened = await $.ui.open({ id: PANE, title: TITLE })
303 return { text: opened.isPlaced ? '已打開檔案紀錄面板。' : `面板暫時無法顯示:${opened.reason}` }
304 })
305
306 on('command.run', { command: 'files-today' }, async $ => {
307 const day = await loadToday($)
308 await update($, today, () => day)
309 return { text: todayText(day, await localOffset($), await home($)) }
310 })
311
312 // Write: new when the file did not exist before the call.
313 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
314 let existed = true
315 try {
316 existed = await $.fs.exists(e.file_path)
317 } catch { }
318 const ran = await next(e)
319 if (ran.deny === undefined && ran.isError !== true) {
320 await record($, e.file_path, existed ? 'modified' : 'created')
321 }
322 return ran
323 }).catch(($, e, next) => next(e))
324
325 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
326 const ran = await next(e)
327 if (ran.deny === undefined && ran.isError !== true) await record($, e.file_path, 'modified')
328 return ran
329 }).catch(($, e, next) => next(e))
330
331 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
332 const ran = await next(e)
333 if (ran.deny === undefined && ran.isError !== true) await record($, e.notebook_path, 'modified')
334 return ran
335 }).catch(($, e, next) => next(e))
336
337 // A game board reports a finished game's score; the best one per game is kept.
338 on('ui.message', async ($, e, next) => {
339 if (!GAMES || (e.element !== 'snake' && e.element !== 'dino')) return next(e)
340 const data = e.data as { score?: unknown } | null
341 const score = typeof data?.score === 'number' ? data.score : 0
342 await saveBest($, e.element, score)
343 return {}
344 })
345
346 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
347 const elements = $.ui.resolve(e)
348 const { Box, Text, Button } = elements
349 const current = await read($, tab)
350 const cwd = await $.session.cwd()
351 const homeDir = await home($)
352 const offset = await localOffset($)
353 const width = Math.max(10, (e.props.bodyColumns ?? 40) - 2)
354 const fit = (text: string, room = width) => (text.length > room ? '…' + text.slice(text.length - room + 1) : text)
355
356 const tabButton = (key: string, hotkey: string, value: FileLogTab, label: string) => (
357 <Button
358 key={key}
359 hotkey={hotkey}
360 variant={current === value ? 'primary' : 'secondary'}
361 label={label}
362 onPress={() => switchTab($, value)}
363 />
364 )
365 // The 中文 switch belongs to the zh-translate mod; shown only when that mod is loaded.
366 const zh = (await $.state.get({ plugin: 'zh-translate', key: 'isOn' } as never)) as { value?: unknown; version: number }
367 const zhButton =
368 zh.version > 0 ? (
369 <Button
370 key="zh-toggle"
371 variant={zh.value === true ? 'primary' : 'secondary'}
372 label={zh.value === true ? '中文 開' : '中文 關'}
373 onPress={() => $.command.run({ command: 'zh' })}
374 />
375 ) : null
376 const tabs = (
377 <Box flexDirection="row" gap={1}>
378 {zhButton}
379 {tabButton('tab-session', '1', 'session', 'talk')}
380 {tabButton('tab-today', '2', 'today', 'project')}
381 {tabButton('tab-all', '3', 'all', 'all')}
382 {GAMES && tabButton('tab-game', '4', 'game', 'game')}
383 </Box>
384 )
385
386
387 // Files grouped under their folder: the folder tag opens the folder, a file row opens the file.
388 type Row = { path: string; kind: FileKind; at: number; count: number }
389 const fileList = (rows: Row[], base: string, withTime: boolean) => {
390 // Keyed by pathKey so one Windows folder spelled in two cases is one group.
391 const groups = new Map<string, { dir: string; list: Row[] }>()
392 for (const r of rows) {
393 const dir = dirname(r.path)
394 const group = groups.get(pathKey(dir))
395 if (group) group.list.push(r)
396 else groups.set(pathKey(dir), { dir, list: [r] })
397 }
398 const out = []
399 for (const { dir, list } of groups.values()) {
400 const label = base && samePath(dir, base) ? basename(base) : shortPath(dir, base, homeDir)
401 out.push(
402 <Button key={'dir:' + dir} plain onPress={() => openFolder($, dir)}>
403 <Text color="cyan">📁 {fit(label + '/', width - 3)}</Text>
404 </Button>,
405 )
406 for (const r of list) {
407 const time = withTime ? clockText(r.at, offset) + ' ' : ''
408 out.push(
409 <Button key={'file:' + r.path} plain onPress={() => openFile($, r.path)}>
410 {' '}
411 <Text color={r.kind === 'created' ? 'green' : 'yellow'}>{r.kind === 'created' ? '+' : '✎'}</Text>
412 <Text dimColor> {time}</Text>
413 {fit(basename(r.path), width - 6 - time.length)}
414 {r.count > 1 && <Text dimColor> ×{r.count}</Text>}
415 </Button>,
416 )
417 }
418 }
419 return out
420 }
421
422 if (current === 'today' || current === 'all') {
423 const everyProject = current === 'all'
424 const latest = new Map<string, DayEntry>()
425 for (const d of await read($, today)) {
426 // "project" keeps files that live inside this project's folder, whichever session changed them.
427 // Records from before normPath may still carry another spelling of the same path.
428 const path = normPath(d.path)
429 if (!everyProject && !samePath(path, cwd) && !isUnder(path, cwd)) continue
430 const seen = latest.get(pathKey(path))
431 if (!seen || seen.at < d.at) latest.set(pathKey(path), { ...d, path })
432 }
433 const day = [...latest.values()].sort((a, b) => b.at - a.at)
434 const all = await read($, today)
435 const sessions = new Set(all.filter(d => latest.has(pathKey(d.path))).map(d => d.session)).size
436 const projects = new Set(day.map(d => pathKey(d.cwd))).size
437 return (
438 <Box flexDirection="column">
439 {tabs}
440 <Text dimColor>
441 {everyProject
442 ? `今天 ${day.length} 個檔案 · ${projects} 個專案 · ${sessions} 個對話`
443 : `這個專案今天 ${day.length} 個檔案 · ${sessions} 個對話`}
444 </Text>
445 {day.length === 0 && <Text dimColor>今天還沒有紀錄。</Text>}
446 {fileList(day.map(d => ({ ...d, count: 1 })), everyProject ? '' : cwd, true)}
447 </Box>
448 )
449 }
450
451 const list = await read($, files)
452 const created = list.filter(f => f.kind === 'created').length
453 return (
454 <Box flexDirection="column">
455 {tabs}
456 {list.length === 0 ? (
457 <Text dimColor>這次對話還沒有修改檔案。</Text>
458 ) : (
459 <Box flexDirection="row" gap={1}>
460 <Text dimColor>
461 <Text color="green">+ 新增 {created}</Text> · <Text color="yellow">✎ 修改 {list.length - created}</Text>
462 </Text>
463 {/* Empties this tab only: today's records under project / all stay. */}
464 <Button
465 key="clear-talk"
466 hotkey="c"
467 variant="secondary"
468 label="清除"
469 onPress={() => update($, files, () => [])}
470 />
471 </Box>
472 )}
473 {fileList(list, cwd, false)}
474 </Box>
475 )
476 })
477}
478hooks/features.ts 3 lines1// Made by scripts/make-nogame.sh from file-log: no game tab, no game files.
2export const GAMES = false
3types/index.d.ts 22 lines1export type FileKind = 'created' | 'modified'
2
3/** One file touched in this session. */
4export type FileEntry = { path: string; kind: FileKind; count: number; at: number }
5
6/** One file touched today, in any session. */
7export type DayEntry = { path: string; kind: FileKind; session: string; cwd: string; at: number }
8
9export type FileLogTab = 'session' | 'today' | 'all' | 'game'
10
11/** What the game tab shows. */
12export type GameView = 'menu' | 'snake' | 'dino'
13
14/** A key typed into the game controller, numbered so it is applied once. */
15export type GameCmd = { seq: number; key: string }
16
17declare module 'claude-code' {
18 interface PluginState {
19 'file-log-nogame': { files: FileEntry[]; tab: FileLogTab; today: DayEntry[]; gameCmd: GameCmd; pad: string; game: GameView }
20 }
21}
22