SLOPSHOPPER

session-stats

工作階段狀態 pane:用量與預報(context、rate limit、compact 預估,可手動重新整理)、任務與執行紀錄(回放)、上下文與改動範圍;context 達 80% 時在回合之間先壓縮,壓縮摘要存到專案 .claude/session-notes/

newpaneguardcommandtoastprompt
v0.3.2no licenseupdated 2026-10-07hn83320589/claude-mods/session-stats
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-stats
│ ┃ session-stats ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ 用量與預報claude-opus-5-5 [ 重新整理 ] [ ⏺ Read(src/auth.ts) │ ┃ │ Session 30m00s · 1 回合 · 30m00s/回合 · $ ⎿ Read 6 lines │ ┃ │ Context ━━━━━━━━━━━━━━─────────────── 49% ⏺ Update(src/auth.ts) │ ┃ │ 5 小時 ━━━━━━━━━──────────────────── 31% ⎿ Added 2 lines, removed 1 line │ ┃ │ Cache ━━━━━━━━━━━━━━━━━━━━━━━━━━━── 93% ⏺ Bash(bun test) │ ┃ │ ─ 預報 ─────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ 每回合 — ▄ │ ┃ │ Compact 累積兩回合以上才能預估 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ◆ 任務與執行紀錄 │ ┃ │ 目前沒有執行中的工作 › /session-stats │ ┃ │ ─ 執行紀錄 ─────────────────────────────── ⎿ session-stats: 已關閉「工作階段狀態」pane。 │ ┃ │ Bash 4✗1 Write 2 Edit 1 Grep 1 Read… │ ┃ │ 回放 1/1 [ ◀ ] [ ▶ ] 步驟 [ ▲ ] [ ▼ ] │ ┃ │ 「fix the failing auth test and add an … │ ┃ │ ▸ 9 ✓ Bash Show environment file 20ms │ ┃ │ └ DATABASE_URL=postgres://app:hunter2@d… │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ 上下文與改動 │ ┃ │ 回合結束後顯示 │ ┃ │ ─ 改動範圍 ─────────────────────────────── │ ┃ │ ⚠ 有改程式碼但沒動測試 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-stats
╭─────────────────────────────────────────────────────────── │ ◆ 用量與預報 claude-opus-5-5 [ 重新整理 ] [ 關閉 ] │ Session 30m00s · 1 回合 · 30m00s/回合 · $0.42 │ Context ━━━━━━━━━━━━━━─────────────── 49% │ 5 小時 ━━━━━━━━━──────────────────── 31% ↻ │ Cache ━━━━━━━━━━━━━━━━━━━━━━━━━━━── 93% 本回93% │ ─ 預報 ─────────────────────────────────────────────────── │ 每回合 — ▄ │ Compact 累積兩回合以上才能預估 ╰─────────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────── │ ◆ 任務與執行紀錄 │ 目前沒有執行中的工作 │ ─ 執行紀錄 ─────────────────────────────────────────────── │ Bash 4✗1 Write 2 Edit 1 Grep 1 Read 1 │ 回放 1/1 [ ◀ ] [ ▶ ] 步驟 [ ▲ ] [ ▼ ] │ 「fix the failing auth test and add an audit log ca… │ ▸ 9 ✓ Bash Show environment file 20ms │ └ DATABASE_URL=postgres://app:hunter2@db.internal:5… ╰─────────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────── │ ◆ 上下文與改動 │ 回合結束後顯示 │ ─ 改動範圍 ─────────────────────────────────────────────── │ ⚠ 有改程式碼但沒動測試 │ src ────────────── 3 檔 +0 −0 │ auth.ts +0 −0 │ audit.ts +0 −0 │ cache.ts +0 −0 ╰───────────────────────────────────────────────────────────
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 12 files
hooks/register.tsx 672 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionMessage, Timer } from 'claude-code'
3
4import type { ContextBreakdown, ReplayCursor, Running, Task, Usage } from '../types'
5import { countPatch, groupEdits, isUntested, recordEdit } from './blast'
6import { ACCENT, barOf, compact, levelColor, limitName, resetIn } from './format'
7import { fit, pick, SECTION_CHROME, share, splitRows } from './layout'
8import { COMPACT_INSTRUCTIONS, COMPACT_THRESHOLD, isInside, NOTES_DIR, NOTES_GITIGNORE, notesFileName, notesMarkdown } from './notes'
9import { addStep, firstLine, resolveCursor, startTurn, windowAround } from './replay'
10import { duration, ratio, recordCache, recordTool, statusName, summarizeTools, visibleAgents } from './stats'
11import { applyTaskTool, runningLabel } from './tasks'
12import { clip } from './text'
13import { diff, gauge, LABEL_WIDTH, labeled, line, more, rule, section, text } from './ui'
14import { burnRate, projectLimit, sparkline, turnsUntil, weatherOf } from './weather'
15
16const PANE = 'session-stats'
17const TITLE = '工作階段狀態'
18const TASK_TOOLS = ['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList']
19const EDIT_TOOLS = ['Edit', 'Write']
20const HISTORY_SIZE = 20
21const TICK_MS = 15_000
22const TASK_ORDER: Record<Task['status'], number> = { in_progress: 0, pending: 1, completed: 2 }
23
24const isOpen = atom({ plugin: 'session-stats', key: 'isOpen' } as const, false)
25const toolCalls = atom({ plugin: 'session-stats', key: 'toolCalls' } as const, 0)
26const usage = atom({ plugin: 'session-stats', key: 'usage' } as const, null)
27const tasks = atom({ plugin: 'session-stats', key: 'tasks' } as const, [])
28const running = atom({ plugin: 'session-stats', key: 'running' } as const, [])
29const context = atom({ plugin: 'session-stats', key: 'context' } as const, null)
30const history = atom({ plugin: 'session-stats', key: 'history' } as const, [])
31const edits = atom({ plugin: 'session-stats', key: 'edits' } as const, [])
32const root = atom({ plugin: 'session-stats', key: 'root' } as const, '')
33const replay = atom({ plugin: 'session-stats', key: 'replay' } as const, [])
34const cursor = atom({ plugin: 'session-stats', key: 'cursor' } as const, null)
35const toolStats = atom({ plugin: 'session-stats', key: 'toolStats' } as const, [])
36const cache = atom({ plugin: 'session-stats', key: 'cache' } as const, { read: 0, creation: 0, input: 0, last: null })
37const agents = atom({ plugin: 'session-stats', key: 'agents' } as const, [])
38const tick = atom({ plugin: 'session-stats', key: 'tick' } as const, 0)
39
40// ── 資料更新 ──────────────────────────────────────────────
41
42// pane 關著的時候不去讀用量,省掉沒人看的工作。
43// breakdown 用 'summary':本地估算,不會多送 token-count 請求。
44const refresh = async ($: EngineInterface, withBreakdown: boolean): Promise<void> => {
45  if (!(await read($, isOpen))) return
46  const now = await $.session.usage(withBreakdown ? { breakdown: 'summary' } : undefined)
47  const next: Usage = {
48    model: await $.session.model(),
49    startedAt: now.startedAt,
50    turns: await $.session.turns(),
51    percent: now.context.percent ?? null,
52    tokens: now.context.tokens ?? null,
53    window: now.context.window,
54    costUsd: now.cost?.usd ?? null,
55    rateLimits: now.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt ?? null })),
56  }
57  await update($, usage, () => next)
58
59  const list = await $.agent.list()
60  await update($, agents, () => list.map(a => ({ id: a.id, type: a.type, description: a.description, status: a.status })))
61
62  const b = now.context.breakdown
63  if (b !== undefined) {
64    // 只列真正占用的內容;剩餘空間(free)與 auto-compact 保留區(buffer)不算
65    const rows = b.categories
66      .filter(c => c.kind === 'used' && c.tokens > 0)
67      .map(c => ({ name: c.name, tokens: c.tokens }))
68      .sort((x, y) => y.tokens - x.tokens)
69    const buffer = b.categories.filter(c => c.kind === 'buffer').reduce((n, c) => n + c.tokens, 0)
70    const breakdown: ContextBreakdown = {
71      rows,
72      totalTokens: b.totalTokens,
73      maxTokens: b.rawMaxTokens,
74      percentage: b.percentage,
75      compactAt: buffer > 0 ? b.rawMaxTokens - buffer : null,
76    }
77    await update($, context, () => breakdown)
78  }
79}
80
81// Session 計時與 subagent 狀態要隨時間更新;只在 pane 開著時跑。
82// 模組重新載入時這個變數會歸零,舊的計時器由 engine 隨舊模組一起清掉。
83let timer: Timer | null = null
84
85// 計時器在背景跑,沒有人 await 它:錯誤寫進 debug log,不讓它變成沒人處理的 rejection
86const onTick = async ($: EngineInterface): Promise<void> => {
87  try {
88    await refresh($, false)
89    await update($, tick, n => n + 1)
90  } catch (err) {
91    $.ui.log(`計時更新失敗:${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
92  }
93}
94
95// 手動重新整理:讀取失敗或停在「讀取中」時用。開關可能被重置(例如 /clear 之後),一併設回並恢復計時更新。
96const refreshNow = async ($: EngineInterface): Promise<void> => {
97  await update($, isOpen, () => true)
98  await refresh($, true)
99  timer ??= $.clock.every(TICK_MS, () => void onTick($))
100}
101
102const open = async ($: EngineInterface): Promise<void> => {
103  await update($, isOpen, () => true)
104  await refresh($, true)
105  await $.ui.open({ id: PANE, title: TITLE })
106  timer ??= $.clock.every(TICK_MS, () => void onTick($))
107}
108
109// /clear 或新的工作階段不會再觸發 session.start,狀態卻會重置:pane 還開著,isOpen 與資料都回到初始值,
110// 之後的更新全被當成「pane 關著」而跳過。繪製時發現這種情形,就安排一次開啟流程補回來。
111// 繪製期間不能寫狀態,所以交給 clock.after 在繪製之後執行;失敗時隔一段時間才再試,避免每次重繪都重試。
112const HEAL_RETRY_MS = 5_000
113let healing = false
114let lastHealAt = Number.NEGATIVE_INFINITY
115
116const scheduleHeal = async ($: EngineInterface): Promise<void> => {
117  const now = await $.clock.now()
118  if (healing || now - lastHealAt < HEAL_RETRY_MS) return
119  healing = true
120  lastHealAt = now
121  $.clock.after(0, () => {
122    void (async () => {
123      try {
124        await update($, isOpen, () => true)
125        await refresh($, true)
126        timer ??= $.clock.every(TICK_MS, () => void onTick($))
127      } catch (err) {
128        $.ui.log(`pane 資料補讀失敗:${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
129      } finally {
130        healing = false
131      }
132    })()
133  })
134}
135
136// ── Context 自動處理 ──────────────────────────────────────
137
138const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
139const slashed = (path: string): string => path.replaceAll(String.fromCharCode(92), '/')
140
141// 預先壓縮進行中時不重複觸發(模組重新載入時歸零,最多多觸發一次,引擎會擋下重疊的壓縮)
142let compacting = false
143
144/** fs.stat 的失敗只有「不存在」可以放行;權限不足等其他錯誤一律當作不安全。 */
145const isMissing = (err: unknown): boolean => {
146  const code = typeof err === 'object' && err !== null && 'code' in err ? String(err.code) : ''
147  return code === 'ENOENT' || errorText(err).includes('ENOENT')
148}
149
150/**
151 * 確認要寫的路徑在專案之內:別人的 repo 可以把 .claude、session-notes 或其中的檔案做成符號連結
152 * (目標不存在也一樣會被寫入),指向專案外(例如開機自動執行的資料夾)。每一層已存在的路徑都必須
153 * 是指定種類、不是連結,且實際位置在專案根目錄之內;不存在的放行,由 fs.write 在專案內建立。
154 * 回傳路徑是否已存在;不安全或無法確認時丟出錯誤,不存檔。
155 */
156const assertInside = async ($: EngineInterface, path: string, kind: 'dir' | 'file', rootReal: string): Promise<boolean> => {
157  let stat: Awaited<ReturnType<EngineInterface['fs']['stat']>>
158  try {
159    stat = await $.fs.stat(path, { resolve: true })
160  } catch (err) {
161    if (isMissing(err)) return false
162    throw new Error(`無法確認 ${path} 的狀態(${errorText(err)}),略過存檔`)
163  }
164  if (stat.isLink || stat.kind !== kind || stat.realPath === undefined || !isInside(stat.realPath, rootReal)) {
165    throw new Error(`${path} 不是專案內的一般${kind === 'dir' ? '資料夾' : '檔案'}(可能是符號連結),略過存檔`)
166  }
167  return true
168}
169
170/** 專案根目錄的實際位置;解析不出來就不存檔。 */
171const realRoot = async ($: EngineInterface, root: string): Promise<string> => {
172  const real = (await $.fs.stat(root, { resolve: true })).realPath
173  if (real === undefined) throw new Error(`無法解析專案根目錄 ${root} 的實際位置,略過存檔`)
174  return real
175}
176
177/** 把壓縮產生的摘要存到專案的 .claude/session-notes/,資料夾自帶 .gitignore 不會被提交。 */
178const saveNotes = async (
179  $: EngineInterface,
180  messages: readonly SessionMessage[],
181  meta: { trigger: string; percent: number | null },
182): Promise<string> => {
183  const root = slashed(await $.session.root())
184  const rootReal = await realRoot($, root)
185  const dir = `${root}/${NOTES_DIR}`
186  const dirExists = (await assertInside($, `${root}/.claude`, 'dir', rootReal)) && (await assertInside($, dir, 'dir', rootReal))
187  const ignore = `${dir}/.gitignore`
188  if (!(await assertInside($, ignore, 'file', rootReal))) await $.fs.write(ignore, NOTES_GITIGNORE)
189  const existing = dirExists ? (await $.fs.list(dir)).map(e => e.name) : []
190  const when = new Date(await $.clock.now())
191  const file = `${dir}/${notesFileName(when, existing)}`
192  // 檔名已避開清單裡的名稱;寫入前仍確認它不是連結
193  await assertInside($, file, 'file', rootReal)
194  await $.fs.write(file, notesMarkdown(messages, { when, ...meta }))
195  return file
196}
197
198/** 回合結束後 context 達到門檻就先壓縮:不等引擎在工具執行到一半時才自動壓縮。 */
199const compactIfNeeded = async ($: EngineInterface): Promise<void> => {
200  const percent = (await $.session.usage()).context.percent ?? null
201  if (percent === null || percent < COMPACT_THRESHOLD || compacting) return
202  compacting = true
203  // 回合進行中不能壓縮:排到這個事件處理完之後
204  $.clock.after(0, () => {
205    void (async () => {
206      try {
207        // 自己發起的壓縮不會經過自己的 session.compact hook,所以指示與存檔在這裡處理
208        const result = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
209        if (result.skip !== undefined) return
210        const file = await saveNotes($, result.messages, { trigger: 'plugin', percent })
211        $.ui.toast(`Context 已達 ${percent}%,已先壓縮;摘要存在 ${file.split('/').slice(-3).join('/')}`)
212      } catch (err) {
213        $.ui.log(`預先壓縮失敗:${errorText(err)}`, { to: 'debug' })
214      } finally {
215        compacting = false
216      }
217    })()
218  })
219}
220
221// 換回合時從第一步開始看;換步驟時停在同一回合
222const moveCursor = async ($: EngineInterface, dTurn: number, dStep: number): Promise<void> => {
223  const turns = await read($, replay)
224  const at = resolveCursor(turns, await read($, cursor))
225  if (at === null) return
226  const next: ReplayCursor = dTurn !== 0 ? { turn: at.turn + dTurn, step: 0 } : { turn: at.turn, step: at.step + dStep }
227  await update($, cursor, () => resolveCursor(turns, next))
228}
229
230// ── Hooks ─────────────────────────────────────────────────
231
232export const register: Register = on => {
233
234  on('session.start', async ($, e, next) => {
235    await $.command.register({ name: 'session-stats', description: `開啟或關閉「${TITLE}」pane` })
236    const ran = await next(e)
237    const dir = await $.session.root()
238    await update($, root, () => dir)
239    await open($)
240    return ran
241  })
242
243  on('command.run', { command: 'session-stats' }, async $ => {
244    if (await read($, isOpen)) {
245      await $.ui.close({ id: PANE })
246      return { text: `已關閉「${TITLE}」pane。` }
247    }
248    await open($)
249    return { text: `已開啟「${TITLE}」pane。` }
250  })
251
252  // 不論是按鈕、/session-stats 還是使用者自己關掉 pane,都停止更新
253  on('ui.close', async ($, e, next) => {
254    if (e.id === PANE) {
255      await update($, isOpen, () => false)
256      timer?.cancel()
257      timer = null
258    }
259    return next(e)
260  })
261
262  on('prompt.submit', async ($, e, next) => {
263    const id = String(await $.clock.now())
264    await update($, replay, turns => startTurn(turns, id, e.text))
265    return next(e)
266  })
267
268  on('tool.call', async ($, e, next) => {
269    const item: Running = { id: e.tool_use_id, tool: e.tool, label: runningLabel(e.tool, e) }
270    await update($, running, list => [...list, item])
271    const startedAt = await $.clock.now()
272
273    const ran = await next(e)
274
275    const ms = (await $.clock.now()) - startedAt
276    const ok = ran.deny === undefined && ran.isError !== true
277    await update($, running, list => list.filter(one => one.id !== item.id))
278    await update($, toolCalls, n => n + 1)
279    await update($, toolStats, stats => recordTool(stats, e.tool, ms, ok))
280    await update($, replay, turns =>
281      addStep(turns, { id: item.id, tool: item.tool, label: item.label, ms, ok, summary: ran.deny ?? firstLine(ran.text ?? '') }),
282    )
283    if (ok && TASK_TOOLS.includes(e.tool)) {
284      await update($, tasks, list => applyTaskTool(list, e.tool, e, ran.result))
285    }
286    if (ok && EDIT_TOOLS.includes(e.tool) && typeof e.file_path === 'string') {
287      const path = e.file_path
288      const { added, removed } = countPatch((ran.result as { structuredPatch?: unknown } | undefined)?.structuredPatch)
289      // mod 重新載入或測試時 session.start 可能還沒跑過,root 為空就當場查
290      const dir = (await read($, root)) || (await $.session.root())
291      await update($, edits, list => recordEdit(list, path, dir, added, removed))
292    }
293    await refresh($, false)
294    return ran
295  })
296
297  // 回合結束:記 context 走勢與 cache 命中(不論 pane 開關,plain usage 不花成本),再重算明細
298  on('turn.complete', async ($, e, next) => {
299    const ran = await next(e)
300    if (e.agentId === undefined) {
301      const tokens = (await $.session.usage()).context.tokens
302      if (tokens !== undefined) await update($, history, list => [...list, tokens].slice(-HISTORY_SIZE))
303      await update($, cache, c => recordCache(c, e.usage))
304      try {
305        await compactIfNeeded($)
306      } catch (err) {
307        $.ui.log(`context 用量檢查失敗:${errorText(err)}`, { to: 'debug' })
308      }
309    }
310    await refresh($, true)
311    return ran
312  })
313
314  // 所有壓縮(引擎自動、/compact、預先計算)都加上要保留的重點;完成的壓縮把結果存到專案
315  on('session.compact', async ($, e, next) => {
316    if (e.agentId !== undefined) return next(e)
317    const instructions = [e.instructions, COMPACT_INSTRUCTIONS].filter(text => text !== undefined && text !== '').join(String.fromCharCode(10).repeat(2))
318    const result = await next({ ...e, instructions })
319    // precompute 是提前算好、之後才套用的摘要,套用時(auto)才存
320    if (result.skip === undefined && e.trigger !== 'precompute') {
321      try {
322        await saveNotes($, result.messages, { trigger: e.trigger, percent: null })
323      } catch (err) {
324        $.ui.log(`工作階段摘要存檔失敗:${errorText(err)}`, { to: 'debug' })
325      }
326    }
327    return result
328  })
329
330  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
331    const kit = $.ui.resolve(e)
332    const { Box, Button, Text } = kit
333    const calls = await read($, toolCalls)
334    const u = await read($, usage)
335    const taskList = await read($, tasks)
336    const inFlight = await read($, running)
337    const ctx = await read($, context)
338    const trend = await read($, history)
339    const changed = await read($, edits)
340    const turns = await read($, replay)
341    const pinned = await read($, cursor)
342    const stats = await read($, toolStats)
343    const cacheStat = await read($, cache)
344    const agentRows = visibleAgents(await read($, agents))
345    await read($, tick)
346    if (u === null || !(await read($, isOpen))) await scheduleHeal($)
347    const now = await $.clock.now()
348
349    const [usageRows = 4, taskRows = 4, contextRows = 4] = splitRows(Math.max(12, e.props.scroll.bodyRows), 3)
350    const inner = Math.max(24, e.props.bodyColumns - 4)
351    const barWidth = Math.max(6, inner - LABEL_WIDTH - 14)
352
353    // ── 區塊一:用量與預報 ──
354    const burn = burnRate(trend)
355    const currentTokens = u?.tokens ?? trend[trend.length - 1] ?? null
356    const compactAt = ctx?.compactAt ?? ctx?.maxTokens ?? null
357    const turnsLeft = currentTokens !== null && compactAt !== null ? turnsUntil(currentTokens, compactAt, burn) : null
358    const forecasts = (u?.rateLimits ?? []).map(r => ({ limit: r, projected: projectLimit(r, now) }))
359    const worst = Math.max(u?.percent ?? 0, ...forecasts.map(f => f.projected ?? f.limit.percentUsed))
360    const weather = weatherOf(worst)
361    const cacheTotal = ratio(cacheStat.read, cacheStat.creation, cacheStat.input)
362    const elapsed = u === null ? 0 : now - u.startedAt
363    const perTurn = u !== null && u.turns > 0 ? elapsed / u.turns : null
364
365    const usageLines = pick<RenderChildren>(
366      u === null
367        ? [{ item: text(kit, '讀取中…', { dim: true }), priority: 10 }]
368        : [
369            {
370              item: labeled(
371                kit,
372                'Session',
373                <Text wrap="truncate-end">
374                  <Text>{duration(elapsed)}</Text>
375                  <Text dimColor>{` · ${u.turns} 回合${perTurn === null ? '' : ` · ${duration(perTurn)}/回合`}${u.costUsd === null ? '' : ` · $${u.costUsd.toFixed(2)}`}`}</Text>
376                </Text>,
377              ),
378              priority: 6,
379            },
380            {
381              item:
382                u.percent === null
383                  ? labeled(kit, 'Context', <Text dimColor wrap="truncate-end">等待第一次 API 回應…</Text>)
384                  : gauge(kit, 'Context', u.percent, barWidth, `${u.percent}%`, levelColor(u.percent)),
385              priority: 10,
386            },
387            ...u.rateLimits.map(r => ({
388              item: gauge(kit, limitName(r.kind), r.percentUsed, barWidth, `${r.percentUsed}% ↻${resetIn(r.resetsAt, now)}`, levelColor(r.percentUsed)),
389              priority: 8,
390            })),
391            {
392              // cache 命中率越高越好,顏色與用量相反
393              item:
394                cacheTotal === null
395                  ? labeled(kit, 'Cache', <Text dimColor wrap="truncate-end">回合結束後計算</Text>)
396                  : gauge(
397                      kit,
398                      'Cache',
399                      cacheTotal,
400                      barWidth,
401                      `${cacheTotal}%${cacheStat.last === null ? '' : ` 本回${cacheStat.last}%`}`,
402                      cacheTotal >= 70 ? 'success' : cacheTotal >= 40 ? 'warning' : 'error',
403                    ),
404              priority: 7,
405            },
406            {
407              item: labeled(
408                kit,
409                'Tokens',
410                <Text dimColor wrap="truncate-end">
411                  {`${u.tokens === null ? '—' : compact(u.tokens)} / ${compact(u.window)} · 工具 ${calls} 次`}
412                </Text>,
413              ),
414              priority: 3,
415            },
416            { item: rule(kit, '預報', ACCENT.usage, <Text color={weather.color}>{`${weather.icon} ${weather.label}`}</Text>), priority: 9 },
417            {
418              item: labeled(
419                kit,
420                '每回合',
421                <Text wrap="truncate-end">
422                  <Text>{burn === null ? '—' : `+${compact(Math.round(burn))}`}</Text>
423                  <Text color={ACCENT.usage}>{`  ${sparkline(trend.slice(-Math.max(4, barWidth - 4)), u.window)}`}</Text>
424                </Text>,
425              ),
426              priority: 9,
427            },
428            {
429              item: labeled(
430                kit,
431                'Compact',
432                <Text color={turnsLeft !== null && turnsLeft <= 3 ? 'warning' : undefined} dimColor={turnsLeft === null} wrap="truncate-end">
433                  {turnsLeft === null ? '累積兩回合以上才能預估' : turnsLeft === 0 ? '下一回合就可能觸發' : `約 ${turnsLeft} 回合後觸發`}
434                </Text>,
435              ),
436              priority: 7,
437            },
438            ...forecasts.map(f => ({
439              item: labeled(
440                kit,
441                limitName(f.limit.kind),
442                <Text color={f.projected === null ? undefined : levelColor(f.projected)} dimColor={f.projected === null} wrap="truncate-end">
443                  {f.projected === null ? '視窗剛開始,資料不足' : `重置前約用到 ${f.projected}%`}
444                </Text>,
445              ),
446              priority: 5,
447            })),
448          ],
449      usageRows - SECTION_CHROME,
450    )
451
452    // ── 區塊二:任務與執行紀錄 ──
453    // 背景 subagent 已有自己的列,就不再重複列出執行中的 Agent 工具呼叫
454    const runningRows = inFlight.filter(r => !(r.tool === 'Agent' && agentRows.length > 0))
455    const taskLines: RenderChildren[] = [
456      ...agentRows.map(a =>
457        line(
458          kit,
459          <Text color={a.status === 'failed' ? 'error' : ACCENT.tasks} wrap="truncate-end">
460            {`⚙ ${a.type} · ${a.description} · ${statusName(a.status)}`}
461          </Text>,
462        ),
463      ),
464      ...runningRows.map(r => text(kit, `▶ ${r.tool} ${r.label}`, { color: 'warning' })),
465      ...[...taskList]
466        .sort((a, b) => TASK_ORDER[a.status] - TASK_ORDER[b.status])
467        .map(t =>
468          text(kit, `${t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '◐' : '○'} ${t.subject}`, {
469            color: t.status === 'in_progress' ? ACCENT.tasks : t.status === 'completed' ? 'success' : undefined,
470            dim: t.status !== 'in_progress',
471          }),
472        ),
473    ]
474    // 下段最少要有:執行紀錄分隔線、工具排行、回放控制、prompt、一個步驟
475    const taskSplit = share(taskRows - SECTION_CHROME, Math.max(1, taskLines.length), 0.35, turns.length === 0 ? 3 : 5)
476    const taskFit = fit(taskLines, taskSplit.upper)
477    const doneCount = taskList.filter(t => t.status === 'completed').length
478
479    const tools = summarizeTools(stats)
480    const at = resolveCursor(turns, pinned)
481    const shownTurn = at === null ? undefined : turns[at.turn]
482    const steps = shownTurn?.steps ?? []
483    // 下段固定列:分隔線、工具排行、回放控制、prompt、輸出摘要
484    const stepRoom = Math.max(1, taskSplit.lower - 5)
485    const view = windowAround(steps.length, at?.step ?? 0, stepRoom)
486    const selected = at === null ? undefined : steps[at.step]
487
488    const replayRows: RenderChildren[] =
489      shownTurn === undefined
490        ? [text(kit, '還沒有回合紀錄', { dim: true })]
491        : [
492            line(
493              kit,
494              <Box columnGap={1}>
495                <Text color={ACCENT.tasks}>{`回放 ${(at?.turn ?? 0) + 1}/${turns.length}`}</Text>
496                <Button key="turn-prev" dimColor hotkey="p" onPress={() => moveCursor($, -1, 0)}>◀</Button>
497                <Button key="turn-next" dimColor hotkey="n" onPress={() => moveCursor($, 1, 0)}>▶</Button>
498                <Text dimColor>步驟</Text>
499                <Button key="step-prev" dimColor hotkey="k" onPress={() => moveCursor($, 0, -1)}>▲</Button>
500                <Button key="step-next" dimColor hotkey="j" onPress={() => moveCursor($, 0, 1)}>▼</Button>
501                {pinned !== null && (
502                  <Button key="follow" dimColor hotkey="f" onPress={() => update($, cursor, () => null)}>跟隨</Button>
503                )}
504              </Box>,
505            ),
506            text(kit, clip(`「${shownTurn.prompt}」`, inner), { italic: true }),
507            ...(steps.length === 0
508              ? [text(kit, '這一回合沒有工具呼叫', { dim: true })]
509              : steps.slice(view.start, view.end).map((s, i) => {
510                  const index = view.start + i
511                  const isSelected = index === at?.step
512                  const time = s.ms >= 1000 ? `${(s.ms / 1000).toFixed(1)}s` : `${s.ms}ms`
513                  return line(
514                    kit,
515                    <Box flexGrow={1}>
516                      <Text color={isSelected ? ACCENT.tasks : undefined} bold={isSelected}>{isSelected ? '▸' : ' '}</Text>
517                      <Text dimColor>{`${String(index + 1).padStart(2)} `}</Text>
518                      <Text color={s.ok ? 'success' : 'error'}>{s.ok ? '✓ ' : '✗ '}</Text>
519                      <Box flexGrow={1} overflow="hidden">
520                        <Text dimColor={!isSelected} wrap="truncate-end">{`${s.tool} ${s.label}`}</Text>
521                      </Box>
522                      <Text dimColor>{` ${time}`}</Text>
523                    </Box>,
524                  )
525                })),
526            ...(selected === undefined ? [] : [text(kit, clip(`└ ${selected.summary === '' ? '(沒有輸出)' : selected.summary}`, inner), { dim: true })]),
527          ]
528
529    // ── 區塊三:上下文與改動 ──
530    const groups = groupEdits(changed)
531    const maxGroup = Math.max(1, ...groups.map(g => g.added + g.removed))
532    const totalAdded = changed.reduce((n, f) => n + f.added, 0)
533    const totalRemoved = changed.reduce((n, f) => n + f.removed, 0)
534    const groupBar = Math.max(4, Math.floor(barWidth / 2))
535    const blastLines: RenderChildren[] = [
536      ...(isUntested(changed) ? [text(kit, '⚠ 有改程式碼但沒動測試', { color: 'warning' })] : []),
537      ...groups.flatMap(g => {
538        const bar = barOf(((g.added + g.removed) / maxGroup) * 100, groupBar)
539        return [
540          line(
541            kit,
542            <Box flexGrow={1}>
543              <Box flexGrow={1} overflow="hidden"><Text bold wrap="truncate-end">{g.name}</Text></Box>
544              <Text color={ACCENT.context}>{` ${bar.full}`}</Text>
545              <Text dimColor>{bar.rest}</Text>
546              <Text dimColor>{` ${g.files.length} 檔 `}</Text>
547              {diff(kit, g.added, g.removed)}
548            </Box>,
549          ),
550          ...g.files.map(f =>
551            line(
552              kit,
553              <Box flexGrow={1}>
554                <Box flexGrow={1} overflow="hidden">
555                  <Text dimColor wrap="truncate-end">{`  ${f.rel.split('/').pop() ?? f.rel}`}</Text>
556                </Box>
557                <Text> </Text>
558                {diff(kit, f.added, f.removed)}
559              </Box>,
560            ),
561          ),
562        ]
563      }),
564    ]
565    const ctxSplit = share(contextRows - SECTION_CHROME, Math.max(1, ctx?.rows.length ?? 1), 0.45, changed.length === 0 ? 2 : 3)
566    const ctxFit = fit(ctx?.rows ?? [], ctxSplit.upper)
567    const blastFit = fit(blastLines, Math.max(1, ctxSplit.lower - 1))
568
569    return (
570      <Box flexDirection="column">
571        {section(
572          kit,
573          {
574            color: ACCENT.usage,
575            title: '◆ 用量與預報',
576            height: usageRows,
577            right: (
578              <Box columnGap={1}>
579                <Text dimColor wrap="truncate-end">{u === null ? '' : clip(u.model, Math.max(8, inner - 32))}</Text>
580                <Button key="usage-refresh" dimColor hotkey="r" onPress={() => refreshNow($)}>重新整理</Button>
581                <Button key="close" dimColor onPress={() => $.ui.close({ id: PANE })}>關閉</Button>
582              </Box>
583            ),
584          },
585          usageLines,
586        )}
587
588        {section(
589          kit,
590          {
591            color: ACCENT.tasks,
592            title: '◆ 任務與執行紀錄',
593            height: taskRows,
594            right: taskList.length > 0 ? <Text dimColor>{`${doneCount}/${taskList.length} 完成`}</Text> : undefined,
595          },
596          [
597            ...(taskLines.length === 0 ? [text(kit, '目前沒有執行中的工作', { dim: true })] : [...taskFit.shown, ...more(kit, taskFit.more, '項')]),
598            rule(
599              kit,
600              '執行紀錄',
601              ACCENT.tasks,
602              tools.total === 0 ? undefined : (
603                <Text dimColor>
604                  {`${tools.total} 次`}
605                  {tools.failed > 0 ? <Text color="error">{` ✗${tools.failed}`}</Text> : null}
606                  {tools.slowest === null ? '' : ` · 最慢 ${tools.slowest.tool} ${duration(tools.slowest.maxMs)}`}
607                </Text>
608              ),
609            ),
610            tools.total === 0
611              ? text(kit, '還沒有工具呼叫', { dim: true })
612              : line(
613                  kit,
614                  <Text wrap="truncate-end">
615                    {tools.top.slice(0, 6).map((t, i) => (
616                      <Text>
617                        <Text>{`${i === 0 ? '' : '  '}${t.tool} `}</Text>
618                        <Text color={ACCENT.tasks} bold>{String(t.count)}</Text>
619                        {t.failed > 0 ? <Text color="error">{`✗${t.failed}`}</Text> : null}
620                      </Text>
621                    ))}
622                  </Text>,
623                ),
624            ...replayRows,
625          ],
626        )}
627
628        {section(
629          kit,
630          {
631            color: ACCENT.context,
632            title: '◆ 上下文與改動',
633            height: contextRows,
634            right: ctx !== null ? <Text dimColor>{`${compact(ctx.totalTokens)} / ${compact(ctx.maxTokens)} · ${ctx.percentage}%`}</Text> : undefined,
635          },
636          [
637            ...(ctx === null
638              ? [text(kit, '回合結束後顯示', { dim: true })]
639              : [
640                  ...ctxFit.shown.map(row => {
641                    const bar = barOf(ctx.maxTokens > 0 ? (row.tokens / ctx.maxTokens) * 100 : 0, barWidth)
642                    return line(
643                      kit,
644                      <Box flexGrow={1}>
645                        <Box flexGrow={1} overflow="hidden"><Text wrap="truncate-end">{row.name}</Text></Box>
646                        <Text color={ACCENT.context}>{` ${bar.full}`}</Text>
647                        <Text dimColor>{bar.rest}</Text>
648                        <Text dimColor>{` ${compact(row.tokens).padStart(6)}`}</Text>
649                      </Box>,
650                    )
651                  }),
652                  ...more(kit, ctxFit.more, '類'),
653                ]),
654            rule(
655              kit,
656              '改動範圍',
657              ACCENT.context,
658              changed.length === 0 ? undefined : (
659                <Text>
660                  <Text dimColor>{`${changed.length} 檔 `}</Text>
661                  {diff(kit, totalAdded, totalRemoved)}
662                </Text>
663              ),
664            ),
665            ...(changed.length === 0 ? [text(kit, '本 session 還沒有改動檔案', { dim: true })] : [...blastFit.shown, ...more(kit, blastFit.more, '列')]),
666          ],
667        )}
668      </Box>
669    )
670  })
671}
672
hooks/blast.ts 62 lines
1import type { FileEdit } from '../types'
2
3// 影響範圍:本 session 改過哪些檔案、各幾行,依專案第一層資料夾分組。
4
5type Hunk = { lines?: readonly unknown[] }
6
7export const countPatch = (patch: unknown): { added: number; removed: number } => {
8  let added = 0
9  let removed = 0
10  for (const hunk of Array.isArray(patch) ? (patch as Hunk[]) : []) {
11    for (const line of hunk.lines ?? []) {
12      if (typeof line !== 'string') continue
13      if (line.startsWith('+')) added += 1
14      else if (line.startsWith('-')) removed += 1
15    }
16  }
17  return { added, removed }
18}
19
20const slash = (path: string): string => path.replaceAll(String.fromCharCode(92), '/')
21
22// root 之外的檔案歸到「專案外」;root 底下的檔案依第一層資料夾分組
23export const locate = (path: string, root: string): { group: string; rel: string } => {
24  const p = slash(path)
25  const r = slash(root).endsWith('/') ? slash(root).slice(0, -1) : slash(root)
26  if (r === '' || !p.toLowerCase().startsWith(`${r.toLowerCase()}/`)) {
27    return { group: '專案外', rel: p.split('/').slice(-2).join('/') }
28  }
29  const rel = p.slice(r.length + 1)
30  const parts = rel.split('/')
31  return { group: parts.length > 1 ? (parts[0] ?? '') : '(根目錄)', rel }
32}
33
34export const recordEdit = (edits: FileEdit[], path: string, root: string, added: number, removed: number): FileEdit[] => {
35  const { group, rel } = locate(path, root)
36  const found = edits.find(e => e.path === path)
37  if (found === undefined) return [...edits, { path, rel, group, added, removed }]
38  return edits.map(e => (e.path === path ? { ...e, added: e.added + added, removed: e.removed + removed } : e))
39}
40
41export type BlastGroup = { name: string; files: FileEdit[]; added: number; removed: number }
42
43export const groupEdits = (edits: readonly FileEdit[]): BlastGroup[] => {
44  const map = new Map<string, FileEdit[]>()
45  for (const e of edits) map.set(e.group, [...(map.get(e.group) ?? []), e])
46  return [...map.entries()]
47    .map(([name, files]) => ({
48      name,
49      files: [...files].sort((a, b) => b.added + b.removed - (a.added + a.removed)),
50      added: files.reduce((n, f) => n + f.added, 0),
51      removed: files.reduce((n, f) => n + f.removed, 0),
52    }))
53    .sort((a, b) => b.added + b.removed - (a.added + a.removed))
54}
55
56const isTest = (e: FileEdit): boolean => /test/i.test(e.rel)
57const isCode = (e: FileEdit): boolean => /\.(cs|ts|tsx|js|jsx|py|go|rs|java|kt|xaml)$/i.test(e.rel) && e.group !== '專案外'
58
59// 改了程式碼卻沒動任何測試檔
60export const isUntested = (edits: readonly FileEdit[]): boolean =>
61  edits.some(e => isCode(e) && !isTest(e)) && !edits.some(isTest)
62
hooks/format.ts 27 lines
1// 顏色一律用 Claude Code 的主題色名:淺色、深色主題都會換成看得清楚的版本。
2// ANSI 色名(cyan、yellow)在白底上很難讀。
3export const ACCENT = { usage: 'claude', tasks: 'suggestion', context: 'success' } as const
4export const GOOD = 'success'
5export const WARN = 'warning'
6export const BAD = 'error'
7
8export const levelColor = (percent: number): string => (percent >= 85 ? BAD : percent >= 60 ? WARN : GOOD)
9
10export const barOf = (percent: number, width: number): { full: string; rest: string } => {
11  const w = Math.max(0, width)
12  const filled = Math.max(0, Math.min(w, Math.round((percent / 100) * w)))
13  return { full: '━'.repeat(filled), rest: '─'.repeat(w - filled) }
14}
15
16const LIMIT_NAMES: Record<string, string> = { five_hour: '5 小時', seven_day: '7 天', spend_limit: '支出上限' }
17export const limitName = (kind: string): string => LIMIT_NAMES[kind] ?? kind
18
19export const compact = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
20
21export const resetIn = (iso: string | null, now: number): string => {
22  if (iso === null) return ''
23  const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
24  if (Number.isNaN(minutes)) return ''
25  return minutes >= 60 ? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m` : `${minutes}m`
26}
27
hooks/layout.ts 39 lines
1// 三個區塊平均分配 pane 的高度;除不盡的列數給最後一區。
2export const MIN_SECTION_ROWS = 4
3
4export const splitRows = (total: number, count: number): number[] => {
5  const each = Math.max(MIN_SECTION_ROWS, Math.floor(total / count))
6  const heights = Array.from({ length: count }, () => each)
7  const rest = total - each * count
8  if (rest > 0) heights[count - 1] = each + rest
9  return heights
10}
11
12// 區塊內可放的項目數:扣掉上下框線 2 列、標題 1 列,以及固定列。
13// 放不下時留一列給「… 還有 N 項」。
14export const fit = <T>(items: readonly T[], room: number): { shown: T[]; more: number } => {
15  if (items.length <= room) return { shown: [...items], more: 0 }
16  const shown = items.slice(0, Math.max(0, room - 1))
17  return { shown, more: items.length - shown.length }
18}
19
20export const SECTION_CHROME = 3
21
22// 依優先序挑出放得下的列,畫面上仍維持原本的順序
23export const pick = <T>(rows: readonly { item: T; priority: number }[], room: number): T[] => {
24  const keep = new Set(
25    rows
26      .map((row, index) => ({ index, priority: row.priority }))
27      .sort((a, b) => b.priority - a.priority || a.index - b.index)
28      .slice(0, Math.max(0, room))
29      .map(row => row.index),
30  )
31  return rows.filter((_, index) => keep.has(index)).map(row => row.item)
32}
33
34// 區塊內上下兩段分配列數:上段最多拿 share 比例,但要留給下段至少 lowerMin 列
35export const share = (room: number, upperWant: number, share: number, lowerMin: number): { upper: number; lower: number } => {
36  const upper = Math.max(1, Math.min(upperWant, Math.floor(room * share), room - lowerMin))
37  return { upper, lower: Math.max(0, room - upper) }
38}
39
hooks/notes.ts 92 lines
1import { clean } from './text'
2
3// mod 載入器處理反斜線有問題,這個檔案完全不寫反斜線:
4// 換行、tab 用 fromCharCode 組成;正規表示式的字邊界用 lookaround,句點用 [.],任意字元用 [^]。
5const TAB = String.fromCharCode(9)
6const LF = String.fromCharCode(10)
7const CR = String.fromCharCode(13)
8
9/** context 用量達到這個百分比,就在回合結束後先壓縮,不等引擎在工具執行途中自動壓縮。 */
10export const COMPACT_THRESHOLD = 80
11
12/** 每次壓縮都加給摘要器的指示:保留接手工作需要的重點。 */
13export const COMPACT_INSTRUCTIONS = [
14  '摘要請保留以下內容,讓壓縮後能直接接續工作:',
15  '1. 目前正在進行的任務、完成到哪一步、下一步要做什麼',
16  '2. 已經做出的決定與原因(包含使用者否決或選定的方案)',
17  '3. 新增、修改、刪除過的檔案路徑與改動重點',
18  '4. 使用者的偏好、規則與限制(例如語言、提交方式、不可以做的事)',
19  '5. 尚未解決的問題、錯誤訊息與已經嘗試過的做法',
20].join(LF)
21
22/** 摘要存放的資料夾(相對於專案根目錄)。 */
23export const NOTES_DIR = '.claude/session-notes'
24
25/** 資料夾內的 .gitignore:讓整個資料夾不被提交,不必修改專案自己的 .gitignore。 */
26export const NOTES_GITIGNORE = ['# Claude Code 壓縮時保存的工作階段摘要,只留在本機', '*', ''].join(LF)
27
28const pad = (n: number): string => String(n).padStart(2, '0')
29
30/** 例如 2026-10-07-1530.md;同一分鐘內第二次壓縮加上序號。 */
31export const notesFileName = (now: Date, existing: readonly string[] = []): string => {
32  const base = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`
33  if (!existing.includes(`${base}.md`)) return `${base}.md`
34  let n = 2
35  while (existing.includes(`${base}-${n}.md`)) n += 1
36  return `${base}-${n}.md`
37}
38
39type Message = { role: string; text: string }
40
41const MASK = '[已遮蔽]'
42const SPACE = `[ ${TAB}]`
43const NOT_SPACE = `[^ ${TAB}${CR}${LF}]`
44const START = '(?<![A-Za-z0-9_])'
45
46// 常見的密鑰格式:私鑰區塊、API key(sk-、sk-ant-)、GitHub、AWS、Slack token、JWT、Bearer、key=value 形式的密碼
47const SECRET_PATTERNS: readonly (readonly [RegExp, string])[] = [
48  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[^]*?-----END [A-Z ]*PRIVATE KEY-----/g, MASK],
49  [new RegExp(`${START}sk-(?:ant-)?[A-Za-z0-9_-]{16,}`, 'g'), MASK],
50  [new RegExp(`${START}(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})`, 'g'), MASK],
51  [new RegExp(`${START}AKIA[0-9A-Z]{16}(?![A-Za-z0-9_])`, 'g'), MASK],
52  [new RegExp(`${START}xox[abprs]-[A-Za-z0-9-]{10,}`, 'g'), MASK],
53  [new RegExp(`${START}eyJ[A-Za-z0-9_-]{8,}[.][A-Za-z0-9_-]{8,}[.][A-Za-z0-9_-]{8,}`, 'g'), MASK],
54  [new RegExp(`Bearer${SPACE}+[A-Za-z0-9._~+/=-]{16,}`, 'gi'), `Bearer ${MASK}`],
55  // 保留欄位名稱,只遮蔽值:password = [已遮蔽]
56  [
57    new RegExp(`${START}(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)(${SPACE}*[:=]${SPACE}*)${NOT_SPACE}+`, 'gi'),
58    `$1$2${MASK}`,
59  ],
60]
61
62/** 存檔前遮蔽常見的密鑰與密碼:摘要可能引用工具輸出,檔案雖不提交,仍可能被同步或備份帶走。 */
63export const redactSecrets = (text: string): string =>
64  SECRET_PATTERNS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
65
66/** path 是否位於 root 之內(不分大小寫、斜線方向;只比較完整的路徑段)。 */
67export const isInside = (path: string, root: string): boolean => {
68  const norm = (p: string) => p.replaceAll(String.fromCharCode(92), '/').replace(/[/]+$/, '').toLowerCase()
69  const a = norm(path)
70  const b = norm(root)
71  return a === b || a.startsWith(`${b}/`)
72}
73
74/**
75 * 壓縮後的對話第一則是摘要,其後是原樣保留的訊息(可能含工具輸出的原文)。
76 * 只存摘要,並移除控制字元、遮蔽密鑰。
77 */
78export const notesMarkdown = (messages: readonly Message[], meta: { when: Date; trigger: string; percent: number | null }): string => {
79  const summary = messages.find(m => m.text.trim() !== '')?.text ?? '(沒有摘要內容)'
80  return [
81    `# 工作階段摘要(${meta.when.toLocaleString('zh-TW', { hour12: false })})`,
82    '',
83    `- 觸發:${triggerName(meta.trigger)}${meta.percent === null ? '' : `(context ${meta.percent}%)`}`,
84    '',
85    redactSecrets(clean(summary)).trim(),
86    '',
87  ].join(LF)
88}
89
90const triggerName = (trigger: string): string =>
91  trigger === 'auto' ? '引擎自動壓縮' : trigger === 'manual' ? '手動 /compact' : trigger === 'plugin' ? '達到門檻時預先壓縮' : trigger
92
hooks/replay.ts 40 lines
1import type { ReplayStep, ReplayTurn } from '../types'
2import { clean } from './text'
3
4export const MAX_TURNS = 30
5export const MAX_STEPS = 100
6
7export const firstLine = (text: string): string =>
8  clean(text.split(/\r?\n/).map(l => l.trim()).find(l => l !== '') ?? '')
9
10export const startTurn = (turns: ReplayTurn[], id: string, prompt: string): ReplayTurn[] =>
11  [...turns, { id, prompt: firstLine(prompt).slice(0, 200), steps: [] }].slice(-MAX_TURNS)
12
13export const addStep = (turns: ReplayTurn[], step: ReplayStep): ReplayTurn[] => {
14  const base = turns.length === 0 ? [{ id: 'start', prompt: '(mod 載入前開始的回合)', steps: [] }] : turns
15  const last = base[base.length - 1] as ReplayTurn
16  return [...base.slice(0, -1), { ...last, steps: [...last.steps, step].slice(-MAX_STEPS) }]
17}
18
19// cursor 為 null 代表「跟著最新」:最新回合的最後一步
20export const resolveCursor = (
21  turns: readonly ReplayTurn[],
22  cursor: { turn: number; step: number } | null,
23): { turn: number; step: number } | null => {
24  if (turns.length === 0) return null
25  if (cursor === null) {
26    const turn = turns.length - 1
27    return { turn, step: Math.max(0, (turns[turn]?.steps.length ?? 1) - 1) }
28  }
29  const turn = Math.max(0, Math.min(turns.length - 1, cursor.turn))
30  const steps = turns[turn]?.steps.length ?? 0
31  return { turn, step: Math.max(0, Math.min(Math.max(0, steps - 1), cursor.step)) }
32}
33
34// 選取的步驟保持在可見範圍中間附近
35export const windowAround = (count: number, selected: number, room: number): { start: number; end: number } => {
36  if (count <= room) return { start: 0, end: count }
37  const start = Math.max(0, Math.min(count - room, selected - Math.floor(room / 2)))
38  return { start, end: start + room }
39}
40
hooks/stats.ts 72 lines
1import type { AgentRow, CacheStat, ToolStat } from '../types'
2
3// ── 工具統計 ──
4
5export const recordTool = (stats: ToolStat[], tool: string, ms: number, ok: boolean): ToolStat[] => {
6  const found = stats.find(s => s.tool === tool)
7  if (found === undefined) return [...stats, { tool, count: 1, failed: ok ? 0 : 1, totalMs: ms, maxMs: ms }]
8  return stats.map(s =>
9    s.tool === tool
10      ? { ...s, count: s.count + 1, failed: s.failed + (ok ? 0 : 1), totalMs: s.totalMs + ms, maxMs: Math.max(s.maxMs, ms) }
11      : s,
12  )
13}
14
15export const summarizeTools = (stats: readonly ToolStat[]) => {
16  const total = stats.reduce((n, s) => n + s.count, 0)
17  const failed = stats.reduce((n, s) => n + s.failed, 0)
18  const top = [...stats].sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
19  const slowest = stats.reduce<ToolStat | null>((best, s) => (best === null || s.maxMs > best.maxMs ? s : best), null)
20  return { total, failed, top, slowest }
21}
22
23// ── Prompt cache 命中率 ──
24
25type ModelUsageLike = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
26
27export const ratio = (read: number, creation: number, input: number): number | null => {
28  const total = read + creation + input
29  return total > 0 ? Math.round((read / total) * 100) : null
30}
31
32export const recordCache = (stat: CacheStat, usage: ModelUsageLike | undefined): CacheStat => {
33  if (usage === undefined) return stat
34  const read = usage.cache_read_input_tokens ?? 0
35  const creation = usage.cache_creation_input_tokens ?? 0
36  const input = usage.input_tokens ?? 0
37  return {
38    read: stat.read + read,
39    creation: stat.creation + creation,
40    input: stat.input + input,
41    last: ratio(read, creation, input) ?? stat.last,
42  }
43}
44
45// ── Session 計時 ──
46
47export const duration = (ms: number): string => {
48  const s = Math.max(0, Math.round(ms / 1000))
49  if (s < 60) return `${s}s`
50  if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
51  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
52}
53
54// ── Subagent ──
55
56const ACTIVE = ['pending', 'running', 'waiting']
57const STATUS_NAMES: Record<string, string> = {
58  pending: '等待開始',
59  running: '執行中',
60  waiting: '等待中',
61  idle: '閒置',
62  completed: '完成',
63  failed: '失敗',
64  killed: '已停止',
65}
66
67export const statusName = (status: string): string => STATUS_NAMES[status] ?? status
68
69// 只列還在跑的與失敗的;完成的就不占空間
70export const visibleAgents = (agents: readonly AgentRow[]): AgentRow[] =>
71  agents.filter(a => ACTIVE.includes(a.status) || a.status === 'failed')
72
hooks/tasks.ts 62 lines
1import type { Task, TaskStatus } from '../types'
2
3// 從 Claude 的任務工具呼叫推算目前的任務清單。
4// input / result 來自 tool.call,形狀依工具而定,所以這裡只讀需要的欄位。
5
6type Bag = Record<string, unknown>
7
8const asBag = (value: unknown): Bag => (typeof value === 'object' && value !== null ? (value as Bag) : {})
9const asStatus = (value: unknown): TaskStatus | null =>
10  value === 'pending' || value === 'in_progress' || value === 'completed' ? value : null
11
12const TODO_PREFIX = 'todo:'
13
14export const applyTaskTool = (tasks: Task[], tool: string, input: unknown, result: unknown): Task[] => {
15  const args = asBag(input)
16
17  if (tool === 'TodoWrite') {
18    const todos = Array.isArray(args.todos) ? args.todos : []
19    const fromTodos = todos.map((todo, i): Task => {
20      const t = asBag(todo)
21      return { id: `${TODO_PREFIX}${i}`, subject: String(t.content ?? ''), status: asStatus(t.status) ?? 'pending' }
22    })
23    return [...tasks.filter(t => !t.id.startsWith(TODO_PREFIX)), ...fromTodos]
24  }
25
26  if (tool === 'TaskCreate') {
27    const task = asBag(asBag(result).task)
28    if (typeof task.id !== 'string') return tasks
29    const subject = String(task.subject ?? args.subject ?? '')
30    return [...tasks.filter(t => t.id !== task.id), { id: task.id, subject, status: 'pending' }]
31  }
32
33  if (tool === 'TaskUpdate') {
34    const id = args.taskId
35    if (typeof id !== 'string') return tasks
36    if (args.status === 'deleted') return tasks.filter(t => t.id !== id)
37    const status = asStatus(args.status)
38    const subject = typeof args.subject === 'string' ? args.subject : null
39    return tasks.map(t => (t.id === id ? { ...t, status: status ?? t.status, subject: subject ?? t.subject } : t))
40  }
41
42  if (tool === 'TaskList') {
43    const listed = Array.isArray(asBag(result).tasks) ? (asBag(result).tasks as unknown[]) : null
44    if (listed === null) return tasks
45    const fromList = listed.flatMap((one): Task[] => {
46      const t = asBag(one)
47      return typeof t.id === 'string'
48        ? [{ id: t.id, subject: String(t.subject ?? ''), status: asStatus(t.status) ?? 'pending' }]
49        : []
50    })
51    return [...tasks.filter(t => t.id.startsWith(TODO_PREFIX)), ...fromList]
52  }
53
54  return tasks
55}
56
57export const runningLabel = (tool: string, input: unknown): string => {
58  const args = asBag(input)
59  const text = args.description ?? args.command ?? args.file_path ?? args.pattern ?? args.query ?? ''
60  return String(text).replace(/\s+/g, ' ').slice(0, 60)
61}
62
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 107 lines
1import type { Elements, RenderChildren } from 'claude-code'
2
3import { barOf } from './format'
4
5export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
6
7export const LABEL_WIDTH = 9
8
9// 每一列都包在高度 1、超出裁掉的容器裡:內容再長也只會被截掉,不會換行把區塊撐歪。
10// 區塊的列數預算因此可以照「一列一行」精確計算。
11export const line = (kit: Kit, children: RenderChildren): RenderChildren => {
12  const { Box } = kit
13  return (
14    <Box height={1} flexShrink={0} overflow="hidden">
15      {children}
16    </Box>
17  )
18}
19
20// 帶色框的區塊:固定高度,框線 2 列 + 標題 1 列,其餘給 rows
21export const section = (
22  kit: Kit,
23  props: { color: string; title: string; height: number; right?: RenderChildren },
24  rows: RenderChildren[],
25): RenderChildren => {
26  const { Box, Text } = kit
27  return (
28    <Box flexDirection="column" height={props.height} flexShrink={0} borderStyle="round" borderColor={props.color} paddingX={1} overflow="hidden">
29      {line(
30        kit,
31        <Box flexGrow={1} justifyContent="space-between">
32          <Text bold color={props.color} wrap="truncate-end">{props.title}</Text>
33          {props.right ?? null}
34        </Box>,
35      )}
36      {rows}
37    </Box>
38  )
39}
40
41// 區塊內的分隔線:「─ 標題 ─────── 右側」
42export const rule = (kit: Kit, title: string, color: string, right?: RenderChildren): RenderChildren => {
43  const { Box, Text } = kit
44  return line(
45    kit,
46    <Box flexGrow={1}>
47      <Text color={color}>{`─ ${title} `}</Text>
48      <Box flexGrow={1} overflow="hidden">
49        <Text dimColor wrap="truncate">{'─'.repeat(200)}</Text>
50      </Box>
51      {right === undefined ? null : <Text> </Text>}
52      {right ?? null}
53    </Box>,
54  )
55}
56
57// 「標籤 ━━━━──── 數值」,標籤欄固定寬度讓數條對齊
58export const gauge = (kit: Kit, label: string, percent: number, barWidth: number, value: string, color: string): RenderChildren => {
59  const { Box, Text } = kit
60  const bar = barOf(percent, barWidth)
61  return line(
62    kit,
63    <Box>
64      <Box width={LABEL_WIDTH} flexShrink={0}><Text dimColor>{label}</Text></Box>
65      <Text color={color}>{bar.full}</Text>
66      <Text dimColor>{bar.rest}</Text>
67      <Text wrap="truncate-end">{` ${value}`}</Text>
68    </Box>,
69  )
70}
71
72// 「標籤  內容」,標籤欄與 gauge 對齊
73export const labeled = (kit: Kit, label: string, children: RenderChildren): RenderChildren => {
74  const { Box, Text } = kit
75  return line(
76    kit,
77    <Box>
78      <Box width={LABEL_WIDTH} flexShrink={0}><Text dimColor>{label}</Text></Box>
79      <Box flexGrow={1} overflow="hidden">{children}</Box>
80    </Box>,
81  )
82}
83
84export const text = (kit: Kit, value: string, props: { color?: string; dim?: boolean; bold?: boolean; italic?: boolean } = {}): RenderChildren => {
85  const { Text } = kit
86  return line(
87    kit,
88    <Text color={props.color} dimColor={props.dim} bold={props.bold} italic={props.italic} wrap="truncate-end">
89      {value}
90    </Text>,
91  )
92}
93
94export const more = (kit: Kit, count: number, noun: string): RenderChildren[] =>
95  count > 0 ? [text(kit, `  … 還有 ${count} ${noun}`, { dim: true })] : []
96
97export const diff = (kit: Kit, added: number, removed: number): RenderChildren => {
98  const { Text } = kit
99  return (
100    <Text>
101      <Text color="success">{`+${added}`}</Text>
102      <Text> </Text>
103      <Text color="error">{`−${removed}`}</Text>
104    </Text>
105  )
106}
107
hooks/weather.ts 54 lines
1import type { RateLimit } from '../types'
2
3// Token 天氣:從每回合結束時的 context token 數推算走勢與預報。
4
5const SPARKS = '▁▂▃▄▅▆▇█'
6const BURN_WINDOW = 5
7const HOUR = 3600_000
8const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
9
10// 最近幾回合的平均成長量。下降(compact、清空)不算消耗,略過。
11export const burnRate = (history: readonly number[]): number | null => {
12  const deltas: number[] = []
13  for (let i = 1; i < history.length; i += 1) {
14    const d = (history[i] ?? 0) - (history[i - 1] ?? 0)
15    if (d > 0) deltas.push(d)
16  }
17  const recent = deltas.slice(-BURN_WINDOW)
18  if (recent.length === 0) return null
19  return recent.reduce((a, b) => a + b, 0) / recent.length
20}
21
22export const sparkline = (values: readonly number[], max: number): string =>
23  values
24    .map(v => SPARKS[Math.max(0, Math.min(SPARKS.length - 1, Math.floor((v / Math.max(1, max)) * SPARKS.length)))])
25    .join('')
26
27export const turnsUntil = (current: number, limit: number, burn: number | null): number | null => {
28  if (burn === null || burn <= 0) return null
29  return Math.max(0, Math.floor((limit - current) / burn))
30}
31
32// 照目前速度線性外推,視窗重置時會用到多少 %。視窗走不到一成時資料太少,不預測。
33export const projectLimit = (limit: RateLimit, now: number): number | null => {
34  const windowMs = WINDOW_MS[limit.kind]
35  if (windowMs === undefined || limit.resetsAt === null) return null
36  const remaining = Date.parse(limit.resetsAt) - now
37  if (Number.isNaN(remaining)) return null
38  const elapsed = windowMs - remaining
39  if (elapsed < windowMs * 0.1) return null
40  return Math.round((limit.percentUsed * windowMs) / elapsed)
41}
42
43// 圖示都用單格寬的符號;⚡ 這類 emoji 在終端機占 2 格,會把整列撐開
44export type Weather = { icon: string; label: string; color: string }
45
46export const weatherOf = (worstPercent: number): Weather =>
47  worstPercent >= 100
48    ? { icon: '☈', label: '暴風', color: 'error' }
49    : worstPercent >= 80
50      ? { icon: '☂', label: '有雨', color: 'warning' }
51      : worstPercent >= 50
52        ? { icon: '☁', label: '多雲', color: 'suggestion' }
53        : { icon: '☀', label: '晴朗', color: 'success' }
54
types/index.d.ts 60 lines
1export type RateLimit = { kind: string; percentUsed: number; resetsAt: string | null }
2
3export type Usage = {
4  model: string
5  startedAt: number
6  turns: number
7  percent: number | null
8  tokens: number | null
9  window: number
10  costUsd: number | null
11  rateLimits: RateLimit[]
12}
13
14export type TaskStatus = 'pending' | 'in_progress' | 'completed'
15export type Task = { id: string; subject: string; status: TaskStatus }
16
17export type Running = { id: string; tool: string; label: string }
18
19export type ContextRow = { name: string; tokens: number }
20// compactAt:扣掉 auto-compact 保留區之後的觸發點;沒有保留區時為 null
21export type ContextBreakdown = {
22  rows: ContextRow[]
23  totalTokens: number
24  maxTokens: number
25  percentage: number
26  compactAt: number | null
27}
28
29export type FileEdit = { path: string; rel: string; group: string; added: number; removed: number }
30
31export type ReplayStep = { id: string; tool: string; label: string; ms: number; ok: boolean; summary: string }
32export type ReplayTurn = { id: string; prompt: string; steps: ReplayStep[] }
33export type ReplayCursor = { turn: number; step: number }
34
35export type ToolStat = { tool: string; count: number; failed: number; totalMs: number; maxMs: number }
36export type CacheStat = { read: number; creation: number; input: number; last: number | null }
37export type AgentRow = { id: string; type: string; description: string; status: string }
38
39declare module 'claude-code' {
40  interface PluginState {
41    'session-stats': {
42      isOpen: boolean
43      toolCalls: number
44      usage: Usage | null
45      tasks: Task[]
46      running: Running[]
47      context: ContextBreakdown | null
48      history: number[]
49      edits: FileEdit[]
50      root: string
51      replay: ReplayTurn[]
52      cursor: ReplayCursor | null
53      toolStats: ToolStat[]
54      cache: CacheStat
55      agents: AgentRow[]
56      tick: number
57    }
58  }
59}
60