SLOPSHOPPER

file-log

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

paneguardcommandtoastprocess
v0.9.8no licenseupdated 2026-10-19justin20745413/claude-mods/file-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · file-log
│ ┃ 檔案紀錄 ✕ › fix the failing auth test and add an audit log call │ ┃ [ talk ] [ project ] [ all ] [ game ] │ ┃ + 新增 2 · ✎ 修改 1 [ 清除 ] ⏺ Read(src/auth.ts) │ ┃ dir:/work/app/src ⎿ 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 │ ⎿ file-log: 已打開檔案紀錄面板。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 檔案紀錄
[ talk ] [ project ] [ all ] [ game ] + 新增 2 · ✎ 修改 1 [ 清除 ] dir:/work/app/src
README

claude-mods

Claude Code 的兩個 mod:

  • file-log:右側面板列出這次對話修改與新增的檔案,也能查看今天所有對話的紀錄(只保留當天)。內含貪食蛇和小恐龍小遊戲。
  • zh-translate:按一下「中文」按鈕或輸入 /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.sh
Source 5 files
hooks/register.tsx 546 lines
1import { 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'
8const TITLE = '檔案紀錄'
9
10const files = atom({ plugin: 'file-log', key: 'files' } as const, [] as FileEntry[])
11const tab = atom({ plugin: 'file-log', key: 'tab' } as const, 'session' as FileLogTab)
12const today = atom({ plugin: 'file-log', 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', 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', key: 'pad' } as const, '')
17// What the game tab shows: its menu, or one game.
18const game = atom({ plugin: 'file-log', 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    // GAMES:BEGIN (scripts/make-nogame.sh leaves out everything from here to GAMES:END)
387    if (GAMES && current === 'game') {
388      const view = await read($, game)
389      if (view === 'menu') {
390        // Each item's number is its key: on the menu, 1 and 2 pick a game (they win over the tabs' 1 and 2,
391        // being drawn later). Clicks don't reach the pane in every terminal; the keys always do.
392        const item = (key: GameView, hotkey: string, name: string, first: boolean) => (
393          <Button
394            key={'menu-' + key}
395            hotkey={hotkey}
396            plain
397            autoFocus={first ? true : undefined}
398            label={name}
399            onPress={() => chooseGame($, key)}
400          />
401        )
402        return (
403          <Box flexDirection="column">
404            {tabs}
405            {item('snake', '1', '貪吃蛇', true)}
406            {item('dino', '2', '小恐龍', false)}
407          </Box>
408        )
409      }
410      if (!('Client' in elements) || !('Input' in elements)) {
411        return (
412          <Box flexDirection="column">
413            {tabs}
414            <Text dimColor>這個介面不能玩遊戲,請在終端機或桌面版打開。</Text>
415          </Box>
416        )
417      }
418      const { Client, Input } = elements
419      const isSnake = view === 'snake'
420      // The board grows with the pane: everything but the tabs, score, frame, status, controller and hint.
421      const roomCols = (e.props.bodyColumns ?? 40) - 4
422      const roomRows = (e.props.scroll?.bodyRows ?? 20) - 8
423      const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.floor(n)))
424      // Both games draw each cell two columns wide.
425      const cols = isSnake ? clamp(roomCols / 2, 8, 60) : clamp(roomCols / 2, 20, 100)
426      const rows = isSnake ? clamp(roomRows, 8, 40) : clamp(roomRows, MIN_SKY, 30)
427      const best = await readBest($, view)
428      const cmd = await read($, gameCmd)
429      return (
430        <Box flexDirection="column">
431          {tabs}
432          {isSnake ? (
433            <Client key="snake" module="./snake.tsx" props={{ cols, rows, best, cmd }} />
434          ) : (
435            <Client key="dino" module="./dino.tsx" props={{ cols, rows, best, cmd }} />
436          )}
437          <Input
438            key="game-pad"
439            label="🎮"
440            autoFocus
441            value={await read($, pad)}
442            placeholder="遊戲按鍵在這裡"
443            submitLabel="空白鍵"
444            onInput={value => onPad($, value)}
445            onSubmit={() => update($, gameCmd, c => ({ seq: c.seq + 1, key: ' ' }))}
446          />
447          <Text dimColor>
448            {isSnake ? 'W A S D 移動 · 空白鍵暫停' : '空白鍵或 W 跳'}
449          </Text>
450        </Box>
451      )
452    }
453    // GAMES:END
454
455    // Files grouped under their folder: the folder tag opens the folder, a file row opens the file.
456    type Row = { path: string; kind: FileKind; at: number; count: number }
457    const fileList = (rows: Row[], base: string, withTime: boolean) => {
458      // Keyed by pathKey so one Windows folder spelled in two cases is one group.
459      const groups = new Map<string, { dir: string; list: Row[] }>()
460      for (const r of rows) {
461        const dir = dirname(r.path)
462        const group = groups.get(pathKey(dir))
463        if (group) group.list.push(r)
464        else groups.set(pathKey(dir), { dir, list: [r] })
465      }
466      const out = []
467      for (const { dir, list } of groups.values()) {
468        const label = base && samePath(dir, base) ? basename(base) : shortPath(dir, base, homeDir)
469        out.push(
470          <Button key={'dir:' + dir} plain onPress={() => openFolder($, dir)}>
471            <Text color="cyan">📁 {fit(label + '/', width - 3)}</Text>
472          </Button>,
473        )
474        for (const r of list) {
475          const time = withTime ? clockText(r.at, offset) + ' ' : ''
476          out.push(
477            <Button key={'file:' + r.path} plain onPress={() => openFile($, r.path)}>
478              {'  '}
479              <Text color={r.kind === 'created' ? 'green' : 'yellow'}>{r.kind === 'created' ? '+' : '✎'}</Text>
480              <Text dimColor> {time}</Text>
481              {fit(basename(r.path), width - 6 - time.length)}
482              {r.count > 1 && <Text dimColor> ×{r.count}</Text>}
483            </Button>,
484          )
485        }
486      }
487      return out
488    }
489
490    if (current === 'today' || current === 'all') {
491      const everyProject = current === 'all'
492      const latest = new Map<string, DayEntry>()
493      for (const d of await read($, today)) {
494        // "project" keeps files that live inside this project's folder, whichever session changed them.
495        // Records from before normPath may still carry another spelling of the same path.
496        const path = normPath(d.path)
497        if (!everyProject && !samePath(path, cwd) && !isUnder(path, cwd)) continue
498        const seen = latest.get(pathKey(path))
499        if (!seen || seen.at < d.at) latest.set(pathKey(path), { ...d, path })
500      }
501      const day = [...latest.values()].sort((a, b) => b.at - a.at)
502      const all = await read($, today)
503      const sessions = new Set(all.filter(d => latest.has(pathKey(d.path))).map(d => d.session)).size
504      const projects = new Set(day.map(d => pathKey(d.cwd))).size
505      return (
506        <Box flexDirection="column">
507          {tabs}
508          <Text dimColor>
509            {everyProject
510              ? `今天 ${day.length} 個檔案 · ${projects} 個專案 · ${sessions} 個對話`
511              : `這個專案今天 ${day.length} 個檔案 · ${sessions} 個對話`}
512          </Text>
513          {day.length === 0 && <Text dimColor>今天還沒有紀錄。</Text>}
514          {fileList(day.map(d => ({ ...d, count: 1 })), everyProject ? '' : cwd, true)}
515        </Box>
516      )
517    }
518
519    const list = await read($, files)
520    const created = list.filter(f => f.kind === 'created').length
521    return (
522      <Box flexDirection="column">
523        {tabs}
524        {list.length === 0 ? (
525          <Text dimColor>這次對話還沒有修改檔案。</Text>
526        ) : (
527          <Box flexDirection="row" gap={1}>
528            <Text dimColor>
529              <Text color="green">+ 新增 {created}</Text> · <Text color="yellow">✎ 修改 {list.length - created}</Text>
530            </Text>
531            {/* Empties this tab only: today's records under project / all stay. */}
532            <Button
533              key="clear-talk"
534              hotkey="c"
535              variant="secondary"
536              label="清除"
537              onPress={() => update($, files, () => [])}
538            />
539          </Box>
540        )}
541        {fileList(list, cwd, false)}
542      </Box>
543    )
544  })
545}
546
hooks/features.ts 4 lines
1// Whether this copy of the mod has the game tab. file-log-nogame is made from this folder with it off
2// (scripts/make-nogame.sh), its game files left out.
3export const GAMES = true
4
hooks/snake.tsx 148 lines
1import type { ClientModule } from 'claude-code'
2
3// The snake runs here, on the drawing side: its own clock and keys, no round trip per move.
4
5type Dir = 'up' | 'down' | 'left' | 'right'
6type Cell = [number, number]
7type Status = 'ready' | 'running' | 'paused' | 'over'
8
9// `cmd` carries a key pressed through the pane's buttons (WASD / P hotkeys), numbered so each is applied once.
10export type SnakeProps = { cols: number; rows: number; best: number; cmd?: { seq: number; key: string } }
11
12type Game = {
13  status: Status
14  w: number
15  h: number
16  body: Cell[]
17  dir: Dir
18  nextDir: Dir
19  food: Cell
20  score: number
21  best: number
22  cmdSeq?: number
23}
24
25const ROWS = 10
26const TICK_MS = 150
27const DELTA: Record<Dir, Cell> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }
28const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
29const KEY_DIR: Record<string, Dir> = {
30  up: 'up', down: 'down', left: 'left', right: 'right',
31  w: 'up', s: 'down', a: 'left', d: 'right',
32  W: 'up', S: 'down', A: 'left', D: 'right',
33}
34
35function placeFood(w: number, h: number, body: Cell[]): Cell {
36  const free: Cell[] = []
37  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!body.some(b => b[0] === x && b[1] === y)) free.push([x, y])
38  return free[Math.floor(Math.random() * free.length)] ?? [-1, -1]
39}
40
41export function newGame(cols: number, best: number, status: Status = 'ready', dir: Dir = 'right', rows: number = ROWS): Game {
42  const y = Math.floor(rows / 2)
43  const body: Cell[] = [[3, y], [2, y], [1, y]]
44  return { status, w: cols, h: rows, body, dir: 'right', nextDir: dir === 'left' ? 'right' : dir, food: placeFood(cols, rows, body), score: 0, best }
45}
46
47export function step(g: Game): Game {
48  const dir = OPPOSITE[g.nextDir] === g.dir ? g.dir : g.nextDir
49  const [hx, hy] = g.body[0]!
50  const head: Cell = [hx + DELTA[dir][0], hy + DELTA[dir][1]]
51  const eats = head[0] === g.food[0] && head[1] === g.food[1]
52  const rest = eats ? g.body : g.body.slice(0, -1)
53  const hitsWall = head[0] < 0 || head[1] < 0 || head[0] >= g.w || head[1] >= g.h
54  if (hitsWall || rest.some(b => b[0] === head[0] && b[1] === head[1])) {
55    return { ...g, dir, status: 'over', best: Math.max(g.best, g.score) }
56  }
57  const body = [head, ...rest]
58  return { ...g, dir, body, score: g.score + (eats ? 1 : 0), food: eats ? placeFood(g.w, g.h, body) : g.food }
59}
60
61// One key, from the board's own focus or from the pane's buttons.
62function applyKey(g: Game, key: string): Game {
63  const dir = KEY_DIR[key]
64  if (dir) {
65    if (g.status === 'ready' || g.status === 'over') return newGame(g.w, g.best, 'running', dir, g.h)
66    return { ...g, nextDir: dir, status: 'running' }
67  }
68  if (key === ' ') {
69    if (g.status === 'running') return { ...g, status: 'paused' }
70    if (g.status === 'paused') return { ...g, status: 'running' }
71    return newGame(g.w, g.best, 'running', 'right', g.h)
72  }
73  return g
74}
75
76const Snake: ClientModule<SnakeProps, Game> = (props, surface) => {
77  const { Box, Text } = surface.elements
78  if (surface.state === undefined) {
79    surface.setState({ ...newGame(props.cols, props.best, 'ready', 'right', props.rows), cmdSeq: props.cmd?.seq ?? 0 })
80
81    surface.every(TICK_MS, () => {
82      const g = surface.state
83      if (!g || g.status !== 'running') return
84      const next = step(g)
85      surface.setState(next)
86      if (next.status === 'over' && next.score > 0) surface.post({ score: next.score })
87    })
88
89    surface.onKey(k => {
90      const g = surface.state
91      if (g) surface.setState({ ...applyKey(g, k.key), cmdSeq: g.cmdSeq })
92    })
93  }
94
95  let g = surface.state ?? newGame(props.cols, props.best, 'ready', 'right', props.rows)
96  // A key pressed through the pane's buttons arrives as new props.
97  if (surface.state && props.cmd && props.cmd.seq !== g.cmdSeq) {
98    g = { ...applyKey(g, props.cmd.key), cmdSeq: props.cmd.seq }
99    surface.setState(g)
100  }
101    // The pane changed size while no game runs: the next board fills it.
102  if ((g.status === 'ready' || g.status === 'over') && (g.w !== props.cols || g.h !== props.rows)) {
103    g = { ...newGame(props.cols, Math.max(g.best, props.best), 'ready', 'right', props.rows), cmdSeq: g.cmdSeq }
104    surface.setState(g)
105  }
106  const best = Math.max(g.best, props.best)
107
108  const rows = []
109  for (let y = 0; y < g.h; y++) {
110    const cells = []
111    for (let x = 0; x < g.w; x++) {
112      const i = g.body.findIndex(b => b[0] === x && b[1] === y)
113      // Plain spaces on a colored background: the same width in every terminal, so what you see is where it is.
114      if (i === 0) cells.push(<Text backgroundColor="greenBright">{'  '}</Text>)
115      else if (i > 0) cells.push(<Text backgroundColor="green">{'  '}</Text>)
116      else if (g.food[0] === x && g.food[1] === y) cells.push(<Text backgroundColor="red">{'  '}</Text>)
117      else cells.push(<Text dimColor>{'. '}</Text>)
118    }
119    rows.push(<Text>{cells}</Text>)
120  }
121
122  const status =
123    g.status === 'running'
124      ? '進行中 · 吃紅點 +1 分'
125      : g.status === 'paused'
126        ? '暫停中 · 空白鍵繼續'
127        : g.status === 'over'
128          ? `遊戲結束,得 ${g.score} 分 · 按 W A S D 再玩一次`
129          : '按 W A S D 開始'
130
131  return (
132    <Box flexDirection="column">
133      <Text>
134        分數 <Text bold>{g.score}</Text>
135        <Text dimColor>  最高 {best}</Text>
136      </Text>
137      <Box flexDirection="column" borderStyle="classic" alignSelf="flex-start">
138        {rows}
139      </Box>
140      <Text color={g.status === 'over' ? 'red' : undefined} dimColor={g.status !== 'over'}>
141        {status}
142      </Text>
143    </Box>
144  )
145}
146
147export default Snake
148
hooks/dino.tsx 175 lines
1import type { ClientModule } from 'claude-code'
2
3// Dinosaur jumping over cacti, like Chrome's offline game. Runs on the drawing side like the snake.
4// Every cell is one plain space on a colored background, so the drawing lines up in any terminal.
5
6type Status = 'ready' | 'running' | 'over'
7type Cactus = { x: number; h: number }
8
9export type DinoProps = { cols: number; rows: number; best: number; cmd?: { seq: number; key: string } }
10
11export type DinoGame = {
12  status: Status
13  w: number
14  sky: number // rows above the ground
15  y: number // height of the dino's feet above the ground, in rows
16  vy: number
17  cacti: Cactus[]
18  gap: number // ticks until the next cactus
19  ticks: number
20  score: number
21  best: number
22  cmdSeq?: number
23}
24
25// The dinosaur, facing right, top row first: X body, E eye, M mouth.
26// Two leg rows take turns while it runs.
27const BODY = [
28  '     XXXX', //
29  '     XEXX', //
30  '     XXXX', //
31  'X   XXXM ', //
32  'XX XXXXX ', //
33  ' XXXXXX  ', //
34]
35const LEGS = ['  X   X  ', '  XX  X  ', '  X  XX  ']
36export const DINO_W = 9
37export const DINO_H = BODY.length + 1
38const DINO_X = 2
39
40const TICK_MS = 60
41const JUMP = 1.8
42const GRAVITY = 0.27
43// The field is never shorter than the dinosaur at the top of its jump.
44export const MIN_SKY = 14
45const CACTUS_W = 3
46
47function sprite(g: DinoGame): string[] {
48  const legs = g.y > 0 || g.status !== 'running' ? LEGS[0]! : LEGS[1 + (Math.floor(g.ticks / 3) % 2)]!
49  return [...BODY, legs]
50}
51
52// Whether the cell (x, h) above the ground holds the dinosaur, and what part.
53function dinoAt(g: DinoGame, rows: string[], x: number, h: number): string | undefined {
54  const feet = Math.round(g.y)
55  const col = x - DINO_X
56  const row = DINO_H - 1 - (h - feet)
57  if (col < 0 || col >= DINO_W || row < 0 || row >= DINO_H) return undefined
58  const c = rows[row]![col]
59  return c === ' ' ? undefined : c
60}
61
62// A cactus: a trunk with an arm on each side, partway up.
63function cactusAt(c: Cactus, x: number, h: number): boolean {
64  if (h >= c.h || x < c.x || x >= c.x + CACTUS_W) return false
65  if (x === c.x + 1) return true
66  return c.h >= 3 && h >= 1 && h <= c.h - 2
67}
68
69export function newDino(cols: number, best: number, status: Status = 'ready', rows = 10): DinoGame {
70  return { status, w: cols, sky: rows, y: 0, vy: 0, cacti: [], gap: 12, ticks: 0, score: 0, best }
71}
72
73export function hits(g: DinoGame): boolean {
74  const rows = sprite(g)
75  for (const c of g.cacti) {
76    for (let x = c.x; x < c.x + CACTUS_W; x++) {
77      for (let h = 0; h < c.h; h++) if (cactusAt(c, x, h) && dinoAt(g, rows, x, h)) return true
78    }
79  }
80  return false
81}
82
83export function stepDino(g: DinoGame, random: () => number = Math.random): DinoGame {
84  let y = g.y + g.vy
85  let vy = g.vy - GRAVITY
86  if (y <= 0) {
87    y = 0
88    vy = 0
89  }
90  let cacti = g.cacti.map(c => ({ ...c, x: c.x - 1 })).filter(c => c.x + CACTUS_W > 0)
91  let gap = g.gap - 1
92  if (gap <= 0) {
93    cacti = [...cacti, { x: g.w, h: 2 + Math.floor(random() * 2) }]
94    // Gaps shrink as the score grows, never below what one jump clears.
95    gap = Math.max(18, 34 - Math.floor(g.score / 40)) + Math.floor(random() * 14)
96  }
97  const ticks = g.ticks + 1
98  const next = { ...g, y, vy, cacti, gap, ticks, score: Math.floor(ticks / 2) }
99  if (hits(next)) return { ...next, status: 'over', best: Math.max(g.best, next.score) }
100  return next
101}
102
103export function applyDinoKey(g: DinoGame, key: string): DinoGame {
104  if (key !== ' ' && key !== 'w') return g
105  if (g.status !== 'running') return newDino(g.w, g.best, 'running', g.sky)
106  if (g.y === 0) return { ...g, vy: JUMP }
107  return g
108}
109
110const Dino: ClientModule<DinoProps, DinoGame> = (props, surface) => {
111  const { Box, Text } = surface.elements
112
113  if (surface.state === undefined) {
114    surface.setState({ ...newDino(props.cols, props.best, 'ready', props.rows), cmdSeq: props.cmd?.seq ?? 0 })
115    surface.every(TICK_MS, () => {
116      const g = surface.state
117      if (!g || g.status !== 'running') return
118      const next = stepDino(g)
119      surface.setState(next)
120      if (next.status === 'over' && next.score > 0) surface.post({ score: next.score })
121    })
122  }
123
124  let g = surface.state ?? newDino(props.cols, props.best, 'ready', props.rows)
125  if (surface.state && props.cmd && props.cmd.seq !== g.cmdSeq) {
126    g = { ...applyDinoKey(g, props.cmd.key), cmdSeq: props.cmd.seq }
127    surface.setState(g)
128  }
129  // The pane changed size while no game runs: the next field fills it.
130  if (g.status !== 'running' && (g.w !== props.cols || g.sky !== props.rows)) {
131    g = { ...newDino(props.cols, Math.max(g.best, props.best), 'ready', props.rows), cmdSeq: g.cmdSeq }
132    surface.setState(g)
133  }
134  const best = Math.max(g.best, props.best)
135
136  const rows = sprite(g)
137  const dinoColor = g.status === 'over' ? 'red' : 'gray'
138  const lines = []
139  // (Not named `h`: that is the JSX factory.)
140  for (let level = g.sky - 1; level >= 0; level--) {
141    const cells = []
142    for (let x = 0; x < g.w; x++) {
143      const part = dinoAt(g, rows, x, level)
144      // Each pixel is two columns wide: terminal cells are about twice as tall as they are wide.
145      if (part === 'E') cells.push(<Text backgroundColor="white">{'  '}</Text>)
146      else if (part === 'M') cells.push('  ')
147      else if (part) cells.push(<Text backgroundColor={dinoColor}>{'  '}</Text>)
148      else if (g.cacti.some(c => cactusAt(c, x, level))) cells.push(<Text backgroundColor="green">{'  '}</Text>)
149      else cells.push('  ')
150    }
151    lines.push(<Text>{cells}</Text>)
152  }
153  lines.push(<Text dimColor>{'_'.repeat(g.w * 2)}</Text>)
154
155  const status =
156    g.status === 'running' ? '空白鍵或 W 跳過仙人掌' : g.status === 'over' ? `撞到了,得 ${g.score} 分 · 空白鍵再玩一次` : '按空白鍵開始'
157
158  return (
159    <Box flexDirection="column">
160      <Text>
161        分數 <Text bold>{g.score}</Text>
162        <Text dimColor>  最高 {best}</Text>
163      </Text>
164      <Box flexDirection="column" borderStyle="classic" alignSelf="flex-start">
165        {lines}
166      </Box>
167      <Text color={g.status === 'over' ? 'red' : undefined} dimColor={g.status !== 'over'}>
168        {status}
169      </Text>
170    </Box>
171  )
172}
173
174export default Dino
175
types/index.d.ts 22 lines
1export 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': { files: FileEntry[]; tab: FileLogTab; today: DayEntry[]; gameCmd: GameCmd; pad: string; game: GameView }
20  }
21}
22