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

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 546 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'
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}
546hooks/features.ts 4 lines1// 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
4hooks/snake.tsx 148 lines1import 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
148hooks/dino.tsx 175 lines1import 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
175types/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': { files: FileEntry[]; tab: FileLogTab; today: DayEntry[]; gameCmd: GameCmd; pad: string; game: GameView }
20 }
21}
22