Shows the background agents that are running above the prompt box, and re-dispatches the ones that were interrupted in one press

English | 繁體中文
Small mods that keep Claude Code from losing your work, your secrets and your tokens.
A trim tab is the small flap on a rudder that steers the whole ship. These mods do the same for Claude Code: each one is a few hooks that sit quietly in the session and step in only at the moment something would go wrong. Install the ones you want; none of them depends on another.
rm commands show what they would destroy before they run, and Claude is asked to verify before it calls edited code done.session-keeper and the prompt cache stays warm while you step away, and a large context is handed off to a fresh session instead of dragging on./clear, and with project-memory on, what you learn in one session is waiting in the next.| Mod | What it does for you |
|---|---|
secret-scrub | Masks API keys, tokens and private keys in your prompt before it is sent, so they stay out of the transcript and the prompt cache. |
tool-guard | Holds git reset --hard, force pushes, rm -rf and migrations, lists exactly which files or commits they would lose (or which migrations would run), and lets you decide. Harmless runs pass straight through. |
output-trimmer | When a Bash command prints thousands of lines, the model reads only the head, the tail and the errors; the full output is saved to a file. |
subagent-dispatch | Brings interrupted background agents back with one key, and shows where your tokens went: main session, subagents, models, every spawn. |
open-items | Keeps undecided questions and deferred items outside the context, counts them above the prompt box, and quotes one back in a key press. |
session-keeper | Shows the prompt cache state on desktop, and, once turned on, keeps a 1h cache warm while you are away and offers a handoff to a fresh session when the context gets expensive. |
self-check | Asks Claude to find the cause when the same tool fails twice the same way, and to verify before it says "done" after editing code. |
project-memory | Once turned on, distills each conversation in the background into Claude Code's own memory and a progress file; rules confirmed across sessions are proposed for CLAUDE.md. |
Requires Claude Code 2.1.296 or later, in the CLI or desktop app (cloud sessions cannot load mods yet).
claude plugin install all --marketplace wen-hsiu-hsu/cc-mod-trimtab
This installs every mod, secret-scrub first so that it sees every prompt before other mods do. To install only some of them, or to drop one later, see Install and update.
The mods work right after install, with safe defaults. Features that call a model (in session-keeper and project-memory) stay off until you run /<mod> on or turn them on in /config. Run /<mod> status to see what a mod is set to.
MIT. Some code is adapted from cablate/ctx-handoff-mod (MIT). See NOTICE.
hooks/register.tsx 1523 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { WatchedAgent } from '../types'
5import { configCommand, LANGUAGE, loadConfig, pick, type ConfigIO, type Lang, type Specs } from './config'
6import { fitColumns, fitLegend, listBarWidth, movePeriod, periodMark, type Column } from './layout'
7import { COLORS, emptyState, hintLine, MAIN_SHADES, switcher as switchIn, TONE } from './ui'
8import {
9 addDays,
10 applyMeta,
11 buildHeatmap,
12 buildReport,
13 cells,
14 clockTime,
15 shortCount,
16 shortDay,
17 span,
18 countKinds,
19 DAY_KEEP,
20 DAY_PREFIX,
21 dayHead,
22 dayLabel,
23 dayOf,
24 dayRow,
25 daysBetween,
26 heatLevels,
27 heatStart,
28 heatWeeks,
29 liveFrom,
30 MAX_WEEKS,
31 monthMarks,
32 parseSubagent,
33 parseTranscript,
34 recordedDays,
35 settledDays,
36 transcriptLedger,
37 weekdayLabels,
38 windowReport,
39 type HeatDay,
40 elapsed,
41 DEFAULT_DAYS,
42 GROUP_BYS,
43 groups,
44 type Group,
45 type GroupBy,
46 n,
47 repoName,
48 sessionName,
49 stamp,
50 share,
51 spawnParts,
52 tokens,
53 type Spawn,
54 emptyText,
55 fit,
56 emptyLedger,
57 daysLabel,
58 readLedger,
59 LEDGER_PREFIX,
60 LEDGER_SPEC,
61 MAX_DAYS,
62 promptSummary,
63 recordSpawn,
64 recordStep,
65 renderStats,
66 RETENTION_MS,
67 type Ledger,
68 type Report,
69 type SessionMeta,
70} from './ledger'
71
72// A list shows this many rows, and this many more at each "more".
73const PAGE = 10
74
75const running = atom({ plugin: 'subagent-dispatch', key: 'running' } as const, [])
76const stopped = atom({ plugin: 'subagent-dispatch', key: 'stopped' } as const, [])
77// What the stats pane shows: the period's length and its last day (`YYYY-MM-DD`, '' for today), the
78// grouping, the row opened to its spawns, their order, how many rows are listed, and the spawn
79// opened in place.
80const view = atom({ plugin: 'subagent-dispatch', key: 'view' } as const, { days: 30, by: 'repo', item: '', sort: 'tokens', limit: PAGE, spawn: '', end: '' })
81
82// How an opened row orders its spawns: most tokens, newest, or longest run first.
83type SortBy = 'tokens' | 'time' | 'duration'
84const SORTS: Record<SortBy, (spawn: Spawn) => number> = {
85 tokens: spawn => tokens(spawn),
86 time: spawn => spawn.at,
87 duration: spawn => spawn.last - spawn.at,
88}
89
90const MOD = 'subagent-dispatch'
91const REDISPATCH = 'redispatch'
92const STATS = 'stats'
93const BACKFILL = 'backfill'
94const PANE = 'subagent-dispatch-stats'
95const PERIODS = [1, 7, 30, 90]
96// Below this many body columns the heatmap is not drawn: four weeks and the labels.
97const HEAT_COLUMNS = 12
98// The heatmap's colors (DESIGN.md「熱圖色階」): no use, levels 1–4, the day of a 1-day period.
99const HEAT = { none: '#4a4a52', levels: ['#1f5a6d', '#2a7f9c', '#3aa6cc', '#86d6f0'], chosen: '#ff8c42' } as const
100// Under `backfill`: whether the transcripts were read into ledgers, so it runs once per store.
101const BACKFILL_KEY = 'backfill'
102// A run marked `running` this long ago is taken as dead (its session closed partway).
103const BACKFILL_STALE_MS = 10 * 60_000
104// `$.fs.read` rejects a file over this; a larger transcript is read through `cat`.
105const READ_LIMIT = 4 * 1024 * 1024
106// From this many body columns an opened row's bars are twice as long.
107const WIDE_COLUMNS = 80
108const SPECS: Specs = { ledger: LEDGER_SPEC, language: LANGUAGE }
109const POLL_MS = 3000
110const STOPPED = new Set(['killed', 'failed'])
111const FINISHED = new Set(['completed', 'idle'])
112
113function label(agent: WatchedAgent): string {
114 return agent.model ? `${agent.type}/${agent.model}` : agent.type
115}
116
117// Sent to Claude, so always English.
118export function redispatchPrompt(agents: WatchedAgent[]): string {
119 const list = agents.map(agent => `- ${agent.description} (${label(agent)}, agent id ${agent.id})`).join('\n')
120
121 return `These background agents were just interrupted. Dispatch them again with their original prompt, subagent_type and model (the original Agent calls are earlier in the conversation):\n${list}`
122}
123
124// Moves agents the engine reports as finished or stopped out of `running`.
125async function refresh($: EngineInterface, lang: Lang): Promise<void> {
126 const tracked = await read($, running)
127 if (tracked.length === 0) {
128 return
129 }
130 const live = new Map((await $.agent.list()).map(info => [info.id, info.status]))
131 const stillRunning: WatchedAgent[] = []
132 const newlyStopped: WatchedAgent[] = []
133 for (const agent of tracked) {
134 const status = live.get(agent.id)
135 if (status !== undefined && STOPPED.has(status)) {
136 newlyStopped.push({ ...agent, status })
137 } else if (status !== undefined && !FINISHED.has(status)) {
138 stillRunning.push(agent)
139 }
140 }
141
142 await update($, running, () => stillRunning)
143 if (newlyStopped.length > 0) {
144 await update($, stopped, list => [...list, ...newlyStopped])
145 $.ui.toast(
146 pick(lang, {
147 en: `subagent-dispatch: ${newlyStopped.length} background agent(s) were interrupted; type /${MOD} ${REDISPATCH} to dispatch them again`,
148 'zh-TW': `subagent-dispatch:${newlyStopped.length} 個背景 agent 被中斷,輸入 /${MOD} ${REDISPATCH} 重派`,
149 }),
150 { timeoutMs: 8000 },
151 )
152 }
153 await showStatus($, lang)
154}
155
156// Surfaces without the band above the prompt (mobile, the IDE) get a one-line summary instead.
157async function showStatus($: EngineInterface, lang: Lang): Promise<void> {
158 const surface = await $.session.surface()
159 if (surface === 'terminal' || surface === 'desktop') {
160 return
161 }
162 const count = (await read($, running)).length
163 const lost = (await read($, stopped)).length
164 const runningText = pick(lang, { en: `${count} background agent(s)`, 'zh-TW': `${count} 個背景 agent` })
165 const interrupted = pick(lang, { en: `${lost} interrupted (/${MOD} ${REDISPATCH})`, 'zh-TW': `${lost} 個被中斷(/${MOD} ${REDISPATCH})` })
166 const parts = [count > 0 ? runningText : '', lost > 0 ? interrupted : '']
167 const text = parts.filter(Boolean).join(' · ')
168 $.ui.status(text === '' ? undefined : text)
169}
170
171async function fillRedispatch($: EngineInterface, lang: Lang): Promise<string> {
172 const agents = await read($, stopped)
173 if (agents.length === 0) {
174 return pick(lang, { en: 'No interrupted background agents.', 'zh-TW': '沒有被中斷的背景 agent。' })
175 }
176 await $.prompt.fill({ text: redispatchPrompt(agents), mode: 'replace' })
177 await update($, stopped, () => [])
178 await showStatus($, lang)
179
180 return pick(lang, {
181 en: `Put the redispatch instructions for ${agents.length} agent(s) in the prompt box; press Enter to confirm.`,
182 'zh-TW': `已把 ${agents.length} 個 agent 的重派指示放進輸入框,確認後按 Enter。`,
183 })
184}
185
186// `meta` belongs to session `sid`; `agents` maps an agent id to the ledger key it was spawned
187// under, at most AGENT_KEYS of them; `redraw` says a redraw of the stats pane is waiting;
188// `backfilling` is the backfill running in this process; `snapshot` is the store as the pane last
189// read it.
190type LedgerState = {
191 recording: boolean
192 writing: Promise<void>
193 sid: string
194 meta: SessionMeta
195 agents: Map<string, string>
196 redraw: boolean
197 backfilling?: Promise<void>
198 snapshot?: Snapshot
199}
200
201// Every ledger as stored, every day row by its day, and when they were read.
202type Snapshot = { at: number; ledgers: unknown[]; dayRows: Record<string, unknown> }
203
204const AGENT_KEYS = 500
205// A write redraws the stats pane this long after it, once for every write meanwhile.
206const REDRAW_MS = 1000
207// A redraw of the pane this soon after the last read of the store reuses it.
208const SNAPSHOT_MS = 30_000
209
210// `/clear` starts a new session id in the same process without a session.start: the new
211// session starts now and is named after its own first prompt.
212async function sessionKey($: EngineInterface, state: LedgerState): Promise<string> {
213 const sid = await $.session.id()
214 if (state.sid !== sid) {
215 if (state.sid !== '') {
216 state.meta = { ...state.meta, started: await $.clock.now(), prompt: '' }
217 }
218 state.sid = sid
219 }
220
221 return `${LEDGER_PREFIX}${sid}`
222}
223
224// Applies `change` to this session's own ledger key, or for a step of an agent spawned under an
225// earlier session id (one that outlived /clear) to that session's. Parallel subagents step at
226// the same time and each write reads the ledger back, so they take turns. Bookkeeping must
227// never break the engine's call.
228function record($: EngineInterface, state: LedgerState, where: string, change: (ledger: Ledger, at: number) => void, agentId?: string): Promise<void> {
229 if (!state.recording) {
230 return Promise.resolve()
231 }
232 state.writing = state.writing.then(async () => {
233 try {
234 const own = await sessionKey($, state)
235 const key = (agentId === undefined ? undefined : state.agents.get(agentId)) ?? own
236 const stored = await $.store.get(key)
237 const ledger = readLedger(stored) ?? emptyLedger()
238 change(ledger, await $.clock.now())
239 if (key === own) {
240 applyMeta(ledger, state.meta)
241 }
242 await $.store.set(key, ledger)
243 redrawStats($, state)
244 } catch (error) {
245 $.ui.log(`${where}: ledger not written: ${String(error)}`, { to: 'debug' })
246 }
247 })
248
249 return state.writing
250}
251
252// The pane reads the ledgers from `$.store`, whose writes redraw nothing by themselves. Parallel
253// subagents write at every step, so the writes of a second share one redraw, which reads the store
254// afresh. It also redraws this mod's band above the prompt, which reads one value.
255function redrawStats($: EngineInterface, state: LedgerState): void {
256 state.snapshot = undefined
257 if (state.redraw) {
258 return
259 }
260 state.redraw = true
261 $.clock.after(REDRAW_MS, () => {
262 state.redraw = false
263 $.ui.invalidate('ui.render')
264 })
265}
266
267// Deletes the ledgers no session has written to for RETENTION_MS.
268async function pruneLedgers($: EngineInterface): Promise<void> {
269 const cutoff = (await $.clock.now()) - RETENTION_MS
270 for (const key of (await $.store.keys()).filter(name => name.startsWith(LEDGER_PREFIX))) {
271 const stored = (await $.store.get(key)) as Partial<Ledger> | undefined
272 if (typeof stored?.updated !== 'number' || stored.updated < cutoff) {
273 await $.store.delete(key)
274 }
275 }
276}
277
278// Every ledger as stored, and every day row by its day: a read per key.
279async function loadStore($: EngineInterface): Promise<Snapshot> {
280 const keys = await $.store.keys()
281 const ledgers = await Promise.all(keys.filter(name => name.startsWith(LEDGER_PREFIX)).map(key => $.store.get(key)))
282 const dayKeys = keys.filter(name => name.startsWith(DAY_PREFIX))
283 const rows = await Promise.all(dayKeys.map(key => $.store.get(key)))
284
285 return { at: await $.clock.now(), ledgers, dayRows: Object.fromEntries(dayKeys.map((key, i) => [key.slice(DAY_PREFIX.length), rows[i]])) }
286}
287
288// The store for a render of the pane. Reading every key is most of a render's time once there are
289// a few hundred sessions, and a resize redraws many times a second, so a redraw within SNAPSHOT_MS
290// reuses the last read; this session's writes and opening the pane drop it.
291async function paneStore($: EngineInterface, state: LedgerState): Promise<Snapshot> {
292 const now = await $.clock.now()
293 if (state.snapshot === undefined || now - state.snapshot.at >= SNAPSHOT_MS) {
294 state.snapshot = await loadStore($)
295 }
296
297 return state.snapshot
298}
299
300async function loadReport($: EngineInterface, days: number): Promise<Report> {
301 return buildReport((await loadStore($)).ledgers, days, await $.clock.now())
302}
303
304// Writes the day rows that are due: each day a ledger recorded up to the day before yesterday that
305// has no row yet, and each of `redo` the ledgers still hold whole (a backfill adds to them).
306// Deletes the rows older than DAY_KEEP days. Runs before the ledgers are pruned.
307async function writeDayRows($: EngineInterface, redo: Set<string> = new Set()): Promise<void> {
308 const now = await $.clock.now()
309 const { ledgers, dayRows } = await loadStore($)
310 const read = ledgers.map(readLedger).filter((ledger): ledger is Ledger => ledger !== undefined)
311 const live = liveFrom(now)
312 for (const day of settledDays(read, now)) {
313 if (!(day in dayRows) || (redo.has(day) && day >= live)) {
314 await $.store.set(`${DAY_PREFIX}${day}`, dayRow(read, day, now))
315 }
316 }
317 const oldest = addDays(dayOf(now), -(DAY_KEEP - 1))
318 for (const day of Object.keys(dayRows).filter(day => day < oldest)) {
319 await $.store.delete(`${DAY_PREFIX}${day}`)
320 }
321}
322
323type BackfillMark = { state: 'running' | 'done'; at: number; sessions: number; skipped: number }
324
325// A file's text: through `$.fs.read` up to its limit, through `cat` above it, or undefined where
326// no process can be run (desktop).
327async function readLarge($: EngineInterface, path: string, size: number): Promise<string | undefined> {
328 if (size <= READ_LIMIT) {
329 return (await $.fs.read(path)) as string
330 }
331 try {
332 let text = ''
333 for await (const piece of $.process.spawn({ argv: ['cat', path] })) {
334 if (piece.stream === 'stdout') {
335 text += piece.text
336 }
337 }
338
339 return text
340 } catch {
341 return undefined
342 }
343}
344
345// The main working tree of a directory, as a live session names its repo: git's common directory
346// without its `.git`. A directory gone since is taken as is, or for a worktree under
347// `.claude/worktrees/` the folder that held it.
348async function repoOf($: EngineInterface, cwd: string, known: Map<string, string>): Promise<string> {
349 if (cwd === '') {
350 return ''
351 }
352 const found = known.get(cwd)
353 if (found !== undefined) {
354 return found
355 }
356 let repo = cwd.split('/.claude/worktrees/')[0] ?? cwd
357 try {
358 const run = await $.process.run(['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { timeoutMs: 5000 })
359 const common = run.stdout.trim()
360 if (run.exitCode === 0 && common.endsWith('/.git')) {
361 repo = common.slice(0, -'/.git'.length)
362 }
363 } catch {
364 // No process here (desktop): keep the guess.
365 }
366 known.set(cwd, repo)
367
368 return repo
369}
370
371// The spawns of a session from `<dir>/subagents/agent-<id>.jsonl` and their `.meta.json`.
372async function readSpawns($: EngineInterface, dir: string, skip: (path: string, why: string) => void): Promise<Spawn[]> {
373 const entries = await $.fs.list(`${dir}/subagents`).catch(() => [])
374 const spawns: Spawn[] = []
375 for (const entry of entries.filter(item => item.kind === 'file' && /^agent-.+\.jsonl$/.test(item.name))) {
376 const path = `${dir}/subagents/${entry.name}`
377 try {
378 const text = await readLarge($, path, entry.size)
379 if (text === undefined) {
380 skip(path, 'too large to read here')
381 continue
382 }
383 const meta = await $.fs
384 .read(path.replace(/\.jsonl$/, '.meta.json'))
385 .then(raw => JSON.parse(raw as string) as unknown)
386 .catch(() => undefined)
387 const spawn = parseSubagent(meta, text, entry.name.slice('agent-'.length, -'.jsonl'.length))
388 if (spawn !== undefined) {
389 spawns.push(spawn)
390 }
391 } catch (error) {
392 skip(path, String(error))
393 }
394 }
395
396 return spawns
397}
398
399// Rebuilds a ledger from the transcript of each session on this machine that has none (ADR 0025),
400// one file at a time, then writes the day rows. Unless `force`, only once per store: a run another
401// session marked `running` within BACKFILL_STALE_MS is left to it. Answers how many sessions it
402// added, or undefined when it did not run.
403async function backfill($: EngineInterface, lang: Lang, force: boolean): Promise<number | undefined> {
404 const started = await $.clock.now()
405 const mark = (await $.store.get(BACKFILL_KEY)) as BackfillMark | undefined
406 if (!force && (mark?.state === 'done' || (mark?.state === 'running' && started - mark.at < BACKFILL_STALE_MS))) {
407 return undefined
408 }
409 await $.store.set(BACKFILL_KEY, { state: 'running', at: started, sessions: 0, skipped: 0 } satisfies BackfillMark)
410 const root = `${await configIO($).configDir()}/projects`
411 const own = await $.session.id()
412 const repos = new Map<string, string>()
413 const redo = new Set<string>()
414 let sessions = 0
415 let skipped = 0
416 let since = Infinity
417 const skip = (path: string, why: string) => {
418 skipped += 1
419 $.ui.log(`backfill: skipped ${path}: ${why}`, { to: 'debug' })
420 }
421 const projects = await $.fs.list(root).catch((error: unknown) => {
422 $.ui.log(`backfill: ${root} not read: ${String(error)}`, { to: 'debug' })
423
424 return undefined
425 })
426 // Nothing to read is not done: a later session tries again.
427 if (projects === undefined) {
428 await $.store.delete(BACKFILL_KEY)
429
430 return undefined
431 }
432 for (const project of projects.filter(item => item.kind === 'dir')) {
433 const dir = `${root}/${project.name}`
434 const files = await $.fs.list(dir).catch(() => [])
435 for (const file of files.filter(item => item.kind === 'file' && item.name.endsWith('.jsonl'))) {
436 const sid = file.name.slice(0, -'.jsonl'.length)
437 const key = `${LEDGER_PREFIX}${sid}`
438 if (sid === own) {
439 continue
440 }
441 try {
442 if ((await $.store.get(key)) !== undefined) {
443 continue
444 }
445 const text = await readLarge($, `${dir}/${file.name}`, file.size)
446 if (text === undefined) {
447 skip(`${dir}/${file.name}`, 'too large to read here')
448 continue
449 }
450 const parsed = parseTranscript(text)
451 const spawns = await readSpawns($, `${dir}/${sid}`, skip)
452 if (parsed.steps.length === 0 && spawns.length === 0) {
453 continue
454 }
455 const ledger = transcriptLedger(parsed, spawns, await repoOf($, parsed.cwd, repos))
456 // A live session may have written its own since: its record wins.
457 if ((await $.store.get(key)) !== undefined) {
458 continue
459 }
460 await $.store.set(key, ledger)
461 sessions += 1
462 since = Math.min(since, ledger.started || ledger.updated)
463 for (const day of recordedDays(ledger)) {
464 redo.add(day)
465 }
466 } catch (error) {
467 skip(`${dir}/${file.name}`, String(error))
468 }
469 }
470 }
471 // The day rows before the mark: should they fail, the mark stays `running` and a later session
472 // redoes it all once it is stale.
473 await writeDayRows($, redo)
474 await $.store.set(BACKFILL_KEY, { state: 'done', at: await $.clock.now(), sessions, skipped } satisfies BackfillMark)
475 $.ui.log(`backfill: ${sessions} session(s) added, ${skipped} file(s) skipped`, { to: 'debug' })
476 if (sessions > 0) {
477 const from = dayLabel(lang, since)
478 $.ui.toast(
479 pick(lang, {
480 en: `${MOD}: added ${sessions} session(s) from the transcripts (since ${from})`,
481 'zh-TW': `${MOD}:已從 transcript 補上 ${sessions} 個 session(${from} 起)`,
482 }),
483 )
484 }
485
486 return sessions
487}
488
489// `stats [--text] [days]`: opens the pane, or prints the report with `--text`, in a `-p` run
490// or where the pane cannot be placed (a narrow terminal).
491async function stats($: EngineInterface, lang: Lang, args: string[], interactive: boolean): Promise<string> {
492 const text = args.includes('--text')
493 const rest = args.filter(arg => arg !== '--text')
494 const days = rest[0] === undefined ? DEFAULT_DAYS : Number(rest[0])
495 if (rest.length > 1 || !Number.isInteger(days) || days < 1 || days > MAX_DAYS) {
496 return pick(lang, {
497 en: `Usage: /${MOD} ${STATS} [--text] [days], days from 1 to ${MAX_DAYS} (default ${DEFAULT_DAYS})`,
498 'zh-TW': `用法:/${MOD} ${STATS} [--text] [天數],天數 1 到 ${MAX_DAYS}(預設 ${DEFAULT_DAYS})`,
499 })
500 }
501 if (!text && interactive) {
502 await update($, view, current => ({ ...current, days, end: '', item: '', limit: PAGE }))
503 const opened = await $.ui.open({ id: PANE, title: pick(lang, { en: 'subagent-dispatch usage', 'zh-TW': 'subagent-dispatch 用量' }), focus: true, closeOnEscape: true })
504 if (opened.isPlaced) {
505 return pick(lang, { en: 'Opened the usage pane (Esc closes it).', 'zh-TW': '已開啟用量 pane(Esc 關閉)。' })
506 }
507 $.ui.log(`stats: pane not placed (${opened.reason}), printing the text report`, { to: 'debug' })
508 await $.ui.close({ id: PANE }).catch(() => undefined)
509 }
510
511 return renderStats(lang, await loadReport($, days))
512}
513
514// Starts a backfill in the background unless one is running in this process; a pane open meanwhile
515// redraws with what it added. Answers whether it started one.
516function startBackfill($: EngineInterface, lang: Lang, state: LedgerState, force: boolean): boolean {
517 if (state.backfilling !== undefined) {
518 return false
519 }
520 state.backfilling = backfill($, lang, force)
521 .then(added => {
522 if (added !== undefined && added > 0) {
523 redrawStats($, state)
524 }
525 })
526 .catch(error => $.ui.log(`backfill: stopped: ${String(error)}`, { to: 'debug' }))
527 .finally(() => {
528 state.backfilling = undefined
529 })
530
531 return true
532}
533
534// How many cells of `width` a bar for `part` of `most` fills: at least one when there is any.
535function barCells(part: number, most: number, width: number): number {
536 return part <= 0 ? 0 : Math.max(1, Math.round((part / most) * width))
537}
538
539// One colored part of a summary's bar and of the line above it: `ink` on the terminal, `fill` on desktop.
540type Part = { key: string; label: string; value: number; ink: string; fill: string }
541
542// `width` cells shared out by `values`, each part with any value at least one cell.
543function splitCells(values: number[], width: number): number[] {
544 const total = values.reduce((sum, value) => sum + value, 0)
545 if (total === 0) {
546 return values.map(() => 0)
547 }
548 const cells = values.map(value => (value > 0 ? Math.max(1, Math.round((value / total) * width)) : 0))
549 // Rounding can miss the width by a cell or two: the largest part takes the difference.
550 const largest = cells.indexOf(Math.max(...cells))
551 cells[largest] = (cells[largest] ?? 0) + width - cells.reduce((sum, cell) => sum + cell, 0)
552
553 return cells
554}
555
556function svgStack(parts: { cells: number; fill: string }[]): string {
557 const unit = 8
558 let x = 0
559 const rects = parts.map(part => {
560 const rect = part.cells === 0 ? '' : `<rect x="${x * unit}" width="${part.cells * unit}" height="8" fill="${part.fill}"/>`
561 x += part.cells
562
563 return rect
564 })
565
566 return `<svg xmlns="http://www.w3.org/2000/svg" width="${x * unit}" height="8">${rects.join('')}</svg>`
567}
568
569// The heatmap on desktop: a 10px cell every 12px, a week a column, the month row above and the
570// weekday names at the left, each cell colored by its use alone; under the grid, the bracket and
571// length of the period from `from` to `to`, or for one day (`one`) a `▲` and its `name`. Colors as
572// DESIGN.md「熱圖色階」sets them.
573function svgHeatmap(lang: Lang, days: HeatDay[], from: string, to: string, one: boolean, level: (value: number | null) => number, length: string, name: string): string {
574 const [left, top, pitch, size] = [28, 14, 12, 10]
575 const weeks = Math.ceil(days.length / 7)
576 const width = left + weeks * pitch
577 const rects = days.map((item, i) => {
578 const x = left + Math.floor(i / 7) * pitch
579 const y = top + (i % 7) * pitch
580 if (one && item.day === to) {
581 return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2" fill="${HEAT.chosen}"/>`
582 }
583 if (item.tokens === null) {
584 return `<rect x="${x + 0.5}" y="${y + 0.5}" width="${size - 1}" height="${size - 1}" rx="2" fill="none" stroke="${COLORS.default.fill}" stroke-dasharray="2 2"/>`
585 }
586 if (item.tokens === 0) {
587 return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2" fill="${COLORS.default.fill}" fill-opacity="0.25"/>`
588 }
589
590 return `<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="2" fill="${COLORS.sub.fill}" fill-opacity="${[0.35, 0.55, 0.75, 1][level(item.tokens) - 1]}"/>`
591 })
592 const text = (x: number, y: number, words: string, extra = '') =>
593 `<text x="${x}" y="${y}" font-size="9" fill="${COLORS.default.fill}" font-family="sans-serif"${extra}>${words}</text>`
594 const months = monthMarks(lang, days).map(mark => text(left + mark.week * pitch, 9, mark.label))
595 const names = weekdayLabels(lang).flatMap((label, row) => (label === '' ? [] : [text(0, top + row * pitch + 8, label)]))
596 // The period's weeks; none where it ends before the first.
597 const last = days.findIndex(item => item.day === to)
598 const firstAt = days.findIndex(item => item.day >= from)
599 const bottom = top + 7 * pitch
600 const marks: string[] = []
601 if (last >= 0 && firstAt >= 0) {
602 const [w0, w1] = [Math.floor(firstAt / 7), Math.floor(last / 7)]
603 const [x0, x1] = [left + w0 * pitch, left + w1 * pitch + size]
604 if (one) {
605 // About 6px a cell; where the name runs past the edge it goes before the mark.
606 const after = `▲ ${name}`
607 const fits = x0 + cells(after) * 6 <= width
608 marks.push(
609 `<text x="${fits ? x0 : x1}" y="${bottom + 12}" font-size="10" font-weight="700" fill="${HEAT.chosen}" font-family="sans-serif"${fits ? '' : ' text-anchor="end"'}>${fits ? after : `${name} ▲`}</text>`,
610 )
611 } else {
612 marks.push(`<path d="M${x0} ${bottom + 1}V${bottom + 6}H${x1}V${bottom + 1}" fill="none" stroke="#a8a8b0"/>`)
613 marks.push(`<text x="${(x0 + x1) / 2}" y="${bottom + 17}" font-size="10" fill="#d4d4d4" font-family="sans-serif" text-anchor="middle">${length}</text>`)
614 }
615 }
616
617 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${bottom + 22}">${[...months, ...names, ...rects, ...marks].join('')}</svg>`
618}
619
620// A spawn's key in the pane: its agent id, or for one recorded without, when it started.
621function spawnId(spawn: Spawn): string {
622 return spawn.agentId ?? String(spawn.at)
623}
624
625// Where a spawn's model came from: forked, named, or the default.
626function kindColor(spawn: Spawn): { ink: string; fill: string } {
627 return spawn.fork ? COLORS.fork : spawn.requested !== undefined ? COLORS.named : COLORS.default
628}
629
630// --- configIO: the same block in every mod (scripts/check-copies.sh) ---
631function configIO($: EngineInterface): ConfigIO {
632 return {
633 read: async path => {
634 try {
635 return await $.fs.read(path)
636 } catch {
637 return ''
638 }
639 },
640 write: (path, text) => $.fs.write(path, text),
641 isDir: async path => (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir',
642 configDir: async () => (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`,
643 root: () => $.session.root(),
644 repoRoot: async () => (await $.session.repo())?.root,
645 sessionId: () => $.session.id(),
646 now: () => $.clock.now(),
647 storeGet: key => $.store.get(key),
648 storeSet: (key, value) => $.store.set(key, value),
649 }
650}
651// --- end configIO ---
652
653export const register: Register = (on, options) => {
654 // Re-read at session start and after `/subagent-dispatch set`.
655 let lang: Lang = 'en'
656 // A `-p` run has no pane: `stats` prints its report there.
657 let interactive = true
658 const ledger: LedgerState = { recording: true, writing: Promise.resolve(), sid: '', meta: { repo: '', started: 0, prompt: '' }, agents: new Map(), redraw: false }
659
660 on('session.start', async ($, e, next) => {
661 interactive = e.isInteractive
662 try {
663 const config = await loadConfig(configIO($), MOD, SPECS, options)
664 lang = config.lang
665 ledger.recording = config.values.ledger !== false
666 } catch (error) {
667 $.ui.log(`session.start: settings not read: ${String(error)}`, { to: 'debug' })
668 }
669 try {
670 ledger.meta = { ...ledger.meta, repo: (await $.session.repo())?.root ?? e.cwd, started: await $.clock.now() }
671 } catch (error) {
672 $.ui.log(`session.start: repo not read: ${String(error)}`, { to: 'debug' })
673 }
674 // The day rows first: a ledger pruned now has its days in rows already.
675 try {
676 await writeDayRows($)
677 } catch (error) {
678 $.ui.log(`session.start: day rows not written: ${String(error)}`, { to: 'debug' })
679 }
680 try {
681 await pruneLedgers($)
682 } catch (error) {
683 $.ui.log(`session.start: ledgers not pruned: ${String(error)}`, { to: 'debug' })
684 }
685 try {
686 await $.command.register({
687 name: MOD,
688 description: pick(lang, {
689 en: 'Put the redispatch instructions for interrupted background agents in the prompt box (redispatch), or show token use by subagents and models (stats)',
690 'zh-TW': '把被中斷的背景 agent 重派指示放進輸入框(redispatch),或看 subagent 與各模型的 token 用量(stats)',
691 }),
692 argumentHint: pick(lang, { en: 'redispatch | stats [--text] [days] | backfill | status', 'zh-TW': 'redispatch | stats [--text] [天數] | backfill | status' }),
693 })
694 } catch (error) {
695 $.ui.log(`session.start: /${MOD} not registered: ${String(error)}`, { to: 'debug' })
696 $.ui.toast(pick(lang, { en: `${MOD}: /${MOD} was not registered (${String(error)})`, 'zh-TW': `${MOD}:/${MOD} 指令沒有註冊成功(${String(error)})` }))
697 }
698 $.clock.every(POLL_MS, () => void refresh($, lang))
699 // No hook runs at install: the first session to find no mark reads the transcripts, in the
700 // background. A `-p` run may end before it does, so it leaves them to the next session.
701 if (ledger.recording && interactive) {
702 startBackfill($, lang, ledger, false)
703 }
704
705 return next(e)
706 })
707
708 on('command.run', { command: MOD }, async ($, e) => {
709 if (e.args.trim().split(/\s+/)[0] === REDISPATCH) {
710 return { text: await fillRedispatch($, lang) }
711 }
712 const [command, ...rest] = e.args.trim().split(/\s+/)
713 if (command === STATS) {
714 ledger.snapshot = undefined
715
716 return { text: await stats($, lang, rest, interactive) }
717 }
718 if (command === BACKFILL) {
719 if (!ledger.recording) {
720 return { text: pick(lang, { en: 'The ledger setting is off; nothing to backfill into.', 'zh-TW': 'ledger 設定已關閉,不回填。' }) }
721 }
722
723 return {
724 text: startBackfill($, lang, ledger, true)
725 ? pick(lang, {
726 en: 'Reading the transcripts of sessions with no usage details, in the background; a toast says how many were added.',
727 'zh-TW': '在背景讀取還沒有用量明細的 session 的 transcript,補上後會跳 toast 說補了幾個。',
728 })
729 : pick(lang, { en: 'A backfill is already running.', 'zh-TW': '回填已經在跑。' }),
730 }
731 }
732 const text = await configCommand(configIO($), MOD, SPECS, options, e.args, resolved => {
733 lang = resolved.lang
734 ledger.recording = resolved.values.ledger !== false
735 })
736
737 return {
738 text:
739 text ??
740 pick(lang, {
741 en: `Usage: /${MOD} ${REDISPATCH}, /${MOD} ${STATS} [--text] [days], /${MOD} ${BACKFILL}, /${MOD} status`,
742 'zh-TW': `用法:/${MOD} ${REDISPATCH}、/${MOD} ${STATS} [--text] [天數]、/${MOD} ${BACKFILL}、/${MOD} status`,
743 }),
744 }
745 })
746
747 on('agent.spawn', async ($, e, next) => {
748 const spawned = await next(e)
749 if (spawned.deny !== undefined) {
750 return spawned
751 }
752 await record($, ledger, 'agent.spawn', (entries, at) =>
753 recordSpawn(entries, at, {
754 ...(spawned.agentId === undefined ? {} : { agentId: spawned.agentId }),
755 type: e.subagentType,
756 model: spawned.model,
757 ...(e.model === undefined ? {} : { requested: e.model }),
758 fork: e.fork || e.subagentType === 'fork',
759 background: e.background,
760 description: e.description,
761 }),
762 )
763 if (spawned.agentId !== undefined && ledger.recording) {
764 ledger.agents.set(spawned.agentId, await sessionKey($, ledger))
765 for (const old of [...ledger.agents.keys()].slice(0, -AGENT_KEYS)) {
766 ledger.agents.delete(old)
767 }
768 }
769
770 return spawned
771 })
772
773 // The session is named after its first prompt that is not a slash command; a resumed one
774 // keeps the name it has. Mods cannot read the session's own title.
775 on('prompt.submit', async ($, e, next) => {
776 await sessionKey($, ledger).catch(() => undefined)
777 if (ledger.meta.prompt === '' && ['composer', 'bridge', 'sdk'].includes(e.origin.kind) && !e.text.trimStart().startsWith('/')) {
778 ledger.meta = { ...ledger.meta, prompt: promptSummary(e.text) }
779 }
780
781 return next(e)
782 })
783
784 on('turn.step', async function* ($, e, next) {
785 const result = yield* next(e)
786 const usage = result.usage
787 if (usage !== null) {
788 await record($, ledger, 'turn.step', (entries, at) =>
789 recordStep(entries, at, {
790 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
791 model: usage.model || e.model,
792 input: usage.input_tokens,
793 cacheWrite: usage.cache_creation_input_tokens,
794 cacheRead: usage.cache_read_input_tokens,
795 output: usage.output_tokens,
796 }),
797 e.agentId,
798 )
799 }
800
801 return result
802 })
803
804 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
805 const ran = await next(e)
806 const result = ran.result as { status?: string; agentId?: string; resolvedModel?: string } | undefined
807 if (e.agentId === undefined && result?.status === 'async_launched' && result.agentId) {
808 const agent: WatchedAgent = {
809 id: result.agentId,
810 description: e.description,
811 type: e.subagent_type ?? 'general-purpose',
812 startedAt: await $.clock.now(),
813 status: 'running',
814 ...((e.model ?? result.resolvedModel) ? { model: e.model ?? result.resolvedModel } : {}),
815 }
816 await update($, running, list => [...list, agent])
817 await showStatus($, lang)
818 }
819
820 return ran
821 }).catch(($, e, next) => next(e))
822
823 // The band is one instance shared by every plugin: draw above what the plugins beneath drew
824 // (open-items), never in place of it.
825 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
826 const lost = await read($, stopped)
827 if (e.props.hasSurvey || lost.length === 0) {
828 return next(e)
829 }
830 const below = await next(e)
831 const { Box, Button, Text } = $.ui.resolve(e)
832
833 return (
834 <Box flexDirection="column">
835 <Box flexDirection="row">
836 <Text color={TONE.danger} wrap="truncate-end">
837 {pick(lang, { en: `${lost.length} agent(s) interrupted`, 'zh-TW': `${lost.length} 個 agent 被中斷` })}
838 {pick(lang, { en: ': ', 'zh-TW': ':' })}
839 {lost.map(agent => agent.description).join(pick(lang, { en: ', ', 'zh-TW': '、' }))}{' '}
840 </Text>
841 <Button key="redispatch" label={pick(lang, { en: 'Redispatch', 'zh-TW': '重派' })} hotkey="r" onPress={() => void fillRedispatch($, lang)} />
842 <Button key="dismiss" label={pick(lang, { en: 'Dismiss', 'zh-TW': '略過' })} hotkey="d" onPress={() => void update($, stopped, () => [])} />
843 </Box>
844 {below}
845 </Box>
846 )
847 })
848
849 // Laid out by the body's columns, never padded with spaces (a CJK character takes two cells).
850 // The overview: the heatmap, the period, its summary and a row a line; a row opens to its spawns,
851 // Back returns.
852 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
853 const parts = $.ui.resolve(e)
854 const { Box, Button, Text } = parts
855 // Desktop draws the bars as Svg; the terminal has no Svg and gets colored spaces. The runtime
856 // hands every constructor over, so the surface decides, not their presence.
857 const Svg = e.surface !== 'terminal' && 'Svg' in parts ? parts.Svg : undefined
858 const shown = await read($, view)
859 const by = (GROUP_BYS as readonly string[]).includes(shown.by) ? (shown.by as GroupBy) : 'repo'
860 const now = await $.clock.now()
861 const store = await paneStore($, ledger)
862 const body = e.props.bodyColumns
863 const today = dayOf(now)
864 // The period: `days` days to its last day, today unless moved back, never before the day rows reach.
865 const oldest = addDays(today, -(DAY_KEEP - 1))
866 const days = Number.isInteger(shown.days) && shown.days >= 1 && shown.days <= MAX_DAYS ? shown.days : DEFAULT_DAYS
867 const end = typeof shown.end === 'string' && shown.end >= oldest && shown.end < today ? shown.end : today
868 const begin = addDays(end, -(days - 1))
869 const report = windowReport(store.ledgers, store.dayRows, begin, end, now)
870 const heat = buildHeatmap(store.ledgers, store.dayRows, MAX_WEEKS, now).days
871 const all = report.main + report.sub
872 const rows = report.kept ? groups(lang, report, by) : []
873 const open = rows.find(row => row.key === shown.item)
874 const limit = Math.max(PAGE, Number(shown.limit) || PAGE)
875 const crumbLabel: Record<GroupBy, string> = {
876 repo: 'repo',
877 session: 'session',
878 model: pick(lang, { en: 'model', 'zh-TW': '模型' }),
879 type: pick(lang, { en: 'type', 'zh-TW': '種類' }),
880 }
881 const switcher = (key: string, label: string, hotkey: string, options: { value: string; label: string }[], value: string, onPick: (value: string) => void) =>
882 switchIn(parts, e.surface, key, label, hotkey, options, value, onPick)
883 // The period's days: one day by its name, more by their first and last.
884 const periodName = (tight = false) => (days === 1 ? dayHead(lang, end) : span(lang, begin, end, tight))
885 // As in open-items: the command's name over a dim rule at the top, here with when the ledgers were
886 // read at its right (the time alone where both do not fit), and the hint line at the foot. A dock
887 // is as tall as its room, so the frame fills it; an inline pane keeps its own height.
888 const heading = `${MOD} ${STATS}`
889 const updated = pick(lang, { en: `Updated ${clockTime(store.at)}`, 'zh-TW': `更新於 ${clockTime(store.at)}` })
890 const stamped = cells(heading) + cells(updated) + 6 <= body ? updated : clockTime(store.at)
891 const room = e.props.placement === 'dock' ? (e.props.scroll as { bodyRows?: number } | undefined)?.bodyRows : undefined
892 const frame = room === undefined ? {} : { minHeight: room }
893 const title = (
894 <Box key="title" flexDirection="row" gap={1} marginBottom={1}>
895 <Text bold>{heading}</Text>
896 <Text dimColor>{'─'.repeat(Math.max(1, body - cells(heading) - cells(stamped) - 2))}</Text>
897 <Text dimColor>{stamped}</Text>
898 </Box>
899 )
900 // A dim rule across the pane, under a list's column names or over an opened row's list.
901 const rule = (
902 <Box key="list-rule">
903 <Text dimColor>{'─'.repeat(body)}</Text>
904 </Box>
905 )
906 // A session by its first prompt.
907 const promptOf = (session: Report['sessions'][number]) => session.prompt || pick(lang, { en: '(no prompt)', 'zh-TW': '(沒有 prompt)' })
908
909 // A bar of `width` cells shared by `of`: colored spaces in the terminal, an Svg on desktop.
910 const stack = (of: Part[], width: number) => {
911 const shares = splitCells(of.map(part => part.value), width)
912 if (Svg !== undefined) {
913 return <Svg source={svgStack(of.map((part, i) => ({ cells: shares[i] ?? 0, fill: part.fill })))} alt={of.map(part => `${part.label} ${n(part.value)}`).join(', ')} width={width * 8} height={8} />
914 }
915
916 return <Text>{of.map((part, i) => ((shares[i] ?? 0) === 0 ? '' : <Text backgroundColor={part.ink}>{' '.repeat(shares[i] ?? 0)}</Text>))}</Text>
917 }
918 // A list row's bar: `cells` of `width` in `▆`, lines apart from the rows above and below
919 // (DESIGN.md「長條」), parts in their own colors.
920 const rowBar = (of: { cells: number; ink: string; fill: string }[], width: number) => {
921 if (Svg !== undefined) {
922 return <Svg source={svgStack(of)} alt="" width={Math.max(1, of.reduce((sum, part) => sum + part.cells, 0)) * 8} height={8} />
923 }
924
925 return <Text>{of.map(part => (part.cells === 0 ? '' : <Text color={part.ink}>{'▆'.repeat(part.cells)}</Text>))}</Text>
926 }
927 // A figure in two lines: its total and unit, what it is made of at the right where that fits, and
928 // a bar the width of the pane.
929 const figure = (key: string, total: string, unit: string, of: Part[], listed: Part[], count: (value: number) => string) => {
930 const aside = listed.map(part => `${part.label} ${count(part.value)}`).join(' ')
931 const fits = cells(`${total} ${unit}`) + 2 + cells(aside) <= body
932
933 return [
934 <Box key={`${key}-line`} flexDirection="row" justifyContent="space-between">
935 <Text>
936 <Text bold>{total}</Text> <Text dimColor>{unit}</Text>
937 </Text>
938 {fits ? <Text>{listed.map((part, i) => [i === 0 ? '' : ' ', <Text color={part.ink}>{part.label}</Text>, ` ${count(part.value)}`])}</Text> : null}
939 </Box>,
940 <Box key={`${key}-bar`}>{stack(of, body)}</Box>,
941 ]
942 }
943 const tokenParts = (main: number, sub: number): Part[] => [
944 { key: 'main', label: pick(lang, { en: 'main', 'zh-TW': '主' }), value: main, ...COLORS.main },
945 { key: 'sub', label: 'sub', value: sub, ...COLORS.sub },
946 ]
947 // An opened session's main tokens by model, most first, in alternating shades of purple.
948 const modelParts = (byModel: [string, number][]): Part[] =>
949 [...byModel]
950 .sort((a, b) => b[1] - a[1])
951 .map(([model, value], i) => ({ key: `main:${model}`, label: model, value, ...MAIN_SHADES[i % MAIN_SHADES.length]! }))
952 const kindParts = (s: Report['spawned']): Part[] => [
953 { key: 'named', label: pick(lang, { en: 'named', 'zh-TW': '指定' }), value: s.explicit, ...COLORS.named },
954 { key: 'default', label: pick(lang, { en: 'default', 'zh-TW': '預設' }), value: s.defaults, ...COLORS.default },
955 { key: 'fork', label: 'fork', value: s.forks, ...COLORS.fork },
956 ]
957 // The tokens and the spawns, each a figure, then the cache read they leave out.
958 const summary = (made: Part[], spawned: Report['spawned'], cacheRead: number) => (
959 <Box key="summary" flexDirection="column" marginTop={1}>
960 {figure('tokens', shortCount(made.reduce((sum, part) => sum + part.value, 0)), 'token', made, made.filter(part => part.value > 0), shortCount)}
961 {spawned.all === 0 ? null : figure('spawns', String(spawned.all), pick(lang, { en: 'spawns', 'zh-TW': '次派出' }), kindParts(spawned), kindParts(spawned), String)}
962 <Box key="cache-read">
963 <Text dimColor>{pick(lang, { en: `Cache read ${shortCount(cacheRead)}, not counted`, 'zh-TW': `快取讀取 ${shortCount(cacheRead)} 另計` })}</Text>
964 </Box>
965 </Box>
966 )
967 const note = (key: string, text: string) => (
968 <Box key={key} marginTop={1}>
969 {emptyState(parts, text)}
970 </Box>
971 )
972
973 if (open !== undefined) {
974 const sort: SortBy = shown.sort in SORTS ? (shown.sort as SortBy) : 'tokens'
975 const listed = open.detail
976 .flatMap(part => part.spawns.map(spawn => ({ spawn, session: part.session })))
977 .sort((a, b) => SORTS[sort](b.spawn) - SORTS[sort](a.spawn))
978 const openSub = open.tokens - open.main
979 const counted = countKinds(listed.map(item => item.spawn))
980 const top = Math.max(1, ...listed.map(item => tokens(item.spawn)))
981 const barWidth = body >= WIDE_COLUMNS ? 16 : 8
982 // A row with no spawns lists its sessions instead, by tokens or by when they started.
983 const bySessions = listed.length === 0
984 const sessionRows = bySessions ? [...open.detail].sort((a, b) => (sort === 'time' ? b.session.started - a.session.started : b.main - a.main)) : []
985 const topMain = Math.max(1, ...sessionRows.map(part => part.main))
986 const count = bySessions ? sessionRows.length : listed.length
987 const cellsOf = (part: number, whole: number) => barCells(part, whole, barWidth)
988 // What a row's name leaves the rest: the dot, the bar, the tokens (9), the date (6) and four gaps.
989 // The name is cut to it by cells, with two to spare for what the terminal draws around a button:
990 // a Button's `truncate-end` wraps instead once CJK and Latin mix (DESIGN.md「控制項」).
991 const nameCells = Math.max(1, body - 20 - barWidth - 2)
992
993 return (
994 <Box flexDirection="column" {...frame}>
995 {title}
996 {/* The back button and the crumb keep their width; only the row's name gives way. */}
997 <Box key="crumb" flexDirection="row" gap={1}>
998 <Box flexShrink={0}>
999 <Button key="back" plain hotkey="b" onPress={() => void update($, view, current => ({ ...current, item: '', limit: PAGE }))}>
1000 <Text>{pick(lang, { en: '‹ Back', 'zh-TW': '‹ 返回' })}</Text>
1001 </Button>
1002 </Box>
1003 <Box flexShrink={0}>
1004 <Text dimColor> {crumbLabel[by]} ›</Text>
1005 </Box>
1006 <Box flexShrink={1} minWidth={0}>
1007 <Text bold wrap="truncate-end">
1008 {open.label}
1009 </Text>
1010 </Box>
1011 </Box>
1012 {/* The period as the overview set it, to read only. */}
1013 <Box key="period" marginTop={1}>
1014 <Text wrap="truncate-end">
1015 <Text dimColor>{pick(lang, { en: 'Period ', 'zh-TW': '期間 ' })}</Text>
1016 {daysLabel(lang, days)}
1017 <Text dimColor> · </Text>
1018 <Text bold>{periodName()}</Text>
1019 </Text>
1020 </Box>
1021 {summary(
1022 by === 'session' ? [...modelParts(open.detail.flatMap(part => part.session.mainByModel)), ...tokenParts(open.main, openSub).slice(1)] : tokenParts(open.main, openSub),
1023 counted,
1024 open.cacheRead,
1025 )}
1026 {/* Under a rule: the sort, the column names, then a row per spawn, or for a row with no spawns
1027 (a model only the main session used, or the main session by type) a row per session. */}
1028 <Box key="list" flexDirection="column" marginTop={1}>
1029 {rule}
1030 <Box>
1031 {switcher(
1032 'sort',
1033 pick(lang, { en: 'Sort ', 'zh-TW': '排序 ' }),
1034 's',
1035 [
1036 { value: 'tokens', label: pick(lang, { en: 'most tokens', 'zh-TW': 'token 多到少' }) },
1037 { value: 'time', label: pick(lang, { en: 'time', 'zh-TW': '時間' }) },
1038 ...(bySessions ? [] : [{ value: 'duration', label: pick(lang, { en: 'duration', 'zh-TW': '耗時' }) }]),
1039 ],
1040 sort,
1041 value => void update($, view, current => ({ ...current, sort: value, limit: PAGE })),
1042 )}
1043 </Box>
1044 <Box key="list-columns" flexDirection="row" gap={1} marginTop={1}>
1045 <Box width={1} flexShrink={0} />
1046 <Box flexGrow={1} flexShrink={1}>
1047 <Text dimColor>{bySessions ? 'session' : pick(lang, { en: 'Spawns', 'zh-TW': '派出' })}</Text>
1048 </Box>
1049 <Box width={barWidth} flexShrink={0} />
1050 <Box width={9} flexShrink={0} justifyContent="flex-end">
1051 <Text dimColor>token</Text>
1052 </Box>
1053 <Box width={6} flexShrink={0} justifyContent="flex-end">
1054 <Text dimColor>{pick(lang, { en: 'date', 'zh-TW': '日期' })}</Text>
1055 </Box>
1056 </Box>
1057 {sessionRows.slice(0, limit).map(part => {
1058 const id = `${part.session.repo}|${part.session.started}`
1059
1060 return (
1061 <Box key={`session:${id}`} flexDirection="row" gap={1}>
1062 <Box width={1} flexShrink={0}>
1063 <Text color={COLORS.main.ink}>●</Text>
1064 </Box>
1065 <Box flexGrow={1} flexShrink={1}>
1066 <Text wrap="truncate-end">{fit(promptOf(part.session), nameCells)}</Text>
1067 </Box>
1068 <Box width={barWidth} flexShrink={0}>
1069 {rowBar([{ cells: cellsOf(part.main, topMain), ...COLORS.main }], barWidth)}
1070 </Box>
1071 <Box width={9} flexShrink={0} justifyContent="flex-end">
1072 <Text>{n(part.main)}</Text>
1073 </Box>
1074 <Box width={6} flexShrink={0} justifyContent="flex-end">
1075 <Text dimColor>{dayLabel(lang, part.session.started)}</Text>
1076 </Box>
1077 </Box>
1078 )
1079 })}
1080 {listed.slice(0, limit).map(({ spawn, session }) => {
1081 const id = spawnId(spawn)
1082 const color = kindColor(spawn)
1083 const how = spawn.fork ? 'fork' : spawn.requested === undefined ? pick(lang, { en: 'default', 'zh-TW': '預設' }) : pick(lang, { en: 'named', 'zh-TW': '指定' })
1084 const lines: [string, string, RenderNode][] = [
1085 ['type', pick(lang, { en: 'Type', 'zh-TW': '種類' }), spawn.type],
1086 [
1087 'model',
1088 pick(lang, { en: 'Model', 'zh-TW': '模型' }),
1089 <Text wrap="truncate-end">
1090 <Text color={color.ink}>{how}</Text>
1091 {spawn.requested === undefined || spawn.fork ? '' : ` ${spawn.requested}`} → {spawn.model}
1092 </Text>,
1093 ],
1094 [
1095 'run',
1096 pick(lang, { en: 'Run', 'zh-TW': '執行' }),
1097 [
1098 ...(spawn.background ? [pick(lang, { en: 'background', 'zh-TW': '背景' })] : []),
1099 pick(lang, { en: `${spawn.steps} steps`, 'zh-TW': `${spawn.steps} 步` }),
1100 elapsed(spawn.last - spawn.at),
1101 ].join(' · '),
1102 ],
1103 ['session', 'session', sessionName(lang, session)],
1104 ]
1105
1106 return [
1107 <Box key={`line:${id}`} flexDirection="row" gap={1}>
1108 <Box key={`dot:${id}`} width={1} flexShrink={0}>
1109 <Text color={kindColor(spawn).ink}>●</Text>
1110 </Box>
1111 <Box flexGrow={1} flexShrink={1}>
1112 <Button key={`spawn:${id}`} plain onPress={() => void update($, view, current => ({ ...current, spawn: current.spawn === id ? '' : id }))}>
1113 <Text>{fit(spawnParts(lang, spawn)[0], nameCells)}</Text>
1114 </Button>
1115 </Box>
1116 <Box width={barWidth} flexShrink={0}>
1117 {rowBar([{ cells: cellsOf(tokens(spawn), top), ...COLORS.sub }], barWidth)}
1118 </Box>
1119 <Box width={9} flexShrink={0} justifyContent="flex-end">
1120 <Text>{n(tokens(spawn))}</Text>
1121 </Box>
1122 <Box width={6} flexShrink={0} justifyContent="flex-end">
1123 <Text dimColor>{dayLabel(lang, spawn.at)}</Text>
1124 </Box>
1125 </Box>,
1126 shown.spawn === id ? (
1127 // Opened in place: a line in the color of where its model came from runs down the left
1128 // (a Box border cannot be drawn on one side only).
1129 <Box key={`detail:${id}`} flexDirection="column" marginLeft={1} marginTop={1} marginBottom={1}>
1130 {lines.map(([key, name, value]) => (
1131 <Box key={`detail:${id}:${key}`} flexDirection="row" gap={1}>
1132 <Box width={2} flexShrink={0}>
1133 <Text color={color.ink}>│</Text>
1134 </Box>
1135 <Box width={8} flexShrink={0}>
1136 <Text dimColor>{name}</Text>
1137 </Box>
1138 <Box flexShrink={1}>{typeof value === 'string' ? <Text wrap="truncate-end">{value}</Text> : value}</Box>
1139 </Box>
1140 ))}
1141 </Box>
1142 ) : null,
1143 ]
1144 })}
1145 {count > limit ? (
1146 <Box paddingLeft={2}>
1147 <Button key="more" plain hotkey="m" onPress={() => void update($, view, current => ({ ...current, limit: limit + PAGE }))}>
1148 <Text>{pick(lang, { en: `${count - limit} more ▸`, 'zh-TW': `還有 ${count - limit} 筆 ▸` })}</Text>
1149 </Button>
1150 </Box>
1151 ) : null}
1152 </Box>
1153 <Box key="spacer" flexGrow={1} />
1154 {hintLine(parts, lang, [
1155 // The back, sort and more buttons already show their keys.
1156 { en: 'Tab moves', 'zh-TW': 'Tab 移動' },
1157 ...(bySessions ? [] : [{ en: 'Enter opens a spawn', 'zh-TW': 'Enter 展開一筆' }]),
1158 ])}
1159 </Box>
1160 )
1161 }
1162
1163 // The heatmap: a cell a day, a week a column, colored by use alone (dimming would read as gray, as
1164 // no use does), and under it the period's mark beside as much of the legend as fits.
1165 const heatmap = () => {
1166 if (body < HEAT_COLUMNS) {
1167 return null
1168 }
1169 const weeks = heatWeeks(body)
1170 const first = heatStart(weeks, now)
1171 const drawn = heat.filter(item => item.day >= first)
1172 const level = heatLevels(drawn)
1173 const one = (day: string) => days === 1 && day === end
1174 const peak = drawn.reduce<HeatDay | undefined>((most, item) => ((item.tokens ?? 0) > (most?.tokens ?? 0) ? item : most), undefined)
1175 const ink = (item: HeatDay) => (one(item.day) ? HEAT.chosen : item.tokens === 0 ? HEAT.none : HEAT.levels[level(item.tokens) - 1])
1176 const weekOf = (day: string) => Math.floor((daysBetween(first, day) - 1) / 7)
1177 const lengthLabel = daysLabel(lang, days)
1178 const legend: { width: number; node: RenderNode }[] = [
1179 {
1180 width: cells(pick(lang, { en: 'Less ■■■■ More', 'zh-TW': '少 ■■■■ 多' })),
1181 node: (
1182 <Text>
1183 <Text dimColor>{pick(lang, { en: 'Less ', 'zh-TW': '少 ' })}</Text>
1184 {HEAT.levels.map(color => (
1185 <Text color={color}>■</Text>
1186 ))}
1187 <Text dimColor>{pick(lang, { en: ' More', 'zh-TW': ' 多' })}</Text>
1188 </Text>
1189 ),
1190 },
1191 {
1192 width: cells(pick(lang, { en: 'None ■', 'zh-TW': '無用量 ■' })),
1193 node: (
1194 <Text>
1195 <Text dimColor>{pick(lang, { en: 'None ', 'zh-TW': '無用量 ' })}</Text>
1196 <Text color={HEAT.none}>■</Text>
1197 </Text>
1198 ),
1199 },
1200 { width: cells(pick(lang, { en: 'No data ·', 'zh-TW': '無資料 ·' })), node: <Text dimColor>{pick(lang, { en: 'No data ·', 'zh-TW': '無資料 ·' })}</Text> },hooks/config.ts 792 lines1// Layered settings, shared by every mod: the same file sits in each mod's hooks folder (a mod
2// cannot import outside its own folder), and scripts/check-copies.sh keeps the copies equal.
3//
4// Precedence, highest first: session (`/<mod> set --session`), `.claude/cc-mod-trimtab.local.json`,
5// `.claude/cc-mod-trimtab.json` (committed, held to each key's repo rule), the user layer
6// (`/config` for single values, `~/.claude/cc-mod-trimtab.json` for lists), the default.
7// Every file holds `{ "<mod>": { "<key>": value } }`, and may hold a `"*"` section beside them: of
8// that one only `language` is read, as the value every mod shares (a mod's own section wins).
9//
10// A feature that spends tokens is a key with a `toggle`: `/<mod> on [feature] [duration]` sets its
11// session value until a time (2h by default), `/<mod> off [feature]` takes it back.
12
13import type { PluginOptions } from 'claude-code'
14
15export type Value = boolean | number | string | readonly string[]
16
17// The language of everything shown on screen. Text for the model is English whatever this is.
18export type Lang = 'en' | 'zh-TW'
19
20export type Text = Readonly<Record<Lang, string>>
21
22export function pick(lang: Lang, text: Text): string {
23 return text[lang]
24}
25
26// A description is one string (tests) or a pair.
27export function say(lang: Lang, text: string | Text): string {
28 return typeof text === 'string' ? text : text[lang]
29}
30
31// Where the model writes for the person (memories, handoffs), the prompt names the language.
32export function writeIn(lang: Lang): string {
33 return lang === 'zh-TW' ? 'Write in Traditional Chinese (zh-TW).' : 'Write in English.'
34}
35
36export type Layer = 'default' | 'user' | 'repo' | 'local' | 'session'
37
38// What the committed repo file may do: a clone must not loosen a protection or switch on spending.
39// `append` adds to the list below it; `never` leaves the key to the person's own layers.
40export type RepoRule = 'any' | 'only-true' | 'only-false' | 'only-lower' | 'only-higher' | 'append' | 'never'
41
42export type Category = 'protect' | 'cost' | 'plain'
43
44export type KeySpec = {
45 category: Category
46 type: 'boolean' | 'number' | 'string' | 'list'
47 default: Value
48 repo: RepoRule
49 description: string | Text
50 min?: number
51 max?: number
52 // The values a string may take.
53 choices?: readonly string[]
54 // A feature `/<mod> on|off` switches: the value each one sets.
55 toggle?: { on: Value; off: Value }
56}
57
58export type Specs = Readonly<Record<string, KeySpec>>
59
60// The key every mod has: the language of what it shows. One value may stand for all mods in a
61// settings file's `"*"` section. It is the person's own taste, so the repo file cannot set it.
62export const LANGUAGE: KeySpec = {
63 category: 'plain',
64 type: 'string',
65 default: 'en',
66 repo: 'never',
67 choices: ['en', 'zh-TW'],
68 description: {
69 en: 'Language of what this mod shows: en or zh-TW. Put {"*": {"language": "..."}} in a settings file to set it for every mod.',
70 'zh-TW': '這個 mod 顯示的語言:en 或 zh-TW。在設定檔放 {"*": {"language": "..."}} 可一次設定所有 mod。',
71 },
72}
73
74export type Ignored = { key: string; layer: Layer; reason: string }
75
76export type Resolved = {
77 // The language the values come to (`language`, English without it).
78 lang: Lang
79 values: Readonly<Record<string, Value>>
80 sources: Readonly<Record<string, Layer>>
81 ignored: readonly Ignored[]
82 // When each key switched on by `/<mod> on` goes back, for the keys whose value it decides.
83 until: Readonly<Record<string, number>>
84}
85
86export type Target = 'session' | 'local' | 'repo'
87
88type Raw = Readonly<Record<string, unknown>>
89
90export type Layers = {
91 options: PluginOptions
92 user: Raw
93 repo: Raw
94 local: Raw
95 session: Raw
96 // When the session values set by `/<mod> on` run out; the expired ones are left out already.
97 until?: Readonly<Record<string, number>>
98}
99
100export const REPO_FILE = '.claude/cc-mod-trimtab.json'
101export const LOCAL_FILE = '.claude/cc-mod-trimtab.local.json'
102const USER_FILE = 'cc-mod-trimtab.json'
103// Both keyed by session id: `$.store` is one file every session of the mod shares.
104const SESSION_KEY = 'config-sessions'
105const NOTICED_KEY = 'config-noticed-sessions'
106// The settings files' section for what every mod shares.
107const SHARED = '*'
108
109const DEFAULT_ON_MS = 2 * 3_600_000
110const MAX_ON_MS = 24 * 3_600_000
111// A session's entries are dropped once nothing wrote them for this long.
112const KEEP_SESSION_MS = 7 * 86_400_000
113
114const LAYER_NAMES: Readonly<Record<Layer, Text>> = {
115 default: { en: 'default', 'zh-TW': '預設' },
116 user: { en: 'user', 'zh-TW': '使用者' },
117 repo: { en: 'repo', 'zh-TW': 'repo' },
118 local: { en: 'local', 'zh-TW': 'local' },
119 session: { en: 'session', 'zh-TW': 'session' },
120}
121
122const CATEGORY_NAMES: Readonly<Record<Category, Text>> = {
123 protect: { en: 'protection', 'zh-TW': '保護' },
124 cost: { en: 'cost', 'zh-TW': '花費' },
125 plain: { en: 'general', 'zh-TW': '一般' },
126}
127
128const RULE_NAMES: Readonly<Record<RepoRule, Text>> = {
129 any: { en: 'repo may set freely', 'zh-TW': 'repo 可自由設定' },
130 'only-true': { en: 'repo may only turn on', 'zh-TW': 'repo 只能開啟' },
131 'only-false': { en: 'repo may only turn off', 'zh-TW': 'repo 只能關閉' },
132 'only-lower': { en: 'repo may only lower', 'zh-TW': 'repo 只能調低' },
133 'only-higher': { en: 'repo may only raise', 'zh-TW': 'repo 只能調高' },
134 append: { en: 'repo may only add items', 'zh-TW': 'repo 只能加項目' },
135 never: { en: 'repo may not set', 'zh-TW': 'repo 不能設定' },
136}
137
138function langOf(value: unknown): Lang {
139 return value === 'zh-TW' ? 'zh-TW' : 'en'
140}
141
142function listOf(lang: Lang, items: readonly string[]): string {
143 return items.join(lang === 'zh-TW' ? '、' : ', ')
144}
145
146// ---------- pure ----------
147
148// A stored value of the key's type, or why it is not one.
149export function parseValue(spec: KeySpec, raw: unknown, lang: Lang = 'en'): { value: Value } | { error: string } {
150 switch (spec.type) {
151 case 'boolean':
152 return typeof raw === 'boolean' ? { value: raw } : { error: pick(lang, { en: 'must be true or false', 'zh-TW': '要是 true 或 false' }) }
153 case 'number': {
154 if (typeof raw !== 'number' || !Number.isFinite(raw)) {
155 return { error: pick(lang, { en: 'must be a number', 'zh-TW': '要是數字' }) }
156 }
157 if ((spec.min !== undefined && raw < spec.min) || (spec.max !== undefined && raw > spec.max)) {
158 const min = spec.min ?? '-∞'
159 const max = spec.max ?? '∞'
160
161 return { error: pick(lang, { en: `must be between ${min} and ${max}`, 'zh-TW': `要在 ${min} 到 ${max} 之間` }) }
162 }
163
164 return { value: raw }
165 }
166 case 'string':
167 if (typeof raw !== 'string') {
168 return { error: pick(lang, { en: 'must be a string', 'zh-TW': '要是字串' }) }
169 }
170 if (spec.choices !== undefined && !spec.choices.includes(raw)) {
171 return { error: pick(lang, { en: `must be one of ${listOf(lang, spec.choices)}`, 'zh-TW': `要是 ${listOf(lang, spec.choices)} 之一` }) }
172 }
173
174 return { value: raw }
175 case 'list':
176 return Array.isArray(raw) && raw.every(item => typeof item === 'string')
177 ? { value: raw as readonly string[] }
178 : { error: pick(lang, { en: 'must be an array of strings', 'zh-TW': '要是字串陣列' }) }
179 }
180}
181
182// A value typed after `/<mod> set <key>`: on/off for booleans; for lists a JSON array, or the
183// whole text as one item (commas are common in a pattern, so they do not split).
184export function parseInput(spec: KeySpec, text: string, lang: Lang = 'en'): { value: Value } | { error: string } {
185 const t = text.trim()
186 switch (spec.type) {
187 case 'boolean': {
188 const on = ['true', 'on', 'yes', '1'].includes(t.toLowerCase())
189 const off = ['false', 'off', 'no', '0'].includes(t.toLowerCase())
190
191 return on || off ? { value: on } : { error: pick(lang, { en: 'must be on or off', 'zh-TW': '要是 on 或 off' }) }
192 }
193 case 'number':
194 return parseValue(spec, t === '' ? Number.NaN : Number(t), lang)
195 case 'string':
196 return parseValue(spec, t, lang)
197 case 'list': {
198 if (t.startsWith('[')) {
199 try {
200 return parseValue(spec, JSON.parse(t), lang)
201 } catch {
202 return { error: pick(lang, { en: 'not a valid JSON array', 'zh-TW': 'JSON 陣列格式不對' }) }
203 }
204 }
205
206 return { value: t === '' ? [] : [t] }
207 }
208 }
209}
210
211function same(a: Value, b: Value): boolean {
212 return JSON.stringify(a) === JSON.stringify(b)
213}
214
215// What the repo file's value comes to over the one below it, or why it is ignored.
216export function applyRepoRule(spec: KeySpec, below: Value, value: Value, lang: Lang = 'en'): { value: Value } | { error: string } {
217 // `never` refuses even the value below it, so `set --repo` cannot write the key at all.
218 if (spec.repo !== 'never' && (same(below, value) || spec.repo === 'any')) {
219 return { value }
220 }
221 switch (spec.repo) {
222 case 'only-true':
223 return value === true ? { value } : { error: pick(lang, { en: 'protection key: the repo file may only turn it on', 'zh-TW': '保護類,repo 檔案只能開啟' }) }
224 case 'only-false':
225 return value === false ? { value } : { error: pick(lang, { en: 'cost key: the repo file may only turn it off', 'zh-TW': '花費類,repo 檔案只能關閉' }) }
226 case 'only-lower':
227 return (value as number) < (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only lower it', 'zh-TW': 'repo 檔案只能調低' }) }
228 case 'only-higher':
229 return (value as number) > (below as number) ? { value } : { error: pick(lang, { en: 'the repo file may only raise it', 'zh-TW': 'repo 檔案只能調高' }) }
230 case 'append':
231 return { value: [...new Set([...(below as readonly string[]), ...(value as readonly string[])])] }
232 case 'never':
233 return { error: pick(lang, { en: 'the repo file may not set it; use the local file', 'zh-TW': 'repo 檔案不能設定,改用 local 檔案' }) }
234 }
235}
236
237// The one key whose user layer is the settings file, single value or not: `/config` cannot hold
238// a value shared by every mod.
239function isFileValue(key: string, spec: KeySpec): boolean {
240 return spec.type === 'list' || key === 'language'
241}
242
243function resolveKey(key: string, spec: KeySpec, layers: Layers, lang: Lang, ignored: Ignored[]): { value: Value; source: Layer } {
244 let value = spec.default
245 let source: Layer = 'default'
246 const take = (layer: Layer, raw: unknown) => {
247 if (raw === undefined) {
248 return
249 }
250 const parsed = parseValue(spec, raw, lang)
251 if ('error' in parsed) {
252 ignored.push({ key, layer, reason: parsed.error })
253
254 return
255 }
256 const applied = layer === 'repo' ? applyRepoRule(spec, value, parsed.value, lang) : parsed
257 if ('error' in applied) {
258 ignored.push({ key, layer, reason: applied.error })
259
260 return
261 }
262 if (layer === 'user' && same(applied.value, spec.default)) {
263 // `/config` hands over defaults filled in: the same value is no choice of the person's.
264 return
265 }
266 value = applied.value
267 source = layer
268 }
269 // Lists have no `/config` row; their user layer is the file.
270 take('user', isFileValue(key, spec) ? layers.user[key] : layers.options[key])
271 take('repo', layers.repo[key])
272 take('local', layers.local[key])
273 take('session', layers.session[key])
274
275 return { value, source }
276}
277
278export function resolve(specs: Specs, layers: Layers): Resolved {
279 const values: Record<string, Value> = {}
280 const sources: Record<string, Layer> = {}
281 const ignored: Ignored[] = []
282 // The language first: the other keys' reasons are worded in it.
283 const languageSpec = Object.hasOwn(specs, 'language') ? (specs.language as KeySpec) : LANGUAGE
284 const lang = langOf(resolveKey('language', languageSpec, layers, 'en', []).value)
285 for (const [key, spec] of Object.entries(specs)) {
286 const one = resolveKey(key, spec, layers, lang, ignored)
287 values[key] = one.value
288 sources[key] = one.source
289 }
290 for (const layer of ['user', 'repo', 'local', 'session'] as const) {
291 for (const key of Object.keys(layers[layer])) {
292 const spec = Object.hasOwn(specs, key) ? specs[key] : undefined
293 if (spec === undefined) {
294 ignored.push({ key, layer, reason: pick(lang, { en: 'no such setting', 'zh-TW': '沒有這個設定' }) })
295 } else if (layer === 'user' && !isFileValue(key, spec)) {
296 ignored.push({
297 key,
298 layer,
299 reason: pick(lang, { en: 'the user settings file holds lists only; use /config for a single value', 'zh-TW': '使用者設定檔只放清單,單一值用 /config' }),
300 })
301 }
302 }
303 }
304
305 const until: Record<string, number> = {}
306 for (const [key, at] of Object.entries(layers.until ?? {})) {
307 if (sources[key] === 'session' && Object.hasOwn(layers.session, key)) {
308 until[key] = at
309 }
310 }
311
312 return { lang, values, sources, ignored, until }
313}
314
315// `2h`, `90m`, `1h30m`, or a bare number of minutes.
316export function parseDuration(text: string): number | undefined {
317 const t = text.trim().toLowerCase()
318 const match = /^(?:(\d+)h)?(?:(\d+)m?)?$/.exec(t)
319 if (t === '' || match === null || (match[1] === undefined && match[2] === undefined)) {
320 return undefined
321 }
322 const ms = Number(match[1] ?? 0) * 3_600_000 + Number(match[2] ?? 0) * 60_000
323
324 return ms > 0 && ms <= MAX_ON_MS ? ms : undefined
325}
326
327// `1h42m`, `37m`, `<1m`.
328export function fmtLeft(ms: number): string {
329 const minutes = Math.ceil(ms / 60_000)
330 if (ms < 60_000) {
331 return '<1m'
332 }
333 const h = Math.floor(minutes / 60)
334 const m = minutes % 60
335
336 return h === 0 ? `${m}m` : m === 0 ? `${h}h` : `${h}h${m}m`
337}
338
339// The band line while a feature is on for a while: `<mod> on · keep_warm 1h42m`, '' when none is.
340export function onLine(mod: string, until: Readonly<Record<string, number>>, now: number): string {
341 const parts = Object.entries(until)
342 .filter(([, at]) => at > now)
343 .map(([key, at]) => `${key} ${fmtLeft(at - now)}`)
344
345 return parts.length === 0 ? '' : [`${mod} on`, ...parts].join(' · ')
346}
347
348// When the first of the features switched on for a while goes back.
349export function nextExpiry(resolved: Resolved): number | undefined {
350 const times = Object.values(resolved.until)
351
352 return times.length === 0 ? undefined : Math.min(...times)
353}
354
355function show(value: Value): string {
356 return Array.isArray(value) ? (value.length === 0 ? '[]' : value.join(', ')) : String(value)
357}
358
359export function statusText(mod: string, specs: Specs, resolved: Resolved, now = 0): string {
360 const lang = resolved.lang
361 const keys = Object.keys(specs)
362 if (keys.length === 0) {
363 return pick(lang, { en: `${mod} has no settings.`, 'zh-TW': `${mod} 沒有可調的設定。` })
364 }
365 const lines = [
366 pick(lang, {
367 en: `${mod} settings (precedence: session > local > repo > user > default)`,
368 'zh-TW': `${mod} 設定(優先:session > local > repo > 使用者 > 預設)`,
369 }),
370 ]
371 for (const key of keys) {
372 const spec = specs[key] as KeySpec
373 const value = resolved.values[key] as Value
374 const source = resolved.sources[key] as Layer
375 const at = resolved.until[key]
376 const left = at === undefined ? '' : pick(lang, { en: `, on temporarily for ${fmtLeft(at - now)}`, 'zh-TW': `,臨時開啟剩 ${fmtLeft(at - now)}` })
377 const kind = pick(lang, CATEGORY_NAMES[spec.category])
378 const rule = pick(lang, RULE_NAMES[spec.repo])
379 lines.push(` ${key} = ${show(value)} [${pick(lang, LAYER_NAMES[source])}${left}] ${kind}${lang === 'zh-TW' ? ',' : ', '}${rule}`)
380 lines.push(` ${say(lang, spec.description)}`)
381 }
382 if (resolved.ignored.length > 0) {
383 lines.push(pick(lang, { en: 'Ignored:', 'zh-TW': '已忽略:' }))
384 for (const item of resolved.ignored) {
385 const layer = pick(lang, LAYER_NAMES[item.layer])
386 lines.push(pick(lang, { en: ` ${item.key} in ${layer}: ${item.reason}`, 'zh-TW': ` ${layer} 的 ${item.key}:${item.reason}` }))
387 }
388 }
389 lines.push(
390 pick(lang, {
391 en: `Change: /${mod} set <key> <value> [--session|--local|--repo] (default --session); revert: /${mod} unset <key> [same flags]`,
392 'zh-TW': `改值:/${mod} set <key> <value> [--session|--local|--repo](預設 --session),還原:/${mod} unset <key> [同上]`,
393 }),
394 )
395 const toggles = toggleKeys(specs)
396 if (toggles.length > 0) {
397 const names = listOf(lang, toggles)
398 const feature = toggles.length === 1 ? '' : pick(lang, { en: ' <feature>', 'zh-TW': ' <功能>' })
399 lines.push(
400 pick(lang, {
401 en: `Features that use the model (${names}): /${mod} on${feature} [duration, default 2h] turns one on for a while, /${mod} off${feature} turns it off`,
402 'zh-TW': `用模型的功能(${names}):/${mod} on${feature} [時長,預設 2h] 臨時開啟,/${mod} off${feature} 關閉`,
403 }),
404 )
405 }
406
407 return lines.join('\n')
408}
409
410function modSection(text: string, mod: string): Raw {
411 if (text.trim() === '') {
412 return {}
413 }
414 const parsed: unknown = JSON.parse(text)
415 const section = typeof parsed === 'object' && parsed !== null ? (parsed as Raw)[mod] : undefined
416
417 return typeof section === 'object' && section !== null && !Array.isArray(section) ? (section as Raw) : {}
418}
419
420// The file's text with the key set (or removed when `value` is undefined), other mods kept.
421export function withSetting(text: string, mod: string, key: string, value: Value | undefined): string {
422 const parsed: unknown = text.trim() === '' ? {} : JSON.parse(text)
423 const all = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? { ...(parsed as Raw) } : {}
424 const section = { ...modSection(text, mod) }
425 if (value === undefined) {
426 delete section[key]
427 } else {
428 section[key] = value
429 }
430 if (Object.keys(section).length === 0) {
431 delete all[mod]
432 } else {
433 all[mod] = section
434 }
435
436 return `${JSON.stringify(all, null, 2)}\n`
437}
438
439export function withLocalExclude(text: string): string | undefined {
440 const line = `/${LOCAL_FILE}`
441 if (text.split('\n').some(l => l.trim() === line)) {
442 return undefined
443 }
444
445 return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}# cc-mod-trimtab\n${line}\n`
446}
447
448const TARGET_FLAG = /(^|\s)--(session|local|repo)(?=\s|$)/g
449
450// `<key> [value] [--session|--local|--repo]`, the flag anywhere. The value keeps its spacing.
451export function parseSetArgs(args: string, lang: Lang = 'en'): { key: string; rest: string; target: Target } | { error: string } {
452 const targets = [...args.matchAll(TARGET_FLAG)].map(match => match[2] as Target)
453 if (targets.length > 1) {
454 return { error: pick(lang, { en: 'give only one of --session, --local or --repo', 'zh-TW': '只能加一個 --session、--local 或 --repo' }) }
455 }
456 const text = args.replace(TARGET_FLAG, '$1').trim()
457 const key = text.split(/\s+/)[0] ?? ''
458 if (key === '') {
459 return { error: pick(lang, { en: 'name a setting', 'zh-TW': '要指定設定名稱' }) }
460 }
461
462 return { key, rest: text.slice(key.length).trim(), target: targets[0] ?? 'session' }
463}
464
465// ---------- through the mod's I/O ----------
466
467// What the settings need from `$`. `$` cannot cross an import, so each mod builds this in its own
468// register file (the `configIO` block, kept equal across mods by scripts/check-copies.sh).
469export type ConfigIO = {
470 // The file's text, '' when it is missing.
471 read: (path: string) => Promise<string>
472 write: (path: string, text: string) => Promise<void>
473 isDir: (path: string) => Promise<boolean>
474 // `CLAUDE_CONFIG_DIR`, else `$HOME/.claude`.
475 configDir: () => Promise<string>
476 root: () => Promise<string>
477 // The main working tree, whose `.git/info/exclude` covers every worktree.
478 repoRoot: () => Promise<string | undefined>
479 sessionId: () => Promise<string>
480 now: () => Promise<number>
481 storeGet: (key: string) => Promise<unknown>
482 storeSet: (key: string, value: unknown) => Promise<void>
483}
484
485// What a file adds to the layers: the mod's section, with `language` from `"*"` when the section
486// has none of its own, and the problems found on the way.
487type Problem = { key: string; layer: Layer; reason: Text }
488
489function readSection(text: string, path: string, mod: string, layer: Layer, broken: Problem[]): Raw {
490 try {
491 const own = modSection(text, mod)
492 const shared = modSection(text, SHARED)
493 for (const key of Object.keys(shared)) {
494 if (key !== 'language') {
495 broken.push({
496 key: `${SHARED}.${key}`,
497 layer,
498 reason: { en: `only language is read from "${SHARED}"`, 'zh-TW': `"${SHARED}" 只讀 language` },
499 })
500 }
501 }
502
503 return shared.language === undefined || own.language !== undefined ? own : { ...own, language: shared.language }
504 } catch {
505 broken.push({ key: path, layer, reason: { en: 'not valid JSON; the whole file is not used', 'zh-TW': 'JSON 格式不對,整個檔案沒有採用' } })
506
507 return {}
508 }
509}
510
511type SessionEntry = { values: Raw; until?: Readonly<Record<string, number>> }
512type Sessions<T> = Readonly<Record<string, T & { written: number }>>
513
514// Every session's entry under `key`, with `id`'s replaced by `entry` and those not written for a
515// week dropped.
516async function writeSessions<T>(io: ConfigIO, key: string, id: string, entry: T): Promise<void> {
517 const now = await io.now()
518 const kept: Record<string, T & { written: number }> = {}
519 for (const [other, stored] of Object.entries(((await io.storeGet(key)) ?? {}) as Sessions<T>)) {
520 if (other !== id && now - stored.written < KEEP_SESSION_MS) {
521 kept[other] = stored
522 }
523 }
524 kept[id] = { ...entry, written: now }
525 await io.storeSet(key, kept)
526}
527
528async function sessionEntry<T>(io: ConfigIO, key: string, id: string): Promise<T | undefined> {
529 const stored = (await io.storeGet(key)) as Sessions<T> | undefined
530
531 return stored !== undefined && Object.hasOwn(stored, id) ? stored[id] : undefined
532}
533
534// This session's values; those `/<mod> on` set left out once they ran out.
535async function sessionValues(io: ConfigIO): Promise<{ values: Raw; until: Record<string, number> }> {
536 const stored = await sessionEntry<SessionEntry>(io, SESSION_KEY, await io.sessionId())
537 if (stored === undefined) {
538 return { values: {}, until: {} }
539 }
540 const timed = Object.entries(stored.until ?? {})
541 // Read only when needed: a mod without features to switch never asks the clock.
542 const now = timed.length === 0 ? 0 : await io.now()
543 const values: Record<string, unknown> = { ...stored.values }
544 const until: Record<string, number> = {}
545 for (const [key, at] of timed) {
546 if (at > now) {
547 until[key] = at
548 } else {
549 delete values[key]
550 }
551 }
552
553 return { values, until }
554}
555
556async function writeSession(io: ConfigIO, key: string, value: Value | undefined, until: number | undefined): Promise<void> {
557 const current = await sessionValues(io)
558 const values: Record<string, unknown> = { ...current.values }
559 const times = { ...current.until }
560 delete values[key]
561 delete times[key]
562 if (value !== undefined) {
563 values[key] = value
564 }
565 if (value !== undefined && until !== undefined) {
566 times[key] = until
567 }
568 await writeSessions(io, SESSION_KEY, await io.sessionId(), { values, until: times })
569}
570
571// True the first time a feature does its work in this session, for the one notice it gets.
572export async function firstUse(io: ConfigIO, key: string): Promise<boolean> {
573 const sessionId = await io.sessionId()
574 const keys = (await sessionEntry<{ keys: string[] }>(io, NOTICED_KEY, sessionId))?.keys ?? []
575 if (keys.includes(key)) {
576 return false
577 }
578 await writeSessions(io, NOTICED_KEY, sessionId, { keys: [...keys, key] })
579
580 return true
581}
582
583async function filePaths(io: ConfigIO): Promise<{ user: string; repo: string; local: string }> {
584 const root = (await io.root()).replace(/\/$/, '')
585
586 return { user: `${(await io.configDir()).replace(/\/$/, '')}/${USER_FILE}`, repo: `${root}/${REPO_FILE}`, local: `${root}/${LOCAL_FILE}` }
587}
588
589async function loadLayers(io: ConfigIO, mod: string, options: PluginOptions): Promise<{ layers: Layers; broken: Problem[] }> {
590 const paths = await filePaths(io)
591 const broken: Problem[] = []
592 const session = await sessionValues(io)
593 const layers = {
594 options,
595 user: readSection(await io.read(paths.user), paths.user, mod, 'user', broken),
596 repo: readSection(await io.read(paths.repo), paths.repo, mod, 'repo', broken),
597 local: readSection(await io.read(paths.local), paths.local, mod, 'local', broken),
598 session: session.values,
599 until: session.until,
600 }
601
602 return { layers, broken }
603}
604
605export async function loadConfig(io: ConfigIO, mod: string, specs: Specs, options: PluginOptions): Promise<Resolved> {
606 const { layers, broken } = await loadLayers(io, mod, options)
607 const resolved = resolve(specs, layers)
608
609 const problems = broken.map(item => ({ ...item, reason: pick(resolved.lang, item.reason) }))
610
611 return { ...resolved, ignored: [...problems, ...resolved.ignored] }
612}
613
614async function writeSetting(io: ConfigIO, mod: string, key: string, value: Value | undefined, target: Target): Promise<void> {
615 if (target === 'session') {
616 await writeSession(io, key, value, undefined)
617
618 return
619 }
620 const paths = await filePaths(io)
621 const path = target === 'repo' ? paths.repo : paths.local
622 await io.write(path, withSetting(await io.read(path), mod, key, value))
623 if (target === 'local') {
624 const repo = await io.repoRoot()
625 if (repo !== undefined && (await io.isDir(`${repo}/.git`))) {
626 const exclude = `${repo}/.git/info/exclude`
627 const added = withLocalExclude(await io.read(exclude))
628 if (added !== undefined) {
629 await io.write(exclude, added)
630 }
631 }
632 }
633}
634
635// Answers `/<mod> status|set|unset …`; undefined for any other sub-command. `reload` hands the
636// mod the new values after a change.
637export async function configCommand(
638 io: ConfigIO,
639 mod: string,
640 specs: Specs,
641 options: PluginOptions,
642 args: string,
643 reload: (resolved: Resolved) => void,
644): Promise<string | undefined> {
645 const sub = args.trim().split(/\s+/)[0] ?? ''
646 const rest = args.trim().slice(sub.length)
647 if (sub === 'status') {
648 const resolved = await loadConfig(io, mod, specs, options)
649 reload(resolved)
650
651 return statusText(mod, specs, resolved, Object.keys(resolved.until).length === 0 ? 0 : await io.now())
652 }
653 if ((sub === 'on' || sub === 'off') && toggleKeys(specs).length > 0) {
654 return switchFeature(io, mod, specs, options, sub, rest, reload)
655 }
656 if (sub !== 'set' && sub !== 'unset') {
657 return undefined
658 }
659 const before = await loadLayers(io, mod, options)
660 const lang = resolve(specs, before.layers).lang
661 const parsed = parseSetArgs(rest, lang)
662 if ('error' in parsed) {
663 return parsed.error
664 }
665 const spec = Object.hasOwn(specs, parsed.key) ? specs[parsed.key] : undefined
666 if (spec === undefined) {
667 return pick(lang, {
668 en: `${mod} has no setting ${parsed.key}; /${mod} status lists them all`,
669 'zh-TW': `${mod} 沒有 ${parsed.key} 這個設定;/${mod} status 列出全部`,
670 })
671 }
672 let value: Value | undefined
673 if (sub === 'set') {
674 const input = parseInput(spec, parsed.rest, lang)
675 if ('error' in input) {
676 return `${parsed.key} ${input.error}`
677 }
678 value = input.value
679 }
680 if (parsed.target === 'repo' && value !== undefined) {
681 // Do not commit a value every reader would ignore.
682 const { layers } = before
683 const trial = resolve(specs, { ...layers, repo: { ...layers.repo, [parsed.key]: value } })
684 const refused = trial.ignored.find(item => item.layer === 'repo' && item.key === parsed.key)
685 if (refused !== undefined) {
686 return pick(lang, { en: `Not written: ${refused.reason}`, 'zh-TW': `沒有寫入:${refused.reason}` })
687 }
688 }
689 await writeSetting(io, mod, parsed.key, value, parsed.target)
690 const resolved = await loadConfig(io, mod, specs, options)
691 reload(resolved)
692 const shown = show(resolved.values[parsed.key] as Value)
693 const from = pick(resolved.lang, LAYER_NAMES[resolved.sources[parsed.key] as Layer])
694 const now = pick(resolved.lang, { en: `${parsed.key} is now ${shown} (from ${from})`, 'zh-TW': `${parsed.key} 現在是 ${shown}(來自 ${from})` })
695 const where =
696 parsed.target === 'session' ? pick(resolved.lang, { en: 'this session only', 'zh-TW': '只在這個 session' }) : parsed.target === 'repo' ? REPO_FILE : LOCAL_FILE
697 const done = sub === 'set' ? pick(resolved.lang, { en: 'Set', 'zh-TW': '已設定' }) : pick(resolved.lang, { en: 'Removed', 'zh-TW': '已移除' })
698
699 return pick(resolved.lang, { en: `${done} (${where}). ${now}`, 'zh-TW': `${done}(${where})。${now}` })
700}
701
702export function toggleKeys(specs: Specs): string[] {
703 return Object.keys(specs).filter(key => specs[key]?.toggle !== undefined)
704}
705
706// `[feature] [duration]` in either order; the feature may go when the mod has one.
707export function parseOnArgs(specs: Specs, args: string, withDuration: boolean, lang: Lang = 'en'): { key: string; ms: number } | { error: string } {
708 const toggles = toggleKeys(specs)
709 let key: string | undefined
710 let ms: number | undefined
711 for (const word of args.trim().split(/\s+/).filter(w => w !== '')) {
712 if (key === undefined && toggles.includes(word)) {
713 key = word
714 continue
715 }
716 const duration = withDuration && ms === undefined ? parseDuration(word) : undefined
717 if (duration === undefined) {
718 const names = listOf(lang, toggles)
719 const hint = withDuration ? pick(lang, { en: '; a duration looks like 2h, 90m, 1h30m (24h at most)', 'zh-TW': ',時長像 2h、90m、1h30m(最多 24h)' }) : ''
720
721 return { error: pick(lang, { en: `Cannot read "${word}": the features are ${names}${hint}`, 'zh-TW': `看不懂「${word}」:功能是 ${names}${hint}` }) }
722 }
723 ms = duration
724 }
725 if (key === undefined && toggles.length === 1) {
726 key = toggles[0]
727 }
728 if (key === undefined) {
729 return { error: pick(lang, { en: `Name a feature: ${listOf(lang, toggles)}`, 'zh-TW': `要指定功能:${listOf(lang, toggles)}` }) }
730 }
731
732 return { key, ms: ms ?? DEFAULT_ON_MS }
733}
734
735async function switchFeature(
736 io: ConfigIO,
737 mod: string,
738 specs: Specs,
739 options: PluginOptions,
740 sub: 'on' | 'off',
741 args: string,
742 reload: (resolved: Resolved) => void,
743): Promise<string> {
744 const { layers } = await loadLayers(io, mod, options)
745 const lang = resolve(specs, layers).lang
746 const parsed = parseOnArgs(specs, args, sub === 'on', lang)
747 if ('error' in parsed) {
748 return parsed.error
749 }
750 const { key, ms } = parsed
751 const toggle = (specs[key] as KeySpec).toggle as { on: Value; off: Value }
752 const rest: Record<string, unknown> = { ...layers.session }
753 delete rest[key]
754 const below = resolve(specs, { ...layers, session: rest })
755 const belowValue = below.values[key] as Value
756 const isOnBelow = !same(belowValue, toggle.off)
757 const now = await io.now()
758 const from = pick(lang, LAYER_NAMES[below.sources[key] as Layer])
759 if (sub === 'on') {
760 if (isOnBelow) {
761 const wasOff = Object.hasOwn(layers.session, key)
762 await writeSession(io, key, undefined, undefined)
763 reload(await loadConfig(io, mod, specs, options))
764 const source = pick(lang, { en: `${show(belowValue)}, from ${from}`, 'zh-TW': `${show(belowValue)},來自 ${from}` })
765
766 return wasOff
767 ? pick(lang, { en: `${key}: this session's off is cancelled, back to always on (${source})`, 'zh-TW': `${key} 取消這個 session 的關閉,回到常開(${source})` })
768 : pick(lang, { en: `${key} is already always on (${source}); no need to turn it on for a while`, 'zh-TW': `${key} 已經常開(${source}),不用臨時開啟` })
769 }
770 await writeSession(io, key, toggle.on, now + ms)
771 reload(await loadConfig(io, mod, specs, options))
772 const at = new Date(now + ms)
773 const clock = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
774 const off = `/${mod} off${toggleKeys(specs).length === 1 ? '' : ` ${key}`}`
775
776 return pick(lang, {
777 en: `${key} on for ${fmtLeft(ms)} (until ${clock}); it goes off by itself when that runs out or the session ends; turn off: ${off}`,
778 'zh-TW': `${key} 臨時開啟 ${fmtLeft(ms)}(到 ${clock}),到期或 session 結束自動關;關閉:${off}`,
779 })
780 }
781 // Off for the rest of the session; nothing to keep when the layers below are off already.
782 await writeSession(io, key, isOnBelow ? toggle.off : undefined, undefined)
783 reload(await loadConfig(io, mod, specs, options))
784
785 return isOnBelow
786 ? pick(lang, {
787 en: `${key} is off for this session (${from} has it on; it comes back next session)`,
788 'zh-TW': `${key} 在這個 session 關閉(${from} 設定是開啟,下個 session 恢復)`,
789 })
790 : pick(lang, { en: `${key} is already off`, 'zh-TW': `${key} 已關閉` })
791}
792hooks/layout.ts 102 lines1// How the stats pane lays itself out by width (DESIGN.md「欄位清單」「日曆熱圖」「期間」): pure,
2// so each rule is tested without drawing.
3import { addDays, cells } from './ledger'
4
5// The heatmap's weekday labels take this many columns before the first week.
6export const HEAT_LABELS = 4
7
8// A column of the overview list: its width in cells and how much it matters (1 most).
9export type Column = { key: string; width: number; priority: number }
10
11// The columns that fit beside a name of `nameMin` cells, two cells apart, taken by priority and
12// kept in their own order. The first that does not fit stops the rest, so which columns show does
13// not jump around as the pane is resized. `used` is the cells they take with their gaps.
14export function fitColumns<C extends Column>(columns: C[], body: number, nameMin: number): { shown: C[]; used: number } {
15 const on = new Set<string>()
16 let used = 0
17 for (const column of [...columns].sort((a, b) => a.priority - b.priority)) {
18 if (nameMin + used + 2 + column.width > body) {
19 break
20 }
21 on.add(column.key)
22 used += 2 + column.width
23 }
24
25 return { shown: columns.filter(column => on.has(column.key)), used }
26}
27
28// A list row's bar: 12% of the width, 6 to 14 cells.
29export function listBarWidth(body: number): number {
30 return Math.min(14, Math.max(6, Math.round(body * 0.12)))
31}
32
33// The period's last day moved by its own length, back (`-1`) or forward (`1`): never after
34// `today`, never before `first`.
35export function movePeriod(end: string, days: number, step: -1 | 1, today: string, first: string): string {
36 const next = addDays(end, step * days)
37
38 return next > today ? today : next < first ? first : next
39}
40
41// Where the period is marked under the heatmap, by the weeks of its first and last day (negative
42// before the first week drawn): a bracket over its weeks with `label` inside where it fits, or for
43// one day (`name` given) a `▲` under it with the day's name after it, or before it where the line
44// ends first. `at` is the column it starts at, the weekday labels included.
45export function periodMark(body: number, firstWeek: number, lastWeek: number, label: string, name: string | undefined): { at: number; text: string } | undefined {
46 if (lastWeek < 0) {
47 return undefined
48 }
49 if (name !== undefined) {
50 const at = HEAT_LABELS + lastWeek * 2
51 const after = `▲ ${name}`
52 if (at + cells(after) <= body) {
53 return { at, text: after }
54 }
55 const before = `${name} ▲`
56
57 return { at: at - cells(before) + 1, text: before }
58 }
59 const from = Math.max(0, firstWeek)
60 const width = (lastWeek - from) * 2 + 1
61 const at = HEAT_LABELS + from * 2
62 if (width < 3) {
63 return { at, text: '▲' }
64 }
65 const inner = width - 2
66 const inside = ` ${label} `
67 if (inner < cells(inside) + 2) {
68 return { at, text: `└${'─'.repeat(inner)}┘` }
69 }
70 const dashes = inner - cells(inside)
71 const left = Math.floor(dashes / 2)
72
73 return { at, text: `└${'─'.repeat(left)}${inside}${'─'.repeat(dashes - left)}┘` }
74}
75
76// How many of the legend's items (`widths`, in order, two cells apart) fit on the mark's line, and
77// the column they start at: left of the mark after the weekday labels, else right of it flush with
78// the edge, else none. An item that does not fit stops the rest.
79export function fitLegend(body: number, mark: { at: number; width: number } | undefined, widths: number[]): { count: number; at: number } {
80 const fit = (room: number) => {
81 let count = 0
82 let used = 0
83 for (const width of widths) {
84 const need = count === 0 ? width : used + 2 + width
85 if (need > room) {
86 break
87 }
88 count += 1
89 used = need
90 }
91
92 return { count, used }
93 }
94 const left = fit((mark?.at ?? body) - 2 - HEAT_LABELS)
95 if (left.count > 0) {
96 return { count: left.count, at: HEAT_LABELS }
97 }
98 const right = fit(body - ((mark === undefined ? body : mark.at + mark.width) + 2))
99
100 return right.count > 0 ? { count: right.count, at: body - right.used } : { count: 0, at: 0 }
101}
102hooks/ui.tsx 114 lines1// The parts every mod's screens share, as DESIGN.md sets them out. Mods cannot import outside their
2// own folder, so each mod that draws a pane or a band keeps a copy; scripts/check-copies.sh checks
3// they match.
4import type { Elements, RenderNode } from 'claude-code'
5
6import { pick, type Lang } from './config'
7
8type Kit = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text'>
9type Words = { en: string; 'zh-TW': string }
10
11// What a color means: yellow waits on the user, red is an error, an interruption or a risk.
12export const TONE = { attention: 'yellow', danger: 'red' } as const
13
14// A card, and the yellow frame of a pane that holds Claude until the user answers.
15export const CARD = { borderStyle: 'round', borderDimColor: true, paddingX: 1 } as const
16export const CONFIRM = { borderStyle: 'round', borderColor: TONE.attention, paddingX: 1 } as const
17
18// Where a part of a bar comes from: `ink` on the terminal, `fill` in desktop's Svg. Main session
19// purple, subagent cyan, named model yellow, default model gray, fork green.
20export const COLORS = {
21 main: { ink: 'magenta', fill: '#8a5cc7' },
22 sub: { ink: 'cyan', fill: '#2f8fb0' },
23 named: { ink: 'yellow', fill: '#c98a12' },
24 default: { ink: 'gray', fill: '#8b8b93' },
25 fork: { ink: 'green', fill: '#3f9a4f' },
26} as const
27
28// The main session's parts (its models), one after another in the same bar.
29export const MAIN_SHADES = [COLORS.main, { ink: 'magentaBright', fill: '#b48ee6' }, { ink: 'magenta', fill: '#6b3fa8' }]
30
31export type Option = { value: string; label: string }
32
33// Switches in place, so nothing below moves (a Select pushes it down when it opens): in the
34// terminal one button that steps to the next value (its hotkey too) and wraps, elsewhere a segment
35// per value with the hotkey on the one after the current, so it steps forward the same way.
36export function switcher(
37 { Box, Button, Text }: Kit,
38 surface: string,
39 key: string,
40 label: string,
41 hotkey: string,
42 options: Option[],
43 value: string,
44 onPick: (value: string) => void,
45): RenderNode {
46 const at = Math.max(0, options.findIndex(option => option.value === value))
47 if (surface === 'terminal') {
48 const next = options[(at + 1) % options.length]!.value
49
50 return (
51 <Box key={`switch:${key}`} flexDirection="row" flexShrink={0}>
52 {label !== '' && <Text dimColor>{label}</Text>}
53 <Button key={key} plain hotkey={hotkey} onPress={() => onPick(next)}>
54 <Text bold>‹ {options[at]?.label ?? value} ›</Text>
55 </Button>
56 </Box>
57 )
58 }
59
60 return (
61 <Box key={`switch:${key}`} flexDirection="row" gap={1} flexShrink={0}>
62 {label !== '' && <Text dimColor>{label}</Text>}
63 {options.map((option, i) => (
64 <Button
65 key={`${key}:${option.value}`}
66 label={option.label}
67 {...(i === (at + 1) % options.length ? { hotkey } : {})}
68 {...(i === at ? { variant: 'primary' as const } : { dimColor: true })}
69 onPress={() => onPick(option.value)}
70 />
71 ))}
72 </Box>
73 )
74}
75
76// A pane's last line: each key and what it does, then how to close it unless `closes` is false
77// (a pane that waits for an answer). The key keeps the default color and the rest is dim, so the
78// eye finds the keys first: the first word ("d done"), or the key in brackets ("‹ Back (b) returns").
79export function hintLine({ Box, Text }: Kit, lang: Lang, items: Words[], closes = true): RenderNode {
80 const all = closes ? [...items, { en: 'Esc closes', 'zh-TW': 'Esc 關閉' }] : items
81 const parts = all.flatMap((item, i) => {
82 const text = pick(lang, item)
83 const m = /^(.*?[((])(\S)([))].*)$/.exec(text) ?? (text.startsWith('‹') ? null : /^()(\S+)(.*)$/.exec(text))
84 const sep = i === 0 ? [] : [<Text key={`sep${i}`} dimColor>{' · '}</Text>]
85 if (m === null) {
86 return [...sep, <Text key={`all${i}`} dimColor>{text}</Text>]
87 }
88
89 return [
90 ...sep,
91 ...(m[1] === '' ? [] : [<Text key={`pre${i}`} dimColor>{m[1]}</Text>]),
92 <Text key={`key${i}`}>{m[2]}</Text>,
93 <Text key={`does${i}`} dimColor>{m[3]}</Text>,
94 ]
95 })
96
97 return (
98 <Box key="hint" marginTop={1}>
99 <Text wrap="wrap">{parts}</Text>
100 </Box>
101 )
102}
103
104// What a pane shows when it has nothing to list; under a header, the caller sets it a line apart.
105export function emptyState({ Box, Text }: Kit, text: string): RenderNode {
106 return (
107 <Box key="empty">
108 <Text dimColor wrap="wrap">
109 {text}
110 </Text>
111 </Box>
112 )
113}
114hooks/ledger.ts 989 lines1import { pick, type KeySpec, type Lang } from './config'
2
3// What one session wrote under `ledger:<sessionId>` in $.store. Each session writes only its
4// own key, so concurrent sessions never overwrite each other; `stats` reads every key.
5export type Tally = { input: number; cacheWrite: number; cacheRead: number; output: number; steps: number }
6
7// One Agent spawn and the tokens its steps used. `type` is `fork` for a fork and `unknown`
8// for steps of an agent id the ledger never saw spawn (one spawned before a hot reload).
9export type Spawn = Tally & {
10 agentId?: string
11 // ms of the spawn and of its last step: the duration is `last - at`.
12 at: number
13 last: number
14 description: string
15 type: string
16 // The Agent tool's `model` as given; absent when the spawn took the default.
17 requested?: string
18 // The model the spawn resolved to.
19 model: string
20 fork: boolean
21 background: boolean
22}
23
24export type Ledger = {
25 // ms of the last write; keys not written for 90 days are deleted at session start.
26 updated: number
27 // The main working tree's root, so worktrees of one repo group together.
28 repo: string
29 started: number
30 // The first 40 characters of the session's first prompt.
31 prompt: string
32 // Local day (`2026-10-12`) -> model -> the main session's own tokens that day.
33 daily: Record<string, Record<string, Tally>>
34 // Oldest first, at most SPAWN_CAP.
35 spawns: Spawn[]
36 // Set on a ledger rebuilt from the session's transcript rather than recorded live.
37 source?: 'transcript'
38}
39
40// What the session knows before anything is written; merged into the ledger on each write.
41export type SessionMeta = { repo: string; started: number; prompt: string }
42
43export const LEDGER_PREFIX = 'ledger:'
44export const RETENTION_MS = 90 * 86_400_000
45export const SPAWN_CAP = 500
46export const PROMPT_CHARS = 40
47export const DEFAULT_DAYS = 30
48export const MAX_DAYS = 90
49export const DAY_PREFIX = 'day:'
50// Day rows are kept this many days, the heatmap's 53 weeks.
51export const DAY_KEEP = 366
52// The heatmap reads the ledgers for this many days ending today and the day rows before. One day
53// short of the retention: a ledger last written on the 90th day back is pruned partway through it.
54export const LIVE_DAYS = MAX_DAYS - 1
55export const MAX_WEEKS = 53
56export const MIN_WEEKS = 4
57
58export const LEDGER_SPEC: KeySpec = {
59 category: 'plain',
60 type: 'boolean',
61 default: true,
62 repo: 'never',
63 description: {
64 en: 'Record each spawn and the token use of the main session and every subagent on this machine, for /subagent-dispatch stats. Off records nothing; existing records stay',
65 'zh-TW': '把每次派出,以及主 session 與各 subagent 的 token 用量記進本機的用量明細,供 /subagent-dispatch stats 使用。關閉後不再記錄,已有的紀錄保留',
66 },
67}
68
69export function emptyLedger(): Ledger {
70 return { updated: 0, repo: '', started: 0, prompt: '', daily: {}, spawns: [] }
71}
72
73export function promptSummary(text: string): string {
74 const flat = text.replace(/\s+/g, ' ').trim()
75
76 return flat.length > PROMPT_CHARS ? `${flat.slice(0, PROMPT_CHARS)}…` : flat
77}
78
79// Fills what the ledger does not have yet: a resumed session keeps its first start and prompt.
80export function applyMeta(ledger: Ledger, meta: SessionMeta): void {
81 ledger.repo ||= meta.repo
82 ledger.started ||= meta.started
83 ledger.prompt ||= meta.prompt
84}
85
86function zero(): Tally {
87 return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, steps: 0 }
88}
89
90export function recordSpawn(
91 ledger: Ledger,
92 at: number,
93 spawn: { agentId?: string; type: string; model: string; requested?: string; fork: boolean; background: boolean; description: string },
94): void {
95 ledger.spawns = [
96 ...ledger.spawns,
97 {
98 ...zero(),
99 ...(spawn.agentId === undefined ? {} : { agentId: spawn.agentId }),
100 at,
101 last: at,
102 description: spawn.description,
103 type: spawn.fork ? 'fork' : spawn.type,
104 ...(spawn.requested === undefined || spawn.fork ? {} : { requested: spawn.requested }),
105 model: spawn.model,
106 fork: spawn.fork,
107 background: spawn.background,
108 },
109 ].slice(-SPAWN_CAP)
110 ledger.updated = at
111}
112
113export function recordStep(
114 ledger: Ledger,
115 at: number,
116 step: { agentId?: string; model: string; input: number; cacheWrite: number; cacheRead: number; output: number },
117): void {
118 let tally: Tally
119 if (step.agentId === undefined) {
120 tally = (ledger.daily[dayOf(at)] ??= {})[step.model] ??= zero()
121 } else {
122 let spawn = ledger.spawns.findLast(item => item.agentId === step.agentId)
123 if (spawn === undefined) {
124 spawn = { ...zero(), agentId: step.agentId, at, last: at, description: '', type: 'unknown', model: step.model, fork: false, background: false }
125 ledger.spawns = [...ledger.spawns, spawn].slice(-SPAWN_CAP)
126 }
127 spawn.last = at
128 tally = spawn
129 }
130 tally.input += step.input
131 tally.cacheWrite += step.cacheWrite
132 tally.cacheRead += step.cacheRead
133 tally.output += step.output
134 tally.steps += 1
135 ledger.updated = at
136}
137
138// The local calendar day of a time.
139export function dayOf(ms: number): string {
140 const d = new Date(ms)
141
142 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
143}
144
145const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
146
147// A local day without the year: `Oct 12`, `10/12`.
148function monthDay(lang: Lang, month: number, day: number): string {
149 return pick(lang, { en: `${MONTHS[month - 1]} ${day}`, 'zh-TW': `${month}/${day}` })
150}
151
152// A session's start in local time, named with its prompt: `Oct 12 02:11`, `10/12 02:11`.
153export function stamp(lang: Lang, ms: number): string {
154 const d = new Date(ms)
155 const two = (value: number) => String(value).padStart(2, '0')
156
157 return `${monthDay(lang, d.getMonth() + 1, d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`
158}
159
160// A local time of day to the second: `19:04:05`.
161export function clockTime(ms: number): string {
162 const d = new Date(ms)
163 const two = (value: number) => String(value).padStart(2, '0')
164
165 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
166}
167
168// The local day of a time: `Oct 12`, `10/12`.
169export function dayLabel(lang: Lang, ms: number): string {
170 const d = new Date(ms)
171
172 return monthDay(lang, d.getMonth() + 1, d.getDate())
173}
174
175// A day in the stats pane: `Oct 2`, `10/02`.
176export function shortDay(lang: Lang, day: string): string {
177 const [, month, date] = day.split('-').map(Number) as [number, number, number]
178
179 return pick(lang, { en: `${MONTHS[month - 1]} ${date}`, 'zh-TW': `${String(month).padStart(2, '0')}/${String(date).padStart(2, '0')}` })
180}
181
182// A stats period's days: `Sep 15 – Oct 14`, `09/15 – 10/14`; `tight` drops the spaces around the dash.
183export function span(lang: Lang, from: string, to: string, tight = false): string {
184 return `${shortDay(lang, from)}${tight ? '–' : ' – '}${shortDay(lang, to)}`
185}
186
187// The days a report covers: `Sep 13 – Oct 12`, `9/13 至 10/12`.
188export function range(lang: Lang, report: Report): string {
189 const label = (day: string) => {
190 const [, month, date] = day.split('-').map(Number) as [number, number, number]
191
192 return monthDay(lang, month, date)
193 }
194
195 return pick(lang, { en: `${label(report.from)} – ${label(report.to)}`, 'zh-TW': `${label(report.from)} 至 ${label(report.to)}` })
196}
197
198// A period: `1 day`, `30 days`, `30 天`.
199export function daysLabel(lang: Lang, days: number): string {
200 return pick(lang, { en: days === 1 ? '1 day' : `${days} days`, 'zh-TW': `${days} 天` })
201}
202
203// The first day a report covers: `days` local days ending today, today included.
204function firstDay(now: number, days: number): string {
205 const d = new Date(now)
206 d.setDate(d.getDate() - (days - 1))
207
208 return dayOf(d.getTime())
209}
210
211// A local day `count` days after `day` (before, when negative).
212export function addDays(day: string, count: number): string {
213 const [year, month, date] = day.split('-').map(Number) as [number, number, number]
214
215 return dayOf(new Date(year, month - 1, date + count).getTime())
216}
217
218const WEEKDAYS = {
219 en: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
220 'zh-TW': ['一', '二', '三', '四', '五', '六', '日'],
221} as const
222
223// Monday 0 to Sunday 6.
224export function weekday(day: string): number {
225 const [year, month, date] = day.split('-').map(Number) as [number, number, number]
226
227 return (new Date(year, month - 1, date).getDay() + 6) % 7
228}
229
230// A chosen day: `Fri Oct 2`, `10/02 週五`.
231export function dayName(lang: Lang, day: string): string {
232 const [, month, date] = day.split('-').map(Number) as [number, number, number]
233 const two = (value: number) => String(value).padStart(2, '0')
234
235 return pick(lang, {
236 en: `${WEEKDAYS.en[weekday(day)]} ${monthDay(lang, month, date)}`,
237 'zh-TW': `${two(month)}/${two(date)} 週${WEEKDAYS['zh-TW'][weekday(day)]}`,
238 })
239}
240
241// A chosen day at the head of a sentence: `Sat Oct 4`, `10/04(六)`.
242export function dayHead(lang: Lang, day: string): string {
243 const [, month, date] = day.split('-').map(Number) as [number, number, number]
244 const two = (value: number) => String(value).padStart(2, '0')
245
246 return pick(lang, { en: dayName(lang, day), 'zh-TW': `${two(month)}/${two(date)}(${WEEKDAYS['zh-TW'][weekday(day)]})` })
247}
248
249// The heatmap's row labels, Monday first: only Monday, Wednesday and Friday are named.
250export function weekdayLabels(lang: Lang): string[] {
251 return WEEKDAYS[lang].map((name, i) => (i % 2 === 0 && i < 5 ? name : ''))
252}
253
254export function tokens(t: Tally): number {
255 return t.input + t.cacheWrite + t.output
256}
257
258// `cacheByModel` is the main session's cache read per model, `lastByModel` the last day in the
259// window each model had main session tokens.
260export type SessionRow = {
261 repo: string
262 started: number
263 prompt: string
264 main: number
265 mainByModel: [string, number][]
266 cacheByModel: Record<string, number>
267 lastByModel: Record<string, string>
268 cacheRead: number
269 spawns: Spawn[]
270}
271
272export type Report = {
273 // When the ledgers were read.
274 at: number
275 days: number
276 from: string
277 to: string
278 main: number
279 sub: number
280 cacheRead: number
281 spawned: { all: number; explicit: number; opus: number; forks: number; defaults: number }
282 // Newest session first.
283 sessions: SessionRow[]
284}
285
286// Adds `spawns` to `counts` by where their model came from, in three kinds that never overlap:
287// a fork runs on the main session's model, so it named none and took no default. A spawn the
288// ledger only saw step is not counted.
289function addKinds(counts: Report['spawned'], spawns: Spawn[]): void {
290 for (const spawn of spawns.filter(spawn => spawn.type !== 'unknown')) {
291 counts.all += 1
292 if (spawn.fork) {
293 counts.forks += 1
294 } else if (spawn.requested !== undefined) {
295 counts.explicit += 1
296 if (spawn.requested.toLowerCase().includes('opus')) {
297 counts.opus += 1
298 }
299 } else {
300 counts.defaults += 1
301 }
302 }
303}
304
305export function countKinds(spawns: Spawn[]): Report['spawned'] {
306 const counts = { all: 0, explicit: 0, opus: 0, forks: 0, defaults: 0 }
307 addKinds(counts, spawns)
308
309 return counts
310}
311
312// A stored ledger in the current format, or undefined for anything else, such as a ledger of the
313// unreleased first format (spawns per day). A ledger written before 0.4.0 kept the main
314// session's tokens per model only, as `main`: they count on the day of its last write.
315export function readLedger(value: unknown): Ledger | undefined {
316 if (typeof value !== 'object' || value === null || !Array.isArray((value as Ledger).spawns)) {
317 return undefined
318 }
319 const { main, ...rest } = value as Ledger & { main?: Record<string, Tally> }
320 if (typeof rest.daily === 'object' && rest.daily !== null) {
321 return { ...emptyLedger(), ...rest }
322 }
323 if (typeof main !== 'object' || main === null) {
324 return undefined
325 }
326
327 return { ...emptyLedger(), ...rest, daily: Object.keys(main).length === 0 ? {} : { [dayOf(rest.updated)]: main } }
328}
329
330// Every session written to in the window, with its main tokens of the days in the window and
331// its spawns whose last step falls in it. The window is `days` days ending today, or `day` alone.
332export function buildReport(stored: unknown[], days: number, now: number, day?: string): Report {
333 return ledgerReport(stored, day ?? firstDay(now, days), day ?? dayOf(now), now)
334}
335
336// The days from `from` to `to`, both included.
337export function daysBetween(from: string, to: string): number {
338 return Math.round((Date.parse(`${to}T12:00:00`) - Date.parse(`${from}T12:00:00`)) / 86_400_000) + 1
339}
340
341// The stats pane's period, `from` to `to`: the days from liveFrom on from the ledgers, with their
342// sessions, and the days before from their day rows, counted in the totals alone. `kept` says
343// whether any of it reaches the ledgers, so there are rows to list.
344export function windowReport(stored: unknown[], dayRows: Record<string, unknown>, from: string, to: string, now: number): Report & { kept: boolean } {
345 const live = liveFrom(now)
346 const kept = to >= live
347 const report = ledgerReport(kept ? stored : [], from < live ? live : from, to, now)
348 report.from = from
349 report.days = daysBetween(from, to)
350 for (let day = from; day <= to && day < live; day = addDays(day, 1)) {
351 const row = readDayRow(dayRows[day])
352 if (row === undefined) {
353 continue
354 }
355 const part = dayReport(row, day, now)
356 report.main += part.main
357 report.sub += part.sub
358 report.cacheRead += part.cacheRead
359 for (const key of Object.keys(report.spawned) as (keyof Report['spawned'])[]) {
360 report.spawned[key] += part.spawned[key]
361 }
362 }
363
364 return { ...report, kept }
365}
366
367function ledgerReport(stored: unknown[], from: string, to: string, now: number): Report {
368 const report: Report = { at: now, days: daysBetween(from, to), from, to, main: 0, sub: 0, cacheRead: 0, spawned: { all: 0, explicit: 0, opus: 0, forks: 0, defaults: 0 }, sessions: [] }
369 for (const ledger of stored.map(readLedger)) {
370 if (ledger === undefined || dayOf(ledger.updated) < from) {
371 continue
372 }
373 const spawns = ledger.spawns.filter(spawn => dayOf(spawn.last) >= from && dayOf(spawn.last) <= to)
374 const main = new Map<string, number>()
375 const cacheByModel: Record<string, number> = {}
376 const lastByModel: Record<string, string> = {}
377 let cacheRead = 0
378 for (const [day, models] of Object.entries(ledger.daily)) {
379 if (day < from || day > to) {
380 continue
381 }
382 for (const [model, tally] of Object.entries(models)) {
383 main.set(model, (main.get(model) ?? 0) + tokens(tally))
384 cacheByModel[model] = (cacheByModel[model] ?? 0) + tally.cacheRead
385 if (tokens(tally) > 0 && day > (lastByModel[model] ?? '')) {
386 lastByModel[model] = day
387 }
388 cacheRead += tally.cacheRead
389 }
390 }
391 const mainByModel = [...main]
392 const row: SessionRow = {
393 repo: ledger.repo,
394 started: ledger.started || ledger.updated,
395 prompt: ledger.prompt,
396 main: mainByModel.reduce((sum, [, count]) => sum + count, 0),
397 mainByModel,
398 cacheByModel,
399 lastByModel,
400 cacheRead,
401 spawns,
402 }
403 if (row.main === 0 && spawns.length === 0) {
404 continue
405 }
406 report.sessions.push(row)
407 report.main += row.main
408 report.cacheRead += row.cacheRead
409 for (const spawn of spawns) {
410 report.sub += tokens(spawn)
411 report.cacheRead += spawn.cacheRead
412 }
413 addKinds(report.spawned, spawns)
414 }
415 report.sessions.sort((a, b) => b.started - a.started)
416
417 return report
418}
419
420// --- Day rows: one per local day under `day:YYYY-MM-DD`, kept DAY_KEEP days, so the heatmap
421// outlives the ledgers. Each is computed whole from the ledgers, never added to (ADR 0024).
422
423export type DayRow = {
424 // The main session's tokens that day, by model.
425 main: Record<string, Tally>
426 // The tokens of the spawns whose last step fell that day, by the model each resolved to.
427 sub: Record<string, Tally>
428 spawned: Report['spawned']
429 // When the row was computed.
430 at: number
431}
432
433export function readDayRow(value: unknown): DayRow | undefined {
434 const row = value as DayRow | undefined
435 const isObject = (part: unknown) => typeof part === 'object' && part !== null
436 if (!isObject(row) || !isObject(row!.main) || !isObject(row!.sub) || !isObject(row!.spawned)) {
437 return undefined
438 }
439
440 return row
441}
442
443function add(into: Tally, from: Tally): void {
444 into.input += from.input
445 into.cacheWrite += from.cacheWrite
446 into.cacheRead += from.cacheRead
447 into.output += from.output
448 into.steps += from.steps
449}
450
451// The days a ledger has a record of: from its start to its last write, at most DAY_KEEP of them
452// back from the last, and any day it counted tokens on (a ledger of the old format has no start).
453export function recordedDays(ledger: Ledger): Set<string> {
454 const days = new Set(Object.keys(ledger.daily))
455 const last = dayOf(ledger.updated)
456 const first = dayOf(ledger.started || ledger.updated)
457 for (let day = last, i = 0; day >= first && i < DAY_KEEP; day = addDays(day, -1), i += 1) {
458 days.add(day)
459 }
460
461 return days
462}
463
464export function dayRow(ledgers: Ledger[], day: string, at: number): DayRow {
465 const row: DayRow = { main: {}, sub: {}, spawned: { all: 0, explicit: 0, opus: 0, forks: 0, defaults: 0 }, at }
466 for (const ledger of ledgers) {
467 for (const [model, tally] of Object.entries(ledger.daily[day] ?? {})) {
468 add((row.main[model] ??= zero()), tally)
469 }
470 const spawns = ledger.spawns.filter(spawn => dayOf(spawn.last) === day)
471 for (const spawn of spawns) {
472 add((row.sub[spawn.model] ??= zero()), spawn)
473 }
474 addKinds(row.spawned, spawns)
475 }
476
477 return row
478}
479
480// The days a ledger recorded up to the day before yesterday (yesterday a session may still write
481// to), oldest first: the days a day row may be computed for.
482export function settledDays(ledgers: Ledger[], now: number): string[] {
483 const last = addDays(dayOf(now), -2)
484 const days = new Set<string>()
485 for (const ledger of ledgers) {
486 for (const day of recordedDays(ledger)) {
487 if (day <= last) {
488 days.add(day)
489 }
490 }
491 }
492
493 return [...days].sort()
494}
495
496function sumTokens(tallies: Record<string, Tally>): number {
497 return Object.values(tallies).reduce((total, tally) => total + tokens(tally), 0)
498}
499
500// A one-day report from a day row alone: the cards' figures, no sessions.
501export function dayReport(row: DayRow, day: string, now: number): Report {
502 const cacheRead = [...Object.values(row.main), ...Object.values(row.sub)].reduce((total, tally) => total + tally.cacheRead, 0)
503
504 return { at: now, days: 1, from: day, to: day, main: sumTokens(row.main), sub: sumTokens(row.sub), cacheRead, spawned: { ...row.spawned }, sessions: [] }
505}
506
507// The first day the heatmap and a chosen day read from the ledgers; before it, from day rows.
508export function liveFrom(now: number): string {
509 return firstDay(now, LIVE_DAYS)
510}
511
512// --- The calendar heatmap.
513
514// One day: its tokens (main session and spawns, without cache read), 0 for a day recorded with no
515// use, null for a day nothing recorded.
516export type HeatDay = { day: string; tokens: number | null }
517
518// How many weeks a pane of `bodyColumns` fits: a week takes two columns after four for the labels.
519export function heatWeeks(bodyColumns: number): number {
520 return Math.min(MAX_WEEKS, Math.max(MIN_WEEKS, Math.floor((bodyColumns - 4) / 2)))
521}
522
523// The heatmap's first day: the Monday `weeks - 1` weeks before this week's.
524export function heatStart(weeks: number, now: number): string {
525 const today = dayOf(now)
526
527 return addDays(today, -weekday(today) - (weeks - 1) * 7)
528}
529
530// `weeks` weeks of days, Monday first, ending today: the last LIVE_DAYS days from the ledgers, as
531// the rest of the pane counts them, and the days before from their day rows.
532export function buildHeatmap(stored: unknown[], dayRows: Record<string, unknown>, weeks: number, now: number): { days: HeatDay[] } {
533 const today = dayOf(now)
534 const live = liveFrom(now)
535 const recorded = new Set<string>()
536 const used = new Map<string, number>()
537 const count = (day: string, value: number) => used.set(day, (used.get(day) ?? 0) + value)
538 for (const ledger of stored.map(readLedger)) {
539 if (ledger === undefined) {
540 continue
541 }
542 for (const day of recordedDays(ledger)) {
543 recorded.add(day)
544 }
545 for (const [day, models] of Object.entries(ledger.daily)) {
546 count(day, sumTokens(models))
547 }
548 for (const spawn of ledger.spawns) {
549 count(dayOf(spawn.last), tokens(spawn))
550 }
551 }
552 const days: HeatDay[] = []
553 for (let day = heatStart(weeks, now); day <= today; day = addDays(day, 1)) {
554 if (day >= live) {
555 days.push({ day, tokens: recorded.has(day) ? (used.get(day) ?? 0) : null })
556 } else {
557 const row = readDayRow(dayRows[day])
558 days.push({ day, tokens: row === undefined ? null : sumTokens(row.main) + sumTokens(row.sub) })
559 }
560 }
561
562 return { days }
563}
564
565// The level 1–4 of a day's tokens among the used days drawn, by quartile; 0 for no use.
566export function heatLevels(days: HeatDay[]): (value: number | null) => number {
567 const values = days.flatMap(item => (item.tokens !== null && item.tokens > 0 ? [item.tokens] : [])).sort((a, b) => a - b)
568 const at = (p: number) => values[Math.max(0, Math.ceil(values.length * p) - 1)] ?? 0
569 const [q1, q2, q3] = [at(0.25), at(0.5), at(0.75)]
570
571 return value => (value === null || value <= 0 ? 0 : value <= q1 ? 1 : value <= q2 ? 2 : value <= q3 ? 3 : 4)
572}
573
574// The month row over the heatmap's weeks: a label where a month first shows (the week whose
575// Sunday falls in it); on the first week only when its Monday is the 1st.
576export function monthMarks(lang: Lang, days: HeatDay[]): { week: number; label: string }[] {
577 const marks: { week: number; label: string }[] = []
578 let last = ''
579 for (let week = 0; week * 7 < days.length; week += 1) {
580 const monday = days[week * 7]!.day
581 const sunday = addDays(monday, 6)
582 if (sunday.slice(0, 7) !== last && (week > 0 || monday.endsWith('-01'))) {
583 const month = Number(sunday.slice(5, 7))
584 marks.push({ week, label: pick(lang, { en: MONTHS[month - 1]!, 'zh-TW': `${month}月` }) })
585 }
586 last = sunday.slice(0, 7)
587 }
588
589 return marks
590}
591
592// A count in a few characters: `950`, `12k`, `1.2M`, `15M`; rounding never reads `1000k` or `10.0M`.
593export function shortCount(value: number): string {
594 const millions = value / 1e6
595 if (millions >= 9.95) {
596 return `${Math.round(millions)}M`
597 }
598 if (value >= 999_500) {
599 return `${millions.toFixed(1)}M`
600 }
601
602 return value >= 999.5 ? `${Math.round(value / 1e3)}k` : String(Math.round(value))
603}
604
605// --- Rebuilding ledgers from transcripts: `<projects>/<project>/<sid>.jsonl` is a session, and
606// `<project>/<sid>/subagents/agent-<id>.jsonl` with its `.meta.json` one of its spawns.
607
608export type ParsedStep = { at: number; model: string; tally: Tally }
609export type Parsed = { started: number; updated: number; prompt: string; cwd: string; steps: ParsedStep[] }
610
611type Line = {
612 type?: string
613 timestamp?: string
614 cwd?: string
615 isSidechain?: boolean
616 isMeta?: boolean
617 message?: { id?: string; model?: string; content?: unknown; usage?: Record<string, number | undefined> }
618}
619
620function promptText(content: unknown): string {
621 if (typeof content === 'string') {
622 return content
623 }
624 if (!Array.isArray(content)) {
625 return ''
626 }
627
628 return content.flatMap(block => ((block as { type?: string }).type === 'text' ? [String((block as { text?: unknown }).text ?? '')] : [])).join(' ')
629}
630
631// A transcript's first and last time, first prompt, first directory and steps. A reply is written
632// as a line per content block: one step, with the usage of its last line. The lines of a subagent
633// (`isSidechain`) count only with `sidechain`, for a subagent's own transcript.
634export function parseTranscript(text: string, sidechain = false): Parsed {
635 const parsed: Parsed = { started: 0, updated: 0, prompt: '', cwd: '', steps: [] }
636 const steps = new Map<string, ParsedStep>()
637 for (const raw of text.split('\n')) {
638 let line: Line
639 try {
640 line = JSON.parse(raw) as Line
641 } catch {
642 continue
643 }
644 if (typeof line !== 'object' || line === null) {
645 continue
646 }
647 const at = typeof line.timestamp === 'string' ? Date.parse(line.timestamp) : Number.NaN
648 if (!Number.isNaN(at)) {
649 parsed.started ||= at
650 parsed.updated = Math.max(parsed.updated, at)
651 }
652 parsed.cwd ||= typeof line.cwd === 'string' ? line.cwd : ''
653 if (line.isSidechain === true && !sidechain) {
654 continue
655 }
656 if (line.type === 'user' && parsed.prompt === '' && line.isMeta !== true) {
657 const words = promptText(line.message?.content).trim()
658 if (words !== '' && !words.startsWith('<')) {
659 parsed.prompt = promptSummary(words)
660 }
661 }
662 const usage = line.message?.usage
663 const model = line.message?.model ?? ''
664 if (line.type !== 'assistant' || typeof usage !== 'object' || model === '' || model === '<synthetic>' || Number.isNaN(at)) {
665 continue
666 }
667 steps.set(line.message?.id ?? `${at}:${steps.size}`, {
668 at,
669 model,
670 tally: {
671 input: usage.input_tokens ?? 0,
672 cacheWrite: usage.cache_creation_input_tokens ?? 0,
673 cacheRead: usage.cache_read_input_tokens ?? 0,
674 output: usage.output_tokens ?? 0,
675 steps: 1,
676 },
677 })
678 }
679 parsed.steps = [...steps.values()]
680
681 return parsed
682}
683
684// What a subagent's `.meta.json` holds: `model` is the Agent tool's as given, `inherit` on a fork.
685type SubagentMeta = { agentType?: string; description?: string; model?: string; requestShape?: string; isFork?: boolean }
686
687// A spawn from a subagent's transcript and its meta; undefined for a transcript with no time in it.
688export function parseSubagent(meta: unknown, text: string, agentId: string): Spawn | undefined {
689 const info = (typeof meta === 'object' && meta !== null ? meta : {}) as SubagentMeta
690 const parsed = parseTranscript(text, true)
691 const first = parsed.steps[0]
692 const at = first?.at ?? parsed.started
693 if (at === 0) {
694 return undefined
695 }
696 const fork = info.isFork === true || info.agentType === 'fork'
697 const spawn: Spawn = {
698 ...zero(),
699 agentId,
700 at,
701 last: parsed.steps.at(-1)?.at ?? at,
702 description: info.description ?? '',
703 type: fork ? 'fork' : (info.agentType ?? 'general-purpose'),
704 ...(fork || typeof info.model !== 'string' ? {} : { requested: info.model }),
705 model: first?.model ?? 'unknown',
706 fork,
707 background: info.requestShape !== 'foreground',
708 }
709 for (const step of parsed.steps) {
710 add(spawn, step.tally)
711 }
712
713 return spawn
714}
715
716// A session's ledger rebuilt from its transcript and its spawns'.
717export function transcriptLedger(parsed: Parsed, spawns: Spawn[], repo: string): Ledger {
718 const ledger: Ledger = { ...emptyLedger(), repo, started: parsed.started, prompt: parsed.prompt, source: 'transcript' }
719 for (const step of parsed.steps) {
720 add(((ledger.daily[dayOf(step.at)] ??= {})[step.model] ??= zero()), step.tally)
721 }
722 ledger.spawns = [...spawns].sort((a, b) => a.at - b.at).slice(-SPAWN_CAP)
723 ledger.updated = Math.max(parsed.updated, ...ledger.spawns.map(spawn => spawn.last))
724
725 return ledger
726}
727
728// Tokens per model over the report: the main session's by the model each step named, a
729// spawn's by the model it resolved to.
730export function byModel(report: Report): [string, number][] {
731 const totals = new Map<string, number>()
732 for (const session of report.sessions) {
733 for (const [model, count] of session.mainByModel) {
734 totals.set(model, (totals.get(model) ?? 0) + count)
735 }
736 for (const spawn of session.spawns) {
737 totals.set(spawn.model, (totals.get(spawn.model) ?? 0) + tokens(spawn))
738 }
739 }
740
741 return [...totals].sort((a, b) => b[1] - a[1])
742}
743
744// The report's sessions grouped by repo, the repo of the newest session first.
745export function byRepo(report: Report): [string, SessionRow[]][] {
746 const groups = new Map<string, SessionRow[]>()
747 for (const session of report.sessions) {
748 groups.set(session.repo, [...(groups.get(session.repo) ?? []), session])
749 }
750
751 return [...groups]
752}
753
754export type GroupBy = 'repo' | 'session' | 'model' | 'type'
755export const GROUP_BYS: readonly GroupBy[] = ['repo', 'session', 'model', 'type']
756
757// One row of the pane's grouping, and what it opens to: per session, the main tokens that
758// fall in the group and the spawns that do. `main` is the main session's part of `tokens`,
759// `cacheRead` both parts' cache read, and `last` the last day either used tokens ('' for none).
760export type Group = {
761 key: string
762 label: string
763 tokens: number
764 main: number
765 spawns: number
766 cacheRead: number
767 last: string
768 detail: { session: SessionRow; main: number; spawns: Spawn[] }[]
769}
770
771// What of a session's main tokens falls in a group: those of `models`, or of every model.
772type MainPart = { tokens: number; cacheRead: number; last: string }
773
774function mainPart(session: SessionRow, models = session.mainByModel.map(([model]) => model)): MainPart {
775 const tokensOf = new Map(session.mainByModel)
776 const part: MainPart = { tokens: 0, cacheRead: 0, last: '' }
777 for (const model of models) {
778 part.tokens += tokensOf.get(model) ?? 0
779 part.cacheRead += session.cacheByModel[model] ?? 0
780 const last = session.lastByModel[model] ?? ''
781 if (last > part.last) {
782 part.last = last
783 }
784 }
785
786 return part
787}
788
789export function groups(lang: Lang, report: Report, by: GroupBy): Group[] {
790 const found = new Map<string, Group>()
791 const none: MainPart = { tokens: 0, cacheRead: 0, last: '' }
792 const add = (key: string, label: string, session: SessionRow, part: MainPart, spawns: Spawn[]) => {
793 const main = part.tokens
794 let group = found.get(key)
795 if (group === undefined) {
796 group = { key, label, tokens: 0, main: 0, spawns: 0, cacheRead: 0, last: '', detail: [] }
797 found.set(key, group)
798 }
799 group.tokens += main + spawns.reduce((sum, spawn) => sum + tokens(spawn), 0)
800 group.main += main
801 group.cacheRead += part.cacheRead + spawns.reduce((sum, spawn) => sum + spawn.cacheRead, 0)
802 for (const day of [part.last, ...spawns.filter(spawn => tokens(spawn) > 0).map(spawn => dayOf(spawn.last))]) {
803 if (day > group.last) {
804 group.last = day
805 }
806 }
807 group.spawns += spawns.filter(spawn => spawn.type !== 'unknown').length
808 const last = group.detail.at(-1)
809 if (last?.session === session) {
810 last.main += main
811 last.spawns.push(...spawns)
812 } else {
813 group.detail.push({ session, main, spawns: [...spawns] })
814 }
815 }
816 for (const session of report.sessions) {
817 if (by === 'repo') {
818 add(session.repo, repoName(lang, session.repo), session, mainPart(session), session.spawns)
819 } else if (by === 'session') {
820 add(`${session.repo}|${session.started}`, sessionName(lang, session), session, mainPart(session), session.spawns)
821 } else if (by === 'model') {
822 for (const [model] of session.mainByModel) {
823 add(model, model, session, mainPart(session, [model]), [])
824 }
825 for (const spawn of session.spawns) {
826 add(spawn.model, spawn.model, session, none, [spawn])
827 }
828 } else {
829 if (session.main > 0) {
830 add('main', pick(lang, { en: 'main session', 'zh-TW': '主 session' }), session, mainPart(session), [])
831 }
832 for (const spawn of session.spawns) {
833 add(spawn.type, spawn.type, session, none, [spawn])
834 }
835 }
836 }
837
838 return [...found.values()].sort((a, b) => b.tokens - a.tokens)
839}
840
841export const n = (value: number): string => value.toLocaleString('en-US')
842
843// Characters a terminal draws two cells wide (CJK, full-width forms, emoji).
844const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩]|[\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u
845
846export function cells(text: string): number {
847 let count = 0
848 for (const ch of text) {
849 count += WIDE.test(ch) ? 2 : 1
850 }
851
852 return count
853}
854
855// Cut to `width` cells, ending in "…" when cut: a Button's text does not truncate on its own and
856// wraps instead once CJK and Latin mix.
857export function fit(text: string, width: number): string {
858 if (cells(text) <= width) {
859 return text
860 }
861 let out = ''
862 let used = 0
863 for (const ch of text) {
864 const w = WIDE.test(ch) ? 2 : 1
865 if (used + w > width - 1) {
866 break
867 }
868 out += ch
869 used += w
870 }
871
872 return `${out}…`
873}
874
875export function share(part: number, whole: number): string {
876 return whole === 0 ? '0%' : `${Math.round((part / whole) * 100)}%`
877}
878
879export function elapsed(ms: number): string {
880 const s = Math.max(0, Math.floor(ms / 1000))
881 if (s < 60) {
882 return `${s}s`
883 }
884 const m = Math.floor(s / 60)
885 if (m < 60) {
886 return `${m}m${String(s % 60).padStart(2, '0')}s`
887 }
888
889 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
890}
891
892export function sessionName(lang: Lang, session: SessionRow): string {
893 return `${stamp(lang, session.started)} ${session.prompt || pick(lang, { en: '(no prompt)', 'zh-TW': '(沒有 prompt)' })}`
894}
895
896// A repo by its folder's name, as the pane's rows show it.
897export function repoName(lang: Lang, repo: string): string {
898 return repo.split('/').filter(Boolean).at(-1) ?? pick(lang, { en: '(no repo)', 'zh-TW': '(沒有 repo)' })
899}
900
901// A spawn in three parts: what it was asked, what ran it, and what it cost.
902export function spawnParts(lang: Lang, spawn: Spawn): [string, string, string] {
903 return [
904 spawn.description || pick(lang, { en: '(spawned before it was recorded)', 'zh-TW': '(派出時沒記到)' }),
905 `${spawn.type} · ${spawn.requested ?? pick(lang, { en: 'default', 'zh-TW': '預設' })} → ${spawn.model}`,
906 [
907 ...(spawn.background ? [pick(lang, { en: 'background', 'zh-TW': '背景' })] : []),
908 pick(lang, { en: `${n(tokens(spawn))} tokens`, 'zh-TW': `${n(tokens(spawn))} token` }),
909 pick(lang, { en: `${spawn.steps} steps`, 'zh-TW': `${spawn.steps} 步` }),
910 elapsed(spawn.last - spawn.at),
911 ].join(' · '),
912 ]
913}
914
915export function spawnLine(lang: Lang, spawn: Spawn): string {
916 return spawnParts(lang, spawn).join(' · ')
917}
918
919export function emptyText(lang: Lang): string {
920 return pick(lang, {
921 en: 'No usage recorded yet (recording starts once the ledger setting is on and subagent-dispatch is loaded).',
922 'zh-TW': '還沒有用量紀錄(ledger 設定開啟且載入 subagent-dispatch 後才開始記)。',
923 })
924}
925
926export function summaryLines(lang: Lang, report: Report): string[] {
927 const all = report.main + report.sub
928 const s = report.spawned
929
930 return [
931 pick(lang, {
932 en: `Tokens (input + cache write + output): ${n(all)}`,
933 'zh-TW': `Token(input + cache write + output):${n(all)}`,
934 }),
935 pick(lang, { en: ` main session ${n(report.main)}`, 'zh-TW': ` 主 session ${n(report.main)}` }),
936 pick(lang, {
937 en: ` subagents ${n(report.sub)} (${share(report.sub, all)} of all tokens)`,
938 'zh-TW': ` subagent ${n(report.sub)}(佔全部 ${share(report.sub, all)})`,
939 }),
940 pick(lang, { en: `Cache read (not counted above): ${n(report.cacheRead)}`, 'zh-TW': `Cache read(不含在上面):${n(report.cacheRead)}` }),
941 pick(lang, {
942 en: `Spawns: ${s.all} (named a model ${s.explicit}, Opus among them ${s.opus}; default model ${s.defaults}; fork ${s.forks})`,
943 'zh-TW': `派出:${s.all} 次(指定模型 ${s.explicit},其中 Opus ${s.opus};沿用預設 ${s.defaults};fork ${s.forks})`,
944 }),
945 ]
946}
947
948export const NOT_INCLUDED: Record<Lang, string> = {
949 en: "Not included: mods' own model calls ($.model.fork/complete), cloud sessions, and sessions whose transcripts this machine no longer had when recording began.",
950 'zh-TW': '不含:各 mod 自己呼叫模型($.model.fork/complete)的用量、雲端 session,以及開始記錄時這台電腦已經沒有 transcript 的 session。',
951}
952
953// `/subagent-dispatch stats --text [days]`, and what `stats` prints where no pane can be placed.
954export function renderStats(lang: Lang, report: Report): string {
955 if (report.sessions.length === 0) {
956 return emptyText(lang)
957 }
958 const all = report.main + report.sub
959 const lines: string[] = [
960 pick(lang, {
961 en: `subagent-dispatch usage, ${daysLabel(lang, report.days)}: ${range(lang, report)}`,
962 'zh-TW': `subagent-dispatch 用量,${daysLabel(lang, report.days)}:${range(lang, report)}`,
963 }),
964 '',
965 ...summaryLines(lang, report),
966 '',
967 pick(lang, { en: 'Tokens per model', 'zh-TW': '各模型 token' }),
968 ...byModel(report).map(([model, count]) => ` ${model.padEnd(28)}${n(count)} (${share(count, all)})`),
969 '',
970 pick(lang, { en: 'Sessions', 'zh-TW': '各 session' }),
971 ]
972 for (const [repo, sessions] of byRepo(report)) {
973 lines.push(repo || pick(lang, { en: '(no repo)', 'zh-TW': '(沒有 repo)' }))
974 for (const session of sessions) {
975 lines.push(
976 ` ${sessionName(lang, session)} ` +
977 pick(lang, {
978 en: `main ${n(session.main)} · ${session.spawns.length} spawn(s)`,
979 'zh-TW': `主 ${n(session.main)} · 派出 ${session.spawns.length} 次`,
980 }),
981 )
982 lines.push(...session.spawns.map(spawn => ` - ${spawnLine(lang, spawn)}`))
983 }
984 }
985 lines.push('', NOT_INCLUDED[lang])
986
987 return lines.join('\n')
988}
989types/index.d.ts 22 lines1export type WatchedAgent = {
2 id: string
3 description: string
4 type: string
5 model?: string
6 startedAt: number
7 // 'running' while it works; 'killed' or 'failed' once it stopped without finishing.
8 status: string
9}
10
11// What the stats pane shows: the period in days, the grouping (repo, session, model or type),
12// the key of the row opened to its spawns ('' for none), their order (tokens, time or
13// duration), how many rows are listed, the spawn opened in place ('' for none), and the period's
14// last day (`YYYY-MM-DD`, '' for today). A view saved before the last four existed lacks them.
15export type StatsView = { days: number; by: string; item: string; sort: string; limit: number; spawn: string; end: string }
16
17declare module 'claude-code' {
18 interface PluginState {
19 'subagent-dispatch': { running: WatchedAgent[]; stopped: WatchedAgent[]; view: StatsView }
20 }
21}
22