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

自用的 Claude Code mod(plugin),任何專案都能使用。
| Plugin | 內容 |
|---|---|
session-stats | 右側 pane「工作階段狀態」:用量與預報(context、rate limit、compact 預估)、任務與執行紀錄(可回放)、上下文與改動範圍。指令 /session-stats 開關;用量區塊有「重新整理」(快捷鍵 r) |
project-activity | 「專案活動」pane(專案類型、關鍵檔案、熱門檔案、最近活動)與「Git 狀態」pane,測試結果 status line、回合摘要 band。指令 /project-activity、/git-status |
兩個 plugin 分開,是因為引擎規定用到 $(引擎介面)的程式必須寫在同一個檔案;合併會變成一個上千行的檔案。安裝時兩個都裝即可。
這是私人 repository,電腦上的 git 需要能存取 GitHub(登入過即可)。在 Claude Code 的提示列輸入:
/plugin install session-stats --marketplace hn83320589/claude-mods
/plugin install project-activity --marketplace hn83320589/claude-mods
第一次會詢問是否加入 marketplace,輸入 y;scope 選 user(所有專案都會載入)。
claude plugin marketplace update claude-mods
claude plugin update session-stats@claude-mods
claude plugin update project-activity@claude-mods
更新後在工作階段中執行 /reload-plugins。
這個資料夾本身就是 marketplace,以本機資料夾加入後,修改程式再 /reload-plugins 就會生效,不需要重新安裝:
claude plugin marketplace add ~/.claude/mods
claude plugin install session-stats@claude-mods
claude plugin install project-activity@claude-mods
測試與檢查:
claude plugin validate ./session-stats
claude plugin test ./session-stats
claude plugin validate ./project-activity
claude plugin test ./project-activity
/compact、預先壓縮)都會要求摘要保留目前任務與進度、已做的決定與原因、改過的檔案、使用者的偏好與規則、未解決的問題。.claude/session-notes/YYYY-MM-DD-HHmm.md。資料夾內有自己的 .gitignore(忽略全部),不會被提交,也不必修改專案的 .gitignore。password= 等)存檔前會遮蔽.claude、session-notes 或要寫入的檔案(含 .gitignore)是符號連結、實際位置不在專案內,或狀態無法確認(例如權限不足)時都不存檔,避免寫到專案外project-activity 可以在專案的 .claude/project-activity.json 列出要檢查的檔案(例如必要的資料檔),沒有這個檔時 pane 會提示可以設定。
hooks/register.tsx 672 lines1import { 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}
672hooks/blast.ts 62 lines1import 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)
62hooks/format.ts 27 lines1// 顏色一律用 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}
27hooks/layout.ts 39 lines1// 三個區塊平均分配 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}
39hooks/notes.ts 92 lines1import { 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
92hooks/replay.ts 40 lines1import 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}
40hooks/stats.ts 72 lines1import 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')
72hooks/tasks.ts 62 lines1import 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}
62hooks/text.ts 44 lines1// 終端機裡中文、全形符號與 emoji 占 2 格。截斷與對齊都要用顯示寬度算,
2// 用字元數算的話,中文內容實際寬度會是預估的兩倍,整列就換行跑版。
3
4const isWide = (cp: number): boolean =>
5 (cp >= 0x1100 && cp <= 0x115f) ||
6 (cp >= 0x2e80 && cp <= 0x303e) ||
7 (cp >= 0x3041 && cp <= 0x33ff) ||
8 (cp >= 0x3400 && cp <= 0x4dbf) ||
9 (cp >= 0x4e00 && cp <= 0x9fff) ||
10 (cp >= 0xa000 && cp <= 0xa4cf) ||
11 (cp >= 0xac00 && cp <= 0xd7a3) ||
12 (cp >= 0xf900 && cp <= 0xfaff) ||
13 (cp >= 0xfe30 && cp <= 0xfe4f) ||
14 (cp >= 0xff00 && cp <= 0xff60) ||
15 (cp >= 0xffe0 && cp <= 0xffe6) ||
16 (cp >= 0x1f300 && cp <= 0x1faff) ||
17 (cp >= 0x20000 && cp <= 0x3fffd)
18
19const charWidth = (ch: string): number => (isWide(ch.codePointAt(0) ?? 0) ? 2 : 1)
20
21export const widthOf = (text: string): number => [...text].reduce((n, ch) => n + charWidth(ch), 0)
22
23export const clip = (text: string, width: number): string => {
24 if (widthOf(text) <= width) return text
25 let out = ''
26 let used = 0
27 for (const ch of text) {
28 const w = charWidth(ch)
29 if (used + w > width - 1) break
30 out += ch
31 used += w
32 }
33 return `${out}…`
34}
35
36// 移除控制字元(C0、DEL、C1,含 ESC)。commit 訊息、檔名、工具輸出都可能藏終端機跳脫序列,
37// 顯示前一律清掉,避免被當成指令改寫畫面或標題。字元以 fromCharCode 組成(mod 載入器處理反斜線有問題)。
38const CONTROL = new RegExp(
39 `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
40 'g',
41)
42
43export const clean = (text: string): string => text.replace(CONTROL, '')
44hooks/ui.tsx 107 lines1import 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}
107hooks/weather.ts 54 lines1import 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' }
54types/index.d.ts 60 lines1export 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