SLOPSHOPPER

project-activity

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

newpanebandguardcommandstatus
v0.3.4no licenseupdated 2026-10-07hn83320589/claude-mods/project-activity
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-activity
│ ┃ 專案活動 ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ 專案狀態 ⏺ Read(src/auth.ts) │ ┃ │ app · Node.js ⎿ Read 6 lines │ ┃ │ 測試 尚未在這個 session 跑過測試 ⏺ Update(src/auth.ts) │ ┃ │ ✓ README ○ CLAUDE.md ○ .gitignore ○ … ⎿ Added 2 lines, removed 1 line │ ┃ │ ─ 專案檢查 ─────────────────────────────── ⏺ Bash(bun test) │ ┃ │ 可在 .claude/project-activity.json 設定… ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ◆ 熱門檔案 共 3 檔 │ │ ┃ │ auth.ts ━━━━━━━━━━━━━ 讀 1 改 1 │ › /project-activity │ ┃ │ audit.ts ━━━━━━━────── 讀 0 改 1 │ ⎿ project-activity: 已開啟「專案活動」pane。 │ ┃ │ cache.ts ━━━━━━━────── 讀 0 改 1 │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ 最近活動 修改 3 檔 · 工具 9 次 │ ┃ │ ✎ src/cache.ts │ ┃ │ … 還有 2 檔 │ ┃ │ ─ 工具呼叫 ─────────────────────────────── 上一回合 42s · 9 次工具呼叫 · 修改 3 個檔案[ 隱藏 ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ project-activity: 測試 ✗ 建置或執行失敗

Draws

Band
上一回合 42s · 9 次工具呼叫 · 修改 3 個檔案[ 隱藏 ]
Pane · 專案活動
╭─────────────────────────────────────────────────────────── │ ◆ 專案狀態 │ app · Node.js │ 測試 尚未在這個 session 跑過測試 │ ✓ README ○ CLAUDE.md ○ .gitignore ○ LICENSE │ ─ 專案檢查 ─────────────────────────────────────────────── │ 可在 .claude/project-activity.json 設定要檢查的檔案 │ │ ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ ◆ 熱門檔案 共 3 檔 │ │ auth.ts ━━━━━━━━━━━━━ 讀 1 改 1 │ │ audit.ts ━━━━━━━────── 讀 0 改 1 │ │ cache.ts ━━━━━━━────── 讀 0 改 1 │ │ │ │ │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ ◆ 最近活動 修改 3 檔 · 工具 9 次 │ ✎ src/cache.ts │ … 還有 2 檔 │ ─ 工具呼叫 ─────────────────────────────────────────────── │ ✗ Bash bun test │ ✓ Bash git status --porcelain │ ✓ Bash rm -rf build && git push --force origin main │ ✓ Bash cat .env ╰───────────────────────────────────────────────────────────
Pane · git-status
╭──────────────────────────────────────────────────────────╮ │ ◆ Git 狀態 [ 重新整理 ] [ 關閉 ] │ │ 這個資料夾不是 git repository │ │ 執行 git init 開始版本控制後,這裡會顯示分支與變更 │ ╰──────────────────────────────────────────────────────────╯
README

claude-mods

自用的 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

Context 自動處理(session-stats)

  • 門檻 80%:每個回合結束時檢查 context 用量,達到 80% 就在回合之間先壓縮,不等引擎在工具執行到一半時才自動壓縮;畫面會顯示提示。
  • 保留重點:每次壓縮(自動、/compact、預先壓縮)都會要求摘要保留目前任務與進度、已做的決定與原因、改過的檔案、使用者的偏好與規則、未解決的問題。
  • 摘要存檔:壓縮產生的摘要存到專案的 .claude/session-notes/YYYY-MM-DD-HHmm.md。資料夾內有自己的 .gitignore(忽略全部),不會被提交,也不必修改專案的 .gitignore。
  • 只存摘要,不存原樣保留的訊息;常見的密鑰與密碼(API key、GitHub/AWS/Slack token、JWT、私鑰、password= 等)存檔前會遮蔽
  • .claude、session-notes 或要寫入的檔案(含 .gitignore)是符號連結、實際位置不在專案內,或狀態無法確認(例如權限不足)時都不存檔,避免寫到專案外

專案設定(選用)

project-activity 可以在專案的 .claude/project-activity.json 列出要檢查的檔案(例如必要的資料檔),沒有這個檔時 pane 會提示可以設定。

Source 8 files
hooks/register.tsx 633 lines
1import { 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}
633
hooks/git.ts 80 lines
1import 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
80
hooks/layout.ts 40 lines
1// 與 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}
40
hooks/parse.ts 77 lines
1import 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}
77
hooks/project.ts 84 lines
1import 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)
84
hooks/text.ts 44 lines
1// 終端機裡中文、全形符號與 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, '')
44
hooks/ui.tsx 113 lines
1import 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}
113
types/index.d.ts 58 lines
1export 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