任何專案通用:專案活動 pane(專案狀態、熱門檔案、最近活動)與 Git 狀態 pane,加上測試結果 status line 與回合摘要 band

自用的 Claude Code mod(plugin),任何專案都能使用。
| Plugin | 內容 |
|---|---|
session-stats | 右側 pane「工作階段狀態」:用量與預報(context、rate limit、compact 預估)、任務與執行紀錄(可回放)、上下文與改動範圍。指令 /session-stats 開關;用量區塊有「重新整理」(快捷鍵 r) |
project-activity | 「專案活動」pane(專案類型、關鍵檔案、熱門檔案、最近活動)與「Git 狀態」pane,測試結果 status line、回合摘要 band。指令 /project-activity、/git-status |
兩個 plugin 分開,是因為引擎規定用到 $(引擎介面)的程式必須寫在同一個檔案;合併會變成一個上千行的檔案。安裝時兩個都裝即可。
這是私人 repository,電腦上的 git 需要能存取 GitHub(登入過即可)。在 Claude Code 的提示列輸入:
/plugin install session-stats --marketplace hn83320589/claude-mods
/plugin install project-activity --marketplace hn83320589/claude-mods
第一次會詢問是否加入 marketplace,輸入 y;scope 選 user(所有專案都會載入)。
claude plugin marketplace update claude-mods
claude plugin update session-stats@claude-mods
claude plugin update project-activity@claude-mods
更新後在工作階段中執行 /reload-plugins。
這個資料夾本身就是 marketplace,以本機資料夾加入後,修改程式再 /reload-plugins 就會生效,不需要重新安裝:
claude plugin marketplace add ~/.claude/mods
claude plugin install session-stats@claude-mods
claude plugin install project-activity@claude-mods
測試與檢查:
claude plugin validate ./session-stats
claude plugin test ./session-stats
claude plugin validate ./project-activity
claude plugin test ./project-activity
/compact、預先壓縮)都會要求摘要保留目前任務與進度、已做的決定與原因、改過的檔案、使用者的偏好與規則、未解決的問題。.claude/session-notes/YYYY-MM-DD-HHmm.md。資料夾內有自己的 .gitignore(忽略全部),不會被提交,也不必修改專案的 .gitignore。password= 等)存檔前會遮蔽.claude、session-notes 或要寫入的檔案(含 .gitignore)是符號連結、實際位置不在專案內,或狀態無法確認(例如權限不足)時都不存檔,避免寫到專案外project-activity 可以在專案的 .claude/project-activity.json 列出要檢查的檔案(例如必要的資料檔),沒有這個檔時 pane 會提示可以設定。
hooks/register.tsx 633 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { CheckRow, GitView, ProjectStatus, ToolCall, Turn } from '../types'
5import { LOG_FORMAT, letterOf, parseLog, parseNumstat, parseStatus, STATUS_NAMES } from './git'
6import { fit, SECTION_CHROME, share, splitRows } from './layout'
7import { formatStatus, isTestCommand, parseTestSummary } from './parse'
8import { ago, CONFIG_PATH, describeCheck, detectTypes, hottest, keyFiles, parseConfig, recordHeat } from './project'
9import type { Found } from './project'
10import { clean, clip } from './text'
11import { barOf, diff, line, more, rule, section, text } from './ui'
12
13const PANE = 'project-activity'
14const PANE_TITLE = '專案活動'
15const GIT_PANE = 'git-status'
16const GIT_TITLE = 'Git 狀態'
17const COLOR = { project: 'success', heat: 'claude', activity: 'suggestion' } as const
18const GIT_COLOR = { branch: 'claude', changes: 'warning', commits: 'success' } as const
19const LETTER_COLOR: Record<string, string> = { M: 'warning', A: 'success', D: 'error', R: 'suggestion', C: 'suggestion', U: 'error' }
20const COMMIT_COUNT = 30
21const FILTER_SKIPPED = '這個 repository 的設定定義了 filter,為了安全不自動執行 git 指令'
22const CONFIG_UNKNOWN = '無法確認這個 repository 的設定是否安全(git config 失敗),不自動執行 git 指令'
23
24const lastTest = atom({ plugin: 'project-activity', key: 'lastTest' } as const, null)
25const lastTestAt = atom({ plugin: 'project-activity', key: 'lastTestAt' } as const, null)
26const calls = atom({ plugin: 'project-activity', key: 'calls' } as const, [])
27const edited = atom({ plugin: 'project-activity', key: 'edited' } as const, [])
28const turn = atom({ plugin: 'project-activity', key: 'turn' } as const, null)
29const isBandHidden = atom({ plugin: 'project-activity', key: 'isBandHidden' } as const, false)
30const heat = atom({ plugin: 'project-activity', key: 'heat' } as const, [])
31const project = atom({ plugin: 'project-activity', key: 'project' } as const, null)
32const isGitOpen = atom({ plugin: 'project-activity', key: 'isGitOpen' } as const, false)
33const git = atom({ plugin: 'project-activity', key: 'git' } as const, null)
34
35const EMPTY_TURN: Turn = { seconds: null, tools: 0, edits: 0 }
36
37const slash = (path: string): string => path.replaceAll(String.fromCharCode(92), '/')
38const shortPath = (path: string): string => slash(path).split('/').slice(-2).join('/')
39const fileName = (path: string): string => clean(slash(path).split('/').pop() ?? path)
40const message = (err: unknown): string => (err instanceof Error ? err.message : String(err))
41
42// ── 專案狀態 ──────────────────────────────────────────────
43
44const inspect = async ($: EngineInterface, path: string, isDir: boolean): Promise<Found | undefined> => {
45 if (!(await $.fs.exists(path))) return undefined
46 const stat = await $.fs.stat(path)
47 const count = isDir && stat.kind === 'dir' ? (await $.fs.list(path)).filter(e => e.kind === 'file').length : 0
48 return { kind: stat.kind, size: stat.size, mtimeMs: stat.mtimeMs, count }
49}
50
51// 讀專案根目錄與專案設定檔;設定檔有問題只顯示原因,不擋其他資訊
52const refreshProject = async ($: EngineInterface): Promise<void> => {
53 const root = slash(await $.session.root())
54 const entries = await $.fs.list(root)
55 let checks: CheckRow[] | null = null
56 let configError: string | null = null
57 const configFile = `${root}/${CONFIG_PATH}`
58 if (await $.fs.exists(configFile)) {
59 try {
60 const specs = parseConfig(await $.fs.read(configFile))
61 const now = await $.clock.now()
62 checks = []
63 for (const spec of specs) checks.push(describeCheck(spec, await inspect($, `${root}/${spec.path}`, spec.dir), now))
64 } catch (err) {
65 configError = message(err)
66 }
67 }
68 const next: ProjectStatus = { name: fileName(root), types: detectTypes(entries), keyFiles: keyFiles(entries), checks, configError }
69 await update($, project, () => next)
70}
71
72// ── Git 狀態 ──────────────────────────────────────────────
73
74// 開啟的資料夾可能是別人的 repo:.git/config 能設定 fsmonitor、外部 diff、textconv、簽章驗證程式,
75// 讓 git status/diff/log 順便執行任意程式。以命令列設定(優先權高於 repo 設定)關閉這些功能,
76// diff 另加 --no-ext-diff、--no-textconv,並忽略 submodule(會讀取 submodule 自己的設定)。
77const SAFE_CONFIG = [
78 '-c', 'core.fsmonitor=false',
79 '-c', 'core.untrackedCache=false',
80 '-c', 'diff.external=',
81 '-c', 'log.showSignature=false',
82 '-c', 'status.submoduleSummary=false',
83]
84const gitArgv = (args: readonly string[]): string[] => ['git', ...SAFE_CONFIG, ...args]
85
86// filter(clean/smudge/process)可能在 git status 時被執行,名稱由 repo 自訂、無法逐一關閉:
87// repo 自己的設定(local 與 worktree 範圍,含 include 的檔案)定義了 filter 時,就不自動執行 git。
88// 使用者全域或系統設定的 filter(例如 git-lfs)是使用者自己裝的,不受影響。讀取設定本身不會執行任何程式。
89// 回傳不執行 git 的原因;可以安全執行時回傳 null。無法確認時一律不執行(fail closed):
90// git config 只有在「沒有符合的設定」時回 1,其他非 0(設定檔壞掉、被刻意弄壞)都視為不安全。
91const repoFilterRisk = async ($: EngineInterface, root: string): Promise<string | null> => {
92 const result = await $.process.run(
93 gitArgv(['config', '--includes', '--show-scope', '--name-only', '--get-regexp', '^filter[.].*[.](clean|smudge|process)$']),
94 { cwd: root, timeoutMs: 10_000 },
95 )
96 if (result.exitCode === 1) return null
97 if (result.exitCode !== 0) return CONFIG_UNKNOWN
98 const fromRepo = result.stdout
99 .split(String.fromCharCode(10))
100 .map(line => line.split(String.fromCharCode(9))[0]?.trim())
101 .some(scope => scope === 'local' || scope === 'worktree')
102 return fromRepo ? FILTER_SKIPPED : null
103}
104
105// Windows 執行 git 時會先找目前資料夾(專案根目錄)裡的 git.exe、git.cmd 等。
106// 根目錄有這類檔案時,執行的會是 repo 提供的程式,所以完全不執行 git。
107const SHADOWING_GIT = /^git[.](com|exe|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|cpl|ps1)$/i
108
109const shadowingGit = async ($: EngineInterface, root: string): Promise<string | null> => {
110 const entry = (await $.fs.list(root)).find(e => SHADOWING_GIT.test(e.name))
111 return entry ? clean(entry.name) : null
112}
113
114const gitRun = async ($: EngineInterface, root: string, args: readonly string[]): Promise<string> => {
115 const result = await $.process.run(gitArgv(args), { cwd: root, timeoutMs: 10_000 })
116 if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `git ${args[0] ?? ''} 失敗(${result.exitCode})`)
117 return result.stdout
118}
119
120// git pane 關著時不跑任何 git 指令
121const refreshGit = async ($: EngineInterface): Promise<void> => {
122 if (!(await read($, isGitOpen))) return
123 let next: GitView
124 let risk: string | null = null
125 try {
126 // 檢查與執行都固定在專案根目錄,確保檢查過的資料夾就是 git 執行時的資料夾
127 const root = slash(await $.session.root())
128 const shadow = await shadowingGit($, root)
129 if (shadow !== null) {
130 await update($, git, () => ({
131 isRepo: true, status: null, staged: {}, unstaged: {}, commits: [], stashes: 0,
132 error: `專案根目錄有 ${shadow},Windows 執行 git 時會優先執行它,為了安全不自動執行 git 指令`,
133 }))
134 return
135 }
136 const inside = await $.process.run(gitArgv(['rev-parse', '--is-inside-work-tree']), { cwd: root, timeoutMs: 10_000 })
137 if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
138 next = { isRepo: false, status: null, staged: {}, unstaged: {}, commits: [], stashes: 0, error: null }
139 } else if ((risk = await repoFilterRisk($, root)) !== null) {
140 next = { isRepo: true, status: null, staged: {}, unstaged: {}, commits: [], stashes: 0, error: risk }
141 } else {
142 const status = parseStatus(await gitRun($, root, ['status', '--porcelain=v2', '--branch', '-z', '--ignore-submodules=all']))
143 const diff = ['diff', '--numstat', '-z', '--no-ext-diff', '--no-textconv', '--ignore-submodules=all']
144 const staged = parseNumstat(await gitRun($, root, [...diff, '--cached']))
145 const unstaged = parseNumstat(await gitRun($, root, diff))
146 // 剛 init、還沒有任何 commit 時 git log 會失敗,視為沒有提交
147 const log = await $.process.run(gitArgv(['log', `-n${COMMIT_COUNT}`, `--format=${LOG_FORMAT}`]), { cwd: root, timeoutMs: 10_000 })
148 const commits = log.exitCode === 0 ? parseLog(log.stdout) : []
149 const stashes = (await gitRun($, root, ['stash', 'list'])).split('\n').filter(l => l.trim() !== '').length
150 next = { isRepo: true, status, staged, unstaged, commits, stashes, error: null }
151 }
152 } catch (err) {
153 next = { isRepo: true, status: null, staged: {}, unstaged: {}, commits: [], stashes: 0, error: `git 指令失敗:${message(err)}` }
154 }
155 await update($, git, () => next)
156}
157
158// 手動重新整理:pane 開著就代表要看。開關可能被重置(例如 /clear 之後),先設回來再讀取,
159// 否則 refreshGit 會把它當成「pane 關著」而直接略過(之前按了沒反應的原因)。
160const forceRefreshGit = async ($: EngineInterface): Promise<void> => {
161 await update($, isGitOpen, () => true)
162 await refreshGit($)
163}
164
165const openGit = async ($: EngineInterface): Promise<void> => {
166 await update($, isGitOpen, () => true)
167 await refreshGit($)
168 await $.ui.open({ id: GIT_PANE, title: GIT_TITLE })
169}
170
171// ── 補讀 ──────────────────────────────────────────────────
172// /clear 或新的工作階段不會再觸發 session.start,狀態卻會重置:pane 還開著,開關與資料都回到初始值,
173// git pane 之後的更新全被當成「關著」而跳過。繪製時發現沒有資料,就安排一次更新補回來。
174// 繪製期間不能寫狀態,所以交給 clock.after 在繪製之後執行;失敗時隔一段時間才再試,避免每次重繪都重試。
175const HEAL_RETRY_MS = 5_000
176const healing = new Set<string>()
177const lastHealAt = new Map<string, number>()
178
179const scheduleHeal = async ($: EngineInterface, name: string, work: () => Promise<void>): Promise<void> => {
180 const now = await $.clock.now()
181 if (healing.has(name) || now - (lastHealAt.get(name) ?? Number.NEGATIVE_INFINITY) < HEAL_RETRY_MS) return
182 healing.add(name)
183 lastHealAt.set(name, now)
184 $.clock.after(0, () => {
185 void (async () => {
186 try {
187 await work()
188 } catch (err) {
189 $.ui.log(`${name} 資料補讀失敗:${message(err)}`, { to: 'debug' })
190 } finally {
191 healing.delete(name)
192 }
193 })()
194 })
195}
196
197// ── Hooks ─────────────────────────────────────────────────
198
199export const register: Register = on => {
200 on('session.start', async ($, e, next) => {
201 await $.command.register({ name: 'project-activity', description: `開啟「${PANE_TITLE}」pane` })
202 await $.command.register({ name: 'git-status', description: `開啟或關閉「${GIT_TITLE}」pane` })
203 $.ui.status(formatStatus(await read($, lastTest)))
204 await refreshProject($)
205 void $.ui.open({ id: PANE, title: PANE_TITLE })
206 await openGit($)
207 return next(e)
208 })
209
210 on('command.run', { command: 'project-activity' }, async $ => {
211 await update($, isBandHidden, () => false)
212 await refreshProject($)
213 await $.ui.open({ id: PANE, title: PANE_TITLE })
214 return { text: `已開啟「${PANE_TITLE}」pane。` }
215 })
216
217 on('command.run', { command: 'git-status' }, async $ => {
218 if (await read($, isGitOpen)) {
219 await $.ui.close({ id: GIT_PANE })
220 return { text: `已關閉「${GIT_TITLE}」pane。` }
221 }
222 await openGit($)
223 return { text: `已開啟「${GIT_TITLE}」pane。` }
224 })
225
226 on('ui.close', async ($, e, next) => {
227 if (e.id === GIT_PANE) await update($, isGitOpen, () => false)
228 return next(e)
229 })
230
231 on('prompt.submit', async ($, e, next) => {
232 await update($, turn, () => EMPTY_TURN)
233 return next(e)
234 })
235
236 on('tool.call', async ($, e, next) => {
237 await update($, turn, t => ({ ...(t ?? EMPTY_TURN), tools: (t?.tools ?? 0) + 1 }))
238 const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : undefined
239 const file = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : undefined
240 const readFile = e.tool === 'Read' ? e.file_path : undefined
241 const label = clean(command?.slice(0, 60) ?? (file !== undefined ? shortPath(file) : readFile !== undefined ? shortPath(readFile) : ''))
242 const call: ToolCall = { id: e.tool_use_id, tool: e.tool, label, isDone: false, isError: false }
243 await update($, calls, list => [...list, call].slice(-200))
244
245 const ran = await next(e)
246 const isError = ran.deny !== undefined || ran.isError === true
247 await update($, calls, list =>
248 list.map(one => (one.id === call.id ? { ...one, isDone: true, isError } : one)),
249 )
250
251 if (readFile !== undefined && !isError) {
252 await update($, heat, list => recordHeat(list, readFile, 'read'))
253 }
254
255 if (file !== undefined && !isError) {
256 await update($, turn, t => ({ ...(t ?? EMPTY_TURN), edits: (t?.edits ?? 0) + 1 }))
257 await update($, edited, list => (list.includes(file) ? list : [...list, file]))
258 await update($, heat, list => recordHeat(list, file, 'edit'))
259 }
260
261 if (command !== undefined && isTestCommand(command) && ran.deny === undefined) {
262 const result = parseTestSummary(ran.text ?? '')
263 if (result !== null) {
264 await update($, lastTest, () => result)
265 const at = await $.clock.now()
266 await update($, lastTestAt, () => at)
267 $.ui.status(formatStatus(result))
268 } else if (ran.isError === true) {
269 $.ui.status('測試 ✗ 建置或執行失敗')
270 }
271 }
272
273 // 改了檔案或跑了 git 指令,工作區狀態就可能變了
274 if (file !== undefined || (command !== undefined && /\bgit\b/.test(command))) await refreshGit($)
275
276 return ran
277 })
278
279 on('turn.complete', async ($, e, next) => {
280 // subagent 的回合也會觸發 turn.complete,只記主迴圈的
281 if (e.agentId === undefined) {
282 const seconds = Math.round(e.durationMs / 1000)
283 await update($, turn, t => ({ ...(t ?? EMPTY_TURN), seconds }))
284 await refreshProject($)
285 await refreshGit($)
286 }
287 return next(e)
288 })
289
290 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291 const now = await read($, turn)
292 if (e.props.hasSurvey || now === null || (await read($, isBandHidden))) {
293 return next(e)
294 }
295 const test = await read($, lastTest)
296 const { Box, Button, Text } = $.ui.resolve(e)
297
298 return (
299 <Box>
300 <Text key="turn" dimColor>
301 {now.seconds === null ? '本回合' : `上一回合 ${now.seconds}s`} · {now.tools} 次工具呼叫 · 修改 {now.edits} 個檔案
302 </Text>
303 {test !== null && (
304 <Text key="test" color={test.isOk ? 'green' : 'red'}>
305 {' '}· 測試 {test.isOk ? '✓' : '✗'} {test.passed}/{test.total}{' '}
306 </Text>
307 )}
308 <Button key="hide" label="隱藏" onPress={() => update($, isBandHidden, () => true)} />
309 </Box>
310 )
311 })
312
313 // ── 專案活動 pane ──
314 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
315 const kit = $.ui.resolve(e)
316 const { Box, Text } = kit
317 const test = await read($, lastTest)
318 const testAt = await read($, lastTestAt)
319 const status = await read($, project)
320 const heatList = hottest(await read($, heat))
321 const files = await read($, edited)
322 const list = await read($, calls)
323 if (status === null) await scheduleHeal($, PANE, () => refreshProject($))
324 const now = await $.clock.now()
325
326 const [projectRows = 4, heatRows = 4, activityRows = 4] = splitRows(Math.max(12, e.props.scroll.bodyRows), 3)
327 const inner = Math.max(24, e.props.bodyColumns - 4)
328
329 // ── 區塊一:專案狀態 ──
330 const headRows: RenderChildren[] =
331 status === null
332 ? [text(kit, '讀取中…', { dim: true })]
333 : [
334 line(
335 kit,
336 <Text wrap="truncate-end">
337 <Text bold>{status.name}</Text>
338 <Text dimColor>{status.types.length === 0 ? ' · 未偵測到專案類型' : ` · ${status.types.join(' · ')}`}</Text>
339 </Text>,
340 ),
341 test === null
342 ? text(kit, '測試 尚未在這個 session 跑過測試', { dim: true })
343 : line(
344 kit,
345 <Text wrap="truncate-end">
346 <Text dimColor>測試 </Text>
347 <Text color={test.isOk ? 'success' : 'error'} bold>
348 {test.isOk ? `✓ ${test.passed}/${test.total} 全部通過` : `✗ ${test.failed} 個失敗(${test.passed}/${test.total})`}
349 </Text>
350 <Text dimColor>{` · ${test.runner}${testAt === null ? '' : ` · ${ago(testAt, now)}`}`}</Text>
351 </Text>,
352 ),
353 line(
354 kit,
355 <Text wrap="truncate-end">
356 {status.keyFiles.map((k, i) => (
357 <Text>
358 <Text>{i === 0 ? '' : ' '}</Text>
359 <Text color={k.found ? 'success' : undefined} dimColor={!k.found}>{`${k.found ? '✓' : '○'} ${k.label}`}</Text>
360 </Text>
361 ))}
362 </Text>,
363 ),
364 ]
365 const missingRequired = status?.checks?.some(r => r.state === 'missing-required') ?? false
366 const checkRows: RenderChildren[] =
367 status === null
368 ? []
369 : status.configError !== null
370 ? [text(kit, `設定檔有誤:${status.configError}`, { color: 'error' })]
371 : status.checks === null
372 ? [text(kit, `可在 ${CONFIG_PATH} 設定要檢查的檔案`, { dim: true })]
373 : status.checks.map(r =>
374 line(
375 kit,
376 <Box flexGrow={1}>
377 <Text color={r.state === 'ok' ? 'success' : r.state === 'missing-required' ? 'error' : undefined} dimColor={r.state === 'missing-optional'}>
378 {r.state === 'ok' ? '✓ ' : r.state === 'missing-required' ? '✗ ' : '○ '}
379 </Text>
380 <Box flexGrow={1} overflow="hidden">
381 <Text bold={r.state === 'missing-required'} dimColor={r.state === 'missing-optional'} wrap="truncate-end">{r.name}</Text>
382 </Box>
383 <Text color={r.state === 'missing-required' ? 'error' : undefined} dimColor={r.state !== 'missing-required'}>{` ${r.detail}`}</Text>
384 </Box>,
385 ),
386 )
387 const checkFit = fit(checkRows, projectRows - SECTION_CHROME - headRows.length - 1)
388
389 // ── 區塊二:熱門檔案 ──
390 const maxHeat = Math.max(1, ...heatList.map(f => f.reads + f.edits))
391 const heatBar = Math.max(4, Math.floor(inner / 4))
392 const heatFit = fit(
393 // 變數不能取名 h:JSX 編譯後呼叫的就是全域 h
394 heatList.map(f => {
395 const bar = barOf(((f.reads + f.edits) / maxHeat) * 100, heatBar)
396 return line(
397 kit,
398 <Box flexGrow={1}>
399 <Box flexGrow={1} overflow="hidden"><Text wrap="truncate-end">{fileName(f.path)}</Text></Box>
400 <Text color={COLOR.heat}>{` ${bar.full}`}</Text>
401 <Text dimColor>{bar.rest}</Text>
402 <Text dimColor>{` 讀 ${String(f.reads).padStart(2)}`}</Text>
403 <Text color={f.edits > 0 ? 'warning' : undefined} dimColor={f.edits === 0}>{` 改 ${String(f.edits).padStart(2)}`}</Text>
404 </Box>,
405 )
406 }),
407 heatRows - SECTION_CHROME,
408 )
409
410 // ── 區塊三:最近活動 ──
411 const split = share(activityRows - SECTION_CHROME, Math.max(1, files.length), 0.4, 2)
412 const fileFit = fit(
413 [...files].reverse().map(path => text(kit, `✎ ${shortPath(path)}`, { color: COLOR.activity })),
414 split.upper,
415 )
416 const callRows = list.slice(-Math.max(1, split.lower - 1)).map(call =>
417 line(
418 kit,
419 <Box flexGrow={1}>
420 <Text color={call.isError ? 'error' : call.isDone ? 'success' : 'warning'}>
421 {call.isDone ? (call.isError ? '✗ ' : '✓ ') : '… '}
422 </Text>
423 <Text bold={!call.isDone}>{`${call.tool} `}</Text>
424 <Box flexGrow={1} overflow="hidden">
425 <Text dimColor wrap="truncate-end">{call.label}</Text>
426 </Box>
427 </Box>,
428 ),
429 )
430
431 return (
432 <Box flexDirection="column">
433 {section(
434 kit,
435 {
436 color: COLOR.project,
437 title: '◆ 專案狀態',
438 height: projectRows,
439 right: missingRequired ? <Text color="error">缺少必要檔案</Text> : undefined,
440 },
441 [...headRows, rule(kit, '專案檢查', COLOR.project), ...checkFit.shown, ...more(kit, checkFit.more, '項')],
442 )}
443
444 {section(
445 kit,
446 {
447 color: COLOR.heat,
448 title: '◆ 熱門檔案',
449 height: heatRows,
450 right: heatList.length > 0 ? <Text dimColor>{`共 ${heatList.length} 檔`}</Text> : undefined,
451 },
452 heatList.length === 0
453 ? [text(kit, '還沒有讀取或修改任何檔案', { dim: true })]
454 : [...heatFit.shown, ...more(kit, heatFit.more, '檔')],
455 )}
456
457 {section(
458 kit,
459 {
460 color: COLOR.activity,
461 title: '◆ 最近活動',
462 height: activityRows,
463 right: <Text dimColor>{`修改 ${files.length} 檔 · 工具 ${list.length} 次`}</Text>,
464 },
465 [
466 ...(files.length === 0 ? [text(kit, '本 session 還沒有修改檔案', { dim: true })] : [...fileFit.shown, ...more(kit, fileFit.more, '檔')]),
467 rule(kit, '工具呼叫', COLOR.activity),
468 ...(callRows.length === 0 ? [text(kit, '尚無', { dim: true })] : callRows),
469 ],
470 )}
471 </Box>
472 )
473 })
474
475 // ── Git 狀態 pane ──
476 on('ui.render', { component: 'Pane', requestId: GIT_PANE }, async ($, e) => {
477 const kit = $.ui.resolve(e)
478 const { Box, Button, Text } = kit
479 const view = await read($, git)
480 if (view === null || !(await read($, isGitOpen))) {
481 await scheduleHeal($, GIT_PANE, async () => {
482 await update($, isGitOpen, () => true)
483 await refreshGit($)
484 })
485 }
486 const now = await $.clock.now()
487 const bodyRows = Math.max(12, e.props.scroll.bodyRows)
488 const inner = Math.max(24, e.props.bodyColumns - 4)
489
490 const controls = (
491 <Box columnGap={1}>
492 <Button key="git-refresh" dimColor hotkey="r" onPress={() => forceRefreshGit($)}>重新整理</Button>
493 <Button key="git-close" dimColor onPress={() => $.ui.close({ id: GIT_PANE })}>關閉</Button>
494 </Box>
495 )
496
497 // 不是 repo、讀取中或出錯時,整個 pane 只用一個區塊說明
498 if (view === null || !view.isRepo || view.error !== null || view.status === null) {
499 const rows =
500 view === null
501 ? [text(kit, '讀取中…', { dim: true })]
502 : !view.isRepo
503 ? [text(kit, '這個資料夾不是 git repository'), text(kit, '執行 git init 開始版本控制後,這裡會顯示分支與變更', { dim: true })]
504 : [text(kit, view.error ?? 'git 指令失敗:未知原因', { color: 'error' })]
505 return (
506 <Box flexDirection="column">
507 {section(kit, { color: GIT_COLOR.branch, title: '◆ Git 狀態', height: Math.min(bodyRows, rows.length + SECTION_CHROME), right: controls }, rows)}
508 </Box>
509 )
510 }
511
512 const s = view.status
513 const [branchRows = 4, changeRows = 4, commitRows = 4] = splitRows(bodyRows, 3)
514 const stagedFiles = s.files.filter(f => f.kind === 'changed' && f.staged !== '.')
515 const otherFiles = s.files.filter(f => !(f.kind === 'changed' && f.staged !== '.'))
516 const untracked = s.files.filter(f => f.kind === 'untracked').length
517 const conflicts = s.files.filter(f => f.kind === 'conflict').length
518 const unstagedCount = otherFiles.length - untracked - conflicts
519 const totals = [...Object.values(view.staged), ...Object.values(view.unstaged)].reduce(
520 (t, n) => ({ added: t.added + n.added, removed: t.removed + n.removed }),
521 { added: 0, removed: 0 },
522 )
523 const latest = view.commits[0]
524
525 // ── 區塊一:分支 ──
526 const branchLines: RenderChildren[] = [
527 line(
528 kit,
529 <Text wrap="truncate-end">
530 <Text color={GIT_COLOR.branch} bold>{`⎇ ${s.branch === '(detached)' ? '分離 HEAD' : s.branch || '(未命名)'}`}</Text>
531 <Text dimColor>{s.upstream === null ? ' 沒有上游分支' : ` → ${s.upstream}`}</Text>
532 </Text>,
533 ),
534 s.upstream === null
535 ? text(kit, '尚未設定 upstream,push 前需要 git push -u', { dim: true })
536 : line(
537 kit,
538 <Text wrap="truncate-end">
539 {s.ahead === 0 && s.behind === 0 ? <Text color="success">✓ 與遠端同步</Text> : null}
540 {s.ahead > 0 ? <Text color="suggestion">{`↑ 領先 ${s.ahead} 個提交 `}</Text> : null}
541 {s.behind > 0 ? <Text color="warning">{`↓ 落後 ${s.behind} 個提交`}</Text> : null}
542 </Text>,
543 ),
544 latest === undefined
545 ? text(kit, '還沒有任何提交', { dim: true })
546 : line(
547 kit,
548 <Box flexGrow={1}>
549 <Text dimColor>{`最新 ${latest.hash} `}</Text>
550 <Box flexGrow={1} overflow="hidden"><Text wrap="truncate-end">{latest.subject}</Text></Box>
551 <Text dimColor>{` ${ago(latest.time, now)}`}</Text>
552 </Box>,
553 ),
554 line(
555 kit,
556 <Text wrap="truncate-end">
557 <Text color={stagedFiles.length > 0 ? 'success' : undefined} dimColor={stagedFiles.length === 0}>{`暫存 ${stagedFiles.length}`}</Text>
558 <Text dimColor> · </Text>
559 <Text color={unstagedCount > 0 ? 'warning' : undefined} dimColor={unstagedCount === 0}>{`未暫存 ${unstagedCount}`}</Text>
560 <Text dimColor>{` · 未追蹤 ${untracked}`}</Text>
561 {conflicts > 0 ? <Text color="error">{` · 衝突 ${conflicts}`}</Text> : null}
562 <Text dimColor>{` · stash ${view.stashes}`}</Text>
563 </Text>,
564 ),
565 ]
566
567 // ── 區塊二:工作區變更(已暫存在前) ──
568 const fileRow = (f: (typeof s.files)[number], counts: { added: number; removed: number } | undefined): RenderChildren => {
569 const letter = letterOf(f)
570 return line(
571 kit,
572 <Box flexGrow={1}>
573 <Text color={LETTER_COLOR[letter]} dimColor={letter === '?'} bold>{`${letter} `}</Text>
574 <Box flexGrow={1} overflow="hidden"><Text dimColor={letter === '?'} wrap="truncate-end">{f.path}</Text></Box>
575 {counts === undefined ? <Text dimColor>{` ${STATUS_NAMES[letter] ?? ''}`}</Text> : <Text> </Text>}
576 {counts === undefined ? null : diff(kit, counts.added, counts.removed)}
577 </Box>,
578 )
579 }
580 const changeLines: RenderChildren[] = [
581 ...(stagedFiles.length === 0 ? [] : [rule(kit, `已暫存 ${stagedFiles.length}`, 'success'), ...stagedFiles.map(f => fileRow(f, view.staged[f.path]))]),
582 ...(otherFiles.length === 0 ? [] : [rule(kit, `未暫存 ${otherFiles.length}`, GIT_COLOR.changes), ...otherFiles.map(f => fileRow(f, view.unstaged[f.path]))]),
583 ]
584 const changeFit = fit(changeLines, changeRows - SECTION_CHROME)
585
586 // ── 區塊三:最近提交 ──
587 const commitFit = fit(
588 view.commits.map(c =>
589 line(
590 kit,
591 <Box flexGrow={1}>
592 <Text color={GIT_COLOR.commits}>{`${c.hash} `}</Text>
593 <Box flexGrow={1} overflow="hidden"><Text wrap="truncate-end">{c.subject}</Text></Box>
594 <Text dimColor>{` ${clip(c.author, 10)} · ${ago(c.time, now)}`}</Text>
595 </Box>,
596 ),
597 ),
598 commitRows - SECTION_CHROME,
599 )
600
601 return (
602 <Box flexDirection="column">
603 {section(kit, { color: GIT_COLOR.branch, title: '◆ 分支', height: branchRows, right: controls }, branchLines)}
604 {section(
605 kit,
606 {
607 color: GIT_COLOR.changes,
608 title: '◆ 工作區變更',
609 height: changeRows,
610 right: s.files.length === 0 ? undefined : (
611 <Text>
612 <Text dimColor>{`${s.files.length} 檔 `}</Text>
613 {diff(kit, totals.added, totals.removed)}
614 </Text>
615 ),
616 },
617 s.files.length === 0 ? [text(kit, '✓ 工作區乾淨,沒有未提交的變更', { color: 'success' })] : [...changeFit.shown, ...more(kit, changeFit.more, '列')],
618 )}
619 {section(
620 kit,
621 {
622 color: GIT_COLOR.commits,
623 title: '◆ 最近提交',
624 height: commitRows,
625 right: view.commits.length === 0 ? undefined : <Text dimColor>{`最近 ${view.commits.length} 筆`}</Text>,
626 },
627 view.commits.length === 0 ? [text(kit, '還沒有任何提交', { dim: true })] : [...commitFit.shown, ...more(kit, commitFit.more, '筆')],
628 )}
629 </Box>
630 )
631 })
632}
633hooks/git.ts 80 lines1import type { GitCommit, GitFile, GitStatus, NumStat } from '../types'
2import { clean } from './text'
3
4// 解析 git 的機器可讀輸出。一律用 -z(NUL 分隔):路徑含中文或空白時才不會被加引號跳脫。
5// 分隔字元用 fromCharCode 產生,原始碼裡不寫反斜線跳脫(mod 載入器處理反斜線有問題)。
6
7const NUL = String.fromCharCode(0)
8const TAB = String.fromCharCode(9)
9export const FIELD = String.fromCharCode(31)
10export const RECORD = String.fromCharCode(30)
11
12// git status --porcelain=v2 --branch -z
13export const parseStatus = (stdout: string): GitStatus => {
14 const status: GitStatus = { branch: '', upstream: null, ahead: 0, behind: 0, files: [] }
15 const tokens = stdout.split(NUL)
16 for (let i = 0; i < tokens.length; i += 1) {
17 const token = tokens[i] ?? ''
18 if (token.startsWith('# branch.head ')) status.branch = clean(token.slice('# branch.head '.length))
19 else if (token.startsWith('# branch.upstream ')) status.upstream = clean(token.slice('# branch.upstream '.length))
20 else if (token.startsWith('# branch.ab ')) {
21 const [a = '+0', b = '-0'] = token.slice('# branch.ab '.length).split(' ')
22 status.ahead = Number(a.slice(1)) || 0
23 status.behind = Number(b.slice(1)) || 0
24 } else if (token.startsWith('1 ') || token.startsWith('2 ')) {
25 // 1 XY sub mH mI mW hH hI path;2 多一個欄位(相似度),原路徑是下一個 NUL 片段
26 const parts = token.split(' ')
27 const xy = parts[1] ?? '..'
28 const path = parts.slice(token.startsWith('1 ') ? 8 : 9).join(' ')
29 status.files.push({ path: clean(path), staged: xy[0] ?? '.', unstaged: xy[1] ?? '.', kind: 'changed' })
30 if (token.startsWith('2 ')) i += 1
31 } else if (token.startsWith('u ')) {
32 const parts = token.split(' ')
33 status.files.push({ path: clean(parts.slice(10).join(' ')), staged: 'U', unstaged: 'U', kind: 'conflict' })
34 } else if (token.startsWith('? ')) {
35 status.files.push({ path: clean(token.slice(2)), staged: '.', unstaged: '?', kind: 'untracked' })
36 }
37 }
38 return status
39}
40
41// git diff --numstat -z:一般檔案「增 TAB 刪 TAB 路徑」;改名時路徑留空,接著是原路徑與新路徑兩個片段
42export const parseNumstat = (stdout: string): NumStat => {
43 const result: NumStat = {}
44 const tokens = stdout.split(NUL)
45 for (let i = 0; i < tokens.length; i += 1) {
46 const token = tokens[i] ?? ''
47 if (token === '') continue
48 const [a = '', r = '', path = ''] = token.split(TAB)
49 // 二進位檔的增刪是「-」
50 const counts = { added: Number(a) || 0, removed: Number(r) || 0 }
51 if (path === '') {
52 const renamed = tokens[i + 2] ?? ''
53 result[clean(renamed)] = counts
54 i += 2
55 } else {
56 result[clean(path)] = counts
57 }
58 }
59 return result
60}
61
62// git log --format=%h<FIELD>%s<FIELD>%an<FIELD>%ct<RECORD>
63export const LOG_FORMAT = '%h%x1f%s%x1f%an%x1f%ct%x1e'
64
65export const parseLog = (stdout: string): GitCommit[] =>
66 stdout
67 .split(RECORD)
68 .map(r => r.trim())
69 .filter(r => r !== '')
70 .map(r => {
71 const [hash = '', subject = '', author = '', time = '0'] = r.split(FIELD)
72 return { hash: clean(hash), subject: clean(subject), author: clean(author), time: Number(time) * 1000 }
73 })
74
75// 狀態字母的中文說明與顏色
76export const STATUS_NAMES: Record<string, string> = { M: '修改', A: '新增', D: '刪除', R: '改名', C: '複製', T: '類型', U: '衝突', '?': '未追蹤' }
77
78export const letterOf = (file: GitFile): string =>
79 file.kind === 'untracked' ? '?' : file.kind === 'conflict' ? 'U' : file.staged !== '.' ? file.staged : file.unstaged
80hooks/layout.ts 40 lines1// 與 session-stats 的 layout.ts 相同
2// 三個區塊平均分配 pane 的高度;除不盡的列數給最後一區。
3export const MIN_SECTION_ROWS = 4
4
5export const splitRows = (total: number, count: number): number[] => {
6 const each = Math.max(MIN_SECTION_ROWS, Math.floor(total / count))
7 const heights = Array.from({ length: count }, () => each)
8 const rest = total - each * count
9 if (rest > 0) heights[count - 1] = each + rest
10 return heights
11}
12
13// 區塊內可放的項目數:扣掉上下框線 2 列、標題 1 列,以及固定列。
14// 放不下時留一列給「… 還有 N 項」。
15export const fit = <T>(items: readonly T[], room: number): { shown: T[]; more: number } => {
16 if (items.length <= room) return { shown: [...items], more: 0 }
17 const shown = items.slice(0, Math.max(0, room - 1))
18 return { shown, more: items.length - shown.length }
19}
20
21export const SECTION_CHROME = 3
22
23// 依優先序挑出放得下的列,畫面上仍維持原本的順序
24export const pick = <T>(rows: readonly { item: T; priority: number }[], room: number): T[] => {
25 const keep = new Set(
26 rows
27 .map((row, index) => ({ index, priority: row.priority }))
28 .sort((a, b) => b.priority - a.priority || a.index - b.index)
29 .slice(0, Math.max(0, room))
30 .map(row => row.index),
31 )
32 return rows.filter((_, index) => keep.has(index)).map(row => row.item)
33}
34
35// 區塊內上下兩段分配列數:上段最多拿 share 比例,但要留給下段至少 lowerMin 列
36export const share = (room: number, upperWant: number, share: number, lowerMin: number): { upper: number; lower: number } => {
37 const upper = Math.max(1, Math.min(upperWant, Math.floor(room * share), room - lowerMin))
38 return { upper, lower: Math.max(0, room - upper) }
39}
40hooks/parse.ts 77 lines1import type { TestRun } from '../types'
2
3// 從測試指令的輸出抓摘要列。各框架格式不同,依序嘗試,第一個對上的就用。
4// regex 裡不用 \/ 與 \(mod 載入器處理反斜線有問題),需要時用字元類別 [|] [(] 代替。
5
6const TEST_COMMAND =
7 /\b(dotnet\s+test|(npm|pnpm|yarn|bun)(\s+run)?\s+test|npx\s+(jest|vitest)|jest|vitest|pytest|python3?\s+-m\s+pytest|cargo\s+test)\b/
8
9export const isTestCommand = (command: string): boolean => TEST_COMMAND.test(command)
10
11const num = (value: string | undefined): number => Number(value ?? 0) || 0
12
13const run = (runner: string, passed: number, failed: number, total: number): TestRun => ({
14 runner,
15 passed,
16 failed,
17 total,
18 isOk: failed === 0,
19})
20
21type Parser = (output: string) => TestRun | null
22
23// Passed! - Failed: 0, Passed: 153, Skipped: 0, Total: 153
24const dotnet: Parser = output => {
25 const m = /Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*\d+,\s*Total:\s*(\d+)/.exec(output)
26 return m === null ? null : run('dotnet', num(m[2]), num(m[1]), num(m[3]))
27}
28
29// Tests: 1 failed, 2 passed, 3 total
30const jest: Parser = output => {
31 const line = output.split('\n').find(l => /^\s*Tests:\s+.*\d+ total/.test(l))
32 if (line === undefined) return null
33 return run('jest', num(/(\d+) passed/.exec(line)?.[1]), num(/(\d+) failed/.exec(line)?.[1]), num(/(\d+) total/.exec(line)?.[1]))
34}
35
36// Tests 1 failed | 2 passed (3)
37const vitest: Parser = output => {
38 const line = output.split('\n').find(l => /^\s*Tests\s+.*[(]\d+[)]/.test(l))
39 if (line === undefined) return null
40 return run('vitest', num(/(\d+) passed/.exec(line)?.[1]), num(/(\d+) failed/.exec(line)?.[1]), num(/[(](\d+)[)]/.exec(line)?.[1]))
41}
42
43// ==== 2 failed, 5 passed in 0.12s ====
44const pytest: Parser = output => {
45 const line = output.split('\n').find(l => /\d+ (passed|failed)/.test(l) && / in [\d.]+s/.test(l))
46 if (line === undefined) return null
47 const passed = num(/(\d+) passed/.exec(line)?.[1])
48 const failed = num(/(\d+) failed/.exec(line)?.[1]) + num(/(\d+) error/.exec(line)?.[1])
49 return run('pytest', passed, failed, passed + failed)
50}
51
52// test result: ok. 10 passed; 0 failed;(多個 crate 時每個一列,加總)
53const cargo: Parser = output => {
54 const lines = output.split('\n').filter(l => /test result: /.test(l))
55 if (lines.length === 0) return null
56 const passed = lines.reduce((n, l) => n + num(/(\d+) passed/.exec(l)?.[1]), 0)
57 const failed = lines.reduce((n, l) => n + num(/(\d+) failed/.exec(l)?.[1]), 0)
58 return run('cargo', passed, failed, passed + failed)
59}
60
61const PARSERS: readonly Parser[] = [dotnet, jest, vitest, cargo, pytest]
62
63export const parseTestSummary = (output: string): TestRun | null => {
64 for (const parse of PARSERS) {
65 const result = parse(output)
66 if (result !== null) return result
67 }
68 return null
69}
70
71export const formatStatus = (result: TestRun | null): string | undefined => {
72 if (result === null) return undefined
73 return result.isOk
74 ? `測試 ✓ ${result.passed}/${result.total} · ${result.runner}`
75 : `測試 ✗ ${result.failed} 個失敗(${result.passed}/${result.total})· ${result.runner}`
76}
77hooks/project.ts 84 lines1import type { CheckRow, CheckSpec, FileHeat } from '../types'
2import { clean } from './text'
3
4// 任何專案通用的「專案狀態」:專案類型、關鍵檔案,以及專案自己設定的檢查清單。
5// 專案特有的檢查寫在 <專案>/.claude/project-activity.json,mod 本身不寫死任何專案。
6
7export const CONFIG_PATH = '.claude/project-activity.json'
8
9export type RootEntry = { name: string; kind: 'file' | 'dir' | 'other' }
10
11// 依根目錄的標記檔判斷專案類型;一個專案可以同時是多種
12const MARKERS: readonly { type: string; match: (name: string) => boolean }[] = [
13 { type: '.NET', match: n => /[.](sln|slnf|csproj|fsproj|vbproj)$/i.test(n) || n === 'Directory.Build.props' },
14 { type: 'Node.js', match: n => n === 'package.json' },
15 { type: 'Python', match: n => n === 'pyproject.toml' || n === 'requirements.txt' || n === 'setup.py' },
16 { type: 'Go', match: n => n === 'go.mod' },
17 { type: 'Rust', match: n => n === 'Cargo.toml' },
18 { type: 'Java', match: n => n === 'pom.xml' || /^build[.]gradle/.test(n) },
19]
20
21export const detectTypes = (entries: readonly RootEntry[]): string[] =>
22 MARKERS.filter(m => entries.some(e => e.kind === 'file' && m.match(e.name))).map(m => m.type)
23
24const KEY_FILES: readonly { label: string; match: (name: string) => boolean }[] = [
25 { label: 'README', match: n => /^readme([.]|$)/i.test(n) },
26 { label: 'CLAUDE.md', match: n => n === 'CLAUDE.md' },
27 { label: '.gitignore', match: n => n === '.gitignore' },
28 { label: 'LICENSE', match: n => /^licen[cs]e([.]|$)/i.test(n) },
29]
30
31export const keyFiles = (entries: readonly RootEntry[]): { label: string; found: boolean }[] =>
32 KEY_FILES.map(k => ({ label: k.label, found: entries.some(e => e.kind === 'file' && k.match(e.name)) }))
33
34// 設定檔格式:{ "checks": [{ "path": "Data/名單.xlsx", "required": true, "dir": false, "note": "..." }] }
35// 格式不對時丟出可讀的錯誤,讓 pane 直接顯示原因
36export const parseConfig = (text: string): CheckSpec[] => {
37 const raw: unknown = JSON.parse(text)
38 const checks = (raw as { checks?: unknown } | null)?.checks
39 if (!Array.isArray(checks)) throw new Error('缺少 "checks" 陣列')
40 return checks.map((c, i) => {
41 const item = c as Partial<CheckSpec> | null
42 if (typeof item?.path !== 'string' || item.path === '') throw new Error(`checks[${i}] 缺少 "path"`)
43 return { path: clean(item.path), required: item.required === true, dir: item.dir === true, note: typeof item.note === 'string' ? clean(item.note) : null }
44 })
45}
46
47export const sizeOf = (bytes: number): string =>
48 bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)}MB` : bytes >= 1024 ? `${Math.round(bytes / 1024)}KB` : `${bytes}B`
49
50export const ago = (mtimeMs: number, now: number): string => {
51 const minutes = Math.max(0, Math.round((now - mtimeMs) / 60000))
52 if (minutes < 1) return '剛剛'
53 if (minutes < 60) return `${minutes} 分鐘前`
54 if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} 小時前`
55 return `${Math.floor(minutes / 60 / 24)} 天前`
56}
57
58// found:檢查時實際看到的東西;undefined 代表不存在
59export type Found = { kind: 'file' | 'dir' | 'other'; size: number; mtimeMs: number; count: number }
60
61export const describeCheck = (spec: CheckSpec, found: Found | undefined, now: number): CheckRow => {
62 const name = spec.path
63 const isRightKind = found !== undefined && (spec.dir ? found.kind === 'dir' : found.kind === 'file')
64 if (found === undefined || !isRightKind) {
65 return spec.required
66 ? { name, state: 'missing-required', detail: spec.note ?? '缺少(必要)' }
67 : { name, state: 'missing-optional', detail: spec.note ?? '尚未產生' }
68 }
69 const detail = spec.dir ? `${found.count} 份 · ${ago(found.mtimeMs, now)}` : `${sizeOf(found.size)} · ${ago(found.mtimeMs, now)}`
70 return { name, state: 'ok', detail }
71}
72
73// ── 熱門檔案 ──
74
75export const recordHeat = (list: FileHeat[], path: string, kind: 'read' | 'edit'): FileHeat[] => {
76 const found = list.find(f => f.path === path)
77 const base = found ?? { path, reads: 0, edits: 0 }
78 const next = { ...base, reads: base.reads + (kind === 'read' ? 1 : 0), edits: base.edits + (kind === 'edit' ? 1 : 0) }
79 return found === undefined ? [...list, next] : list.map(f => (f.path === path ? next : f))
80}
81
82export const hottest = (list: readonly FileHeat[]): FileHeat[] =>
83 [...list].sort((a, b) => b.reads + b.edits - (a.reads + a.edits) || b.reads - a.reads)
84hooks/text.ts 44 lines1// 終端機裡中文、全形符號與 emoji 占 2 格。截斷與對齊都要用顯示寬度算,
2// 用字元數算的話,中文內容實際寬度會是預估的兩倍,整列就換行跑版。
3
4const isWide = (cp: number): boolean =>
5 (cp >= 0x1100 && cp <= 0x115f) ||
6 (cp >= 0x2e80 && cp <= 0x303e) ||
7 (cp >= 0x3041 && cp <= 0x33ff) ||
8 (cp >= 0x3400 && cp <= 0x4dbf) ||
9 (cp >= 0x4e00 && cp <= 0x9fff) ||
10 (cp >= 0xa000 && cp <= 0xa4cf) ||
11 (cp >= 0xac00 && cp <= 0xd7a3) ||
12 (cp >= 0xf900 && cp <= 0xfaff) ||
13 (cp >= 0xfe30 && cp <= 0xfe4f) ||
14 (cp >= 0xff00 && cp <= 0xff60) ||
15 (cp >= 0xffe0 && cp <= 0xffe6) ||
16 (cp >= 0x1f300 && cp <= 0x1faff) ||
17 (cp >= 0x20000 && cp <= 0x3fffd)
18
19const charWidth = (ch: string): number => (isWide(ch.codePointAt(0) ?? 0) ? 2 : 1)
20
21export const widthOf = (text: string): number => [...text].reduce((n, ch) => n + charWidth(ch), 0)
22
23export const clip = (text: string, width: number): string => {
24 if (widthOf(text) <= width) return text
25 let out = ''
26 let used = 0
27 for (const ch of text) {
28 const w = charWidth(ch)
29 if (used + w > width - 1) break
30 out += ch
31 used += w
32 }
33 return `${out}…`
34}
35
36// 移除控制字元(C0、DEL、C1,含 ESC)。commit 訊息、檔名、工具輸出都可能藏終端機跳脫序列,
37// 顯示前一律清掉,避免被當成指令改寫畫面或標題。字元以 fromCharCode 組成(mod 載入器處理反斜線有問題)。
38const CONTROL = new RegExp(
39 `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
40 'g',
41)
42
43export const clean = (text: string): string => text.replace(CONTROL, '')
44hooks/ui.tsx 113 lines1import type { Elements, RenderChildren } from 'claude-code'
2
3// 與 session-stats 的 ui.tsx 相同;兩個 mod 是各自獨立的 plugin,不能互相 import
4
5export const barOf = (percent: number, width: number): { full: string; rest: string } => {
6 const w = Math.max(0, width)
7 const filled = Math.max(0, Math.min(w, Math.round((percent / 100) * w)))
8 return { full: '━'.repeat(filled), rest: '─'.repeat(w - filled) }
9}
10
11export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
12
13export const LABEL_WIDTH = 9
14
15// 每一列都包在高度 1、超出裁掉的容器裡:內容再長也只會被截掉,不會換行把區塊撐歪。
16// 區塊的列數預算因此可以照「一列一行」精確計算。
17export const line = (kit: Kit, children: RenderChildren): RenderChildren => {
18 const { Box } = kit
19 return (
20 <Box height={1} flexShrink={0} overflow="hidden">
21 {children}
22 </Box>
23 )
24}
25
26// 帶色框的區塊:固定高度,框線 2 列 + 標題 1 列,其餘給 rows
27export const section = (
28 kit: Kit,
29 props: { color: string; title: string; height: number; right?: RenderChildren },
30 rows: RenderChildren[],
31): RenderChildren => {
32 const { Box, Text } = kit
33 return (
34 <Box flexDirection="column" height={props.height} flexShrink={0} borderStyle="round" borderColor={props.color} paddingX={1} overflow="hidden">
35 {line(
36 kit,
37 <Box flexGrow={1} justifyContent="space-between">
38 <Text bold color={props.color} wrap="truncate-end">{props.title}</Text>
39 {props.right ?? null}
40 </Box>,
41 )}
42 {rows}
43 </Box>
44 )
45}
46
47// 區塊內的分隔線:「─ 標題 ─────── 右側」
48export const rule = (kit: Kit, title: string, color: string, right?: RenderChildren): RenderChildren => {
49 const { Box, Text } = kit
50 return line(
51 kit,
52 <Box flexGrow={1}>
53 <Text color={color}>{`─ ${title} `}</Text>
54 <Box flexGrow={1} overflow="hidden">
55 <Text dimColor wrap="truncate">{'─'.repeat(200)}</Text>
56 </Box>
57 {right === undefined ? null : <Text> </Text>}
58 {right ?? null}
59 </Box>,
60 )
61}
62
63// 「標籤 ━━━━──── 數值」,標籤欄固定寬度讓數條對齊
64export const gauge = (kit: Kit, label: string, percent: number, barWidth: number, value: string, color: string): RenderChildren => {
65 const { Box, Text } = kit
66 const bar = barOf(percent, barWidth)
67 return line(
68 kit,
69 <Box>
70 <Box width={LABEL_WIDTH} flexShrink={0}><Text dimColor>{label}</Text></Box>
71 <Text color={color}>{bar.full}</Text>
72 <Text dimColor>{bar.rest}</Text>
73 <Text wrap="truncate-end">{` ${value}`}</Text>
74 </Box>,
75 )
76}
77
78// 「標籤 內容」,標籤欄與 gauge 對齊
79export const labeled = (kit: Kit, label: string, children: RenderChildren): RenderChildren => {
80 const { Box, Text } = kit
81 return line(
82 kit,
83 <Box>
84 <Box width={LABEL_WIDTH} flexShrink={0}><Text dimColor>{label}</Text></Box>
85 <Box flexGrow={1} overflow="hidden">{children}</Box>
86 </Box>,
87 )
88}
89
90export const text = (kit: Kit, value: string, props: { color?: string; dim?: boolean; bold?: boolean; italic?: boolean } = {}): RenderChildren => {
91 const { Text } = kit
92 return line(
93 kit,
94 <Text color={props.color} dimColor={props.dim} bold={props.bold} italic={props.italic} wrap="truncate-end">
95 {value}
96 </Text>,
97 )
98}
99
100export const more = (kit: Kit, count: number, noun: string): RenderChildren[] =>
101 count > 0 ? [text(kit, ` … 還有 ${count} ${noun}`, { dim: true })] : []
102
103export const diff = (kit: Kit, added: number, removed: number): RenderChildren => {
104 const { Text } = kit
105 return (
106 <Text>
107 <Text color="success">{`+${added}`}</Text>
108 <Text> </Text>
109 <Text color="error">{`−${removed}`}</Text>
110 </Text>
111 )
112}
113types/index.d.ts 58 lines1export type TestRun = { runner: string; passed: number; failed: number; total: number; isOk: boolean }
2export type ToolCall = { id: string; tool: string; label: string; isDone: boolean; isError: boolean }
3// seconds 為 null 代表回合還在進行中
4export type Turn = { seconds: number | null; tools: number; edits: number }
5
6export type FileHeat = { path: string; reads: number; edits: number }
7
8export type CheckSpec = { path: string; required: boolean; dir: boolean; note: string | null }
9export type CheckRow = { name: string; state: 'ok' | 'missing-required' | 'missing-optional'; detail: string }
10
11// checks 為 null 代表專案沒有設定檔;configError 是設定檔讀取或格式錯誤的原因
12export type ProjectStatus = {
13 name: string
14 types: string[]
15 keyFiles: { label: string; found: boolean }[]
16 checks: CheckRow[] | null
17 configError: string | null
18}
19
20export type GitFile = {
21 path: string
22 // 暫存區與工作區各自的狀態字母,例如 M、A、D、R;'.' 代表沒變
23 staged: string
24 unstaged: string
25 kind: 'changed' | 'untracked' | 'conflict'
26}
27export type GitStatus = { branch: string; upstream: string | null; ahead: number; behind: number; files: GitFile[] }
28export type NumStat = Record<string, { added: number; removed: number }>
29export type GitCommit = { hash: string; subject: string; author: string; time: number }
30
31// isRepo 為 false 代表不是 git repository;error 是 git 指令失敗的原因
32export type GitView = {
33 isRepo: boolean
34 status: GitStatus | null
35 staged: NumStat
36 unstaged: NumStat
37 commits: GitCommit[]
38 stashes: number
39 error: string | null
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'project-activity': {
45 lastTest: TestRun | null
46 lastTestAt: number | null
47 calls: ToolCall[]
48 edited: string[]
49 turn: Turn | null
50 isBandHidden: boolean
51 heat: FileHeat[]
52 project: ProjectStatus | null
53 isGitOpen: boolean
54 git: GitView | null
55 }
56 }
57}
58