SLOPSHOPPER

usage

Model, folder, git branch/worktree, context and rate-limit band above the prompt, with threshold toasts, /limits and a /ctx pane

newpanebandcommandtoastprocess
v0.9.0MITupdated 2026-10-06jaaaackieLai/claude-mods/plugins/usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage
│ ┃ Context 明細 ✕ › fix the failing auth test and add an audit log call │ ┃ 尚無資料 │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ 重新估算 ] [ 精確計算 ] [ 關閉 ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /limits │ ⎿ usage: Opus 5.5 · app 🌿 a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 │ ⎿ usage: Context:🌞 晴 · 自動壓縮已關閉,佔視窗 49%(97.4k / 200k) │ ⎿ usage: 5h:已用 31%,? 後重置 │ ⎿ usage: 7d:尚無資料 │ │ ✨ Opus 5.5 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✨ Opus 5.5 ⟨Claude Code's own drawing⟩
Pane · Context 明細
尚無資料 [ 重新估算 ] [ 精確計算 ] [ 關閉 ]
README

claude-mods

Claude Code 2.1.286+ Terminal | Desktop


有哪些 mod

mod做什麼說明
usage輸入列上方顯示模型、effort、資料夾、git 分支、context、5h 和 7d 額度。/limits 看文字版,/ctx 開 context 明細面板
agent-monitor子代理在跑時,輸入列上方顯示摘要列。/agent-monitor 開面板,列出每個子代理的模型、context、token、估計費用和時間README
todo-calendar面板顯示一週的待辦。打 /todo 開面板,也可以請 Claude 幫你新增或修改任務README
typo-picker打錯字時畫底線,輸入列上方跳出候選字,點一下就換掉README
gold-fish自動記住工作堆疊,輸入列上方的金魚列顯示主線和支線。支線結束時,提醒你回到主線README

安裝

1. 加入 marketplace(在 Claude Code 輸入列打):

/plugin marketplace add jaaaackieLai/claude-mods

2. 選要裝的 mod:打 /plugin,到 marketplace 的清單裡選 mod 安裝。要幾個就裝幾個。

或在終端機,一次裝一個:

claude plugin marketplace add jaaaackieLai/claude-mods
claude plugin install usage@claude-mods

把 usage 換成 agent-monitor、todo-calendar、typo-picker 或 gold-fish,就能裝其他 mod。

更新:在終端機執行 claude plugin marketplace update claude-mods,然後開一個新 session。

開發

sh scripts/check.sh

這個腳本檢查 marketplace 和每個 mod:claude plugin validate、claude plugin test、tsc。它也檢查 mod 之間的衝突:指令名稱、面板 id 和 $.state 有沒有重複。

畫在輸入列上方(AbovePrompt)的 mod 必須呼叫 next(e),並把下層 mod 的列一起畫出來。每個這類 mod 都要有一個測試,檢查下層的列還在。

致謝

agent-monitor 改寫自 JohnnyVizz/claude-kit 的 savvy-progress(MIT 授權,著作權人 johnnyvizz)。原作的著作權聲明保留在 plugins/agent-monitor/LICENSE。

授權

MIT。agent-monitor 另有自己的 LICENSE,裡面保留原作者 johnnyvizz 的著作權聲明。

Source 2 files
hooks/register.tsx 610 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  ElementConstructor,
4  EngineInterface,
5  Register,
6  RenderChildren,
7  SessionMeasureInput,
8  SessionUsage,
9  TextProps,
10} from 'claude-code'
11
12import type { Breakdown, CompactInfo, GitInfo, LimitWindow, Usage } from '../types'
13
14// Usage band above the prompt: model, folder, git branch/worktree, a context
15// "forecast" measured against the auto-compact threshold, and the rate limits;
16// plus threshold toasts, /limits and a /ctx pane.
17
18const PANE = 'usage-ctx'
19// The snapshot goes to <home>/.claude/usage-state.json, the .claude folder
20// found in the plugin's own install path (…/.claude/plugins/cache/…).
21const stateFileFor = (pluginRoot: string) => {
22  const m = /^(.*?[\\/]\.claude)[\\/]/.exec(pluginRoot)
23  return m ? `${m[1]}/usage-state.json` : undefined
24}
25// Only the last two turns are kept: enough for the per-turn delta.
26const HISTORY_TURNS = 2
27
28const usageAtom = atom({ plugin: 'usage', key: 'usage' } as const, null)
29const nowAtom = atom({ plugin: 'usage', key: 'now' } as const, 0)
30const firedAtom = atom({ plugin: 'usage', key: 'fired' } as const, [])
31const breakdownAtom = atom({ plugin: 'usage', key: 'breakdown' } as const, null)
32const loadingAtom = atom({ plugin: 'usage', key: 'isLoading' } as const, false)
33const compactAtom = atom({ plugin: 'usage', key: 'compact' } as const, null)
34const historyAtom = atom({ plugin: 'usage', key: 'history' } as const, [])
35const effortAtom = atom({ plugin: 'usage', key: 'effort' } as const, null)
36
37// -- colors and the three levels ------------------------------------------------
38const C_GREEN = '#3EB859'
39const C_YELLOW = '#F1FA8C'
40const C_RED = '#FF5555'
41const C_ORANGE = '#FFB86C'
42const C_BRANCH = '#8BE9FD'
43
44// -- chip palette, tuned for a dark background ------------------------------------
45// Each chip is a dim tinted surface (bg), its accent (fg, icons and labels), a
46// muted note color (dim), a divider (line) and the meter's empty track.
47type Tone = { bg: string; fg: string; dim: string; line: string; track: string }
48const T_TEXT = '#E6E6EB'
49const TONE_MODEL: Tone = { bg: '#2B2440', fg: '#C3A6FF', dim: '#8C7BB8', line: '#4A3F6B', track: '#3C3358' }
50const TONE_DIR: Tone = { bg: '#24262E', fg: '#9AA3B5', dim: '#6B7280', line: '#3A3D48', track: '#353843' }
51const TONE_CTX: Tone = { bg: '#1F2A36', fg: '#7CB8E8', dim: '#5F7F9C', line: '#33465A', track: '#2E3E50' }
52const TONE_H5: Tone = { bg: '#1C2E24', fg: '#7FD49B', dim: '#5E9A73', line: '#2F4A3A', track: '#2C4436' }
53const TONE_D7: Tone = { bg: '#272540', fg: '#A9A2F0', dim: '#7C76B8', line: '#3E3A66', track: '#36335A' }
54
55// A short bar: the used part in the level's color, the rest as the chip's track.
56const METER_CELLS = 3
57function meter(Text: ElementConstructor<TextProps>, pct: number, tone: Tone) {
58  const filled = Math.max(pct > 0 ? 1 : 0, Math.min(METER_CELLS, Math.round((pct / 100) * METER_CELLS)))
59  return (
60    <Text>
61      <Text color={colorOf(pct)}>{'━'.repeat(filled)}</Text>
62      <Text color={tone.track}>{'━'.repeat(METER_CELLS - filled)}</Text>
63    </Text>
64  )
65}
66
67// Line icons for surfaces that draw Svg (14px, stroked in the chip's accent); glyphs on the terminal.
68type IconName = 'model' | 'folder' | 'gauge' | 'calendar'
69const ICON_GLYPH: Record<IconName, string> = {
70  model: '✨',
71  folder: '📁',
72  gauge: '⏳',
73  calendar: '📅',
74}
75const ICON_PATHS: Record<IconName, string> = {
76  model: '<path d="M12 3l2.2 5.8L20 11l-5.8 2.2L12 19l-2.2-5.8L4 11l5.8-2.2z"/>',
77  folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
78  gauge: '<path d="M4.5 17a8.5 8.5 0 1 1 15 0"/><path d="M12 13l4-4"/><circle cx="12" cy="13" r="1.2"/>',
79  calendar: '<rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/>',
80}
81const iconSvg = (name: IconName, color: string) =>
82  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[name]}</svg>`
83
84// One set of thresholds for context and both rate limits: green < 50 <= yellow < 80 <= red.
85const LEVELS = [50, 80] as const
86type Level = 0 | 1 | 2
87const levelOf = (p: number): Level => (p >= LEVELS[1] ? 2 : p >= LEVELS[0] ? 1 : 0)
88const LEVEL_COLOR = [C_GREEN, C_YELLOW, C_RED] as const
89const colorOf = (p: number) => LEVEL_COLOR[levelOf(p)]
90
91// The context forecast, one per level.
92const WEATHER = [
93  { icon: '🌞', word: '晴' },
94  { icon: '☔', word: '陣雨' },
95  { icon: '⚡', word: '快壓縮' },
96] as const
97
98// -- helpers ------------------------------------------------------------------
99function untilStr(resetsAt: string | undefined, nowMs: number, days = false) {
100  if (!resetsAt) return ''
101  const t = Date.parse(resetsAt)
102  if (Number.isNaN(t)) return ''
103  const d = Math.max(0, Math.floor((t - nowMs) / 1000))
104  if (days && d >= 86400) return `${Math.floor(d / 86400)}d${Math.floor((d % 86400) / 3600)}h`
105  if (d >= 3600) return `${Math.floor(d / 3600)}h${String(Math.floor((d % 3600) / 60)).padStart(2, '0')}m`
106  if (d >= 60) return `${Math.floor(d / 60)}m`
107  return `${d}s`
108}
109
110const base = (p: string) => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || p
111
112function dirLabel(cwd: string, root: string) {
113  const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase()
114  if (cwd && root && norm(cwd).startsWith(norm(root))) {
115    return cwd.slice(root.length).replace(/^[\\/]+/, '') || base(root)
116  }
117  return base(cwd || root || '?')
118}
119
120// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5,
121// claude-sonnet-5-5[1m] -> Sonnet 5.5 1M; an already readable name keeps its form.
122function shortModel(m: string) {
123  const isLong = /\[1m\]$/i.test(m)
124  const id = m.replace(/\[1m\]$/i, '').replace(/^claude[-\s]+/i, '').replace(/-\d{8}$/, '')
125  const suffix = isLong ? ' 1M' : ''
126  if (!id.includes('-')) return id.charAt(0).toUpperCase() + id.slice(1) + suffix
127  const words: string[] = []
128  for (const part of id.split('-')) {
129    const prev = words[words.length - 1]
130    if (/^\d+$/.test(part) && prev !== undefined && /^[\d.]+$/.test(prev)) {
131      words[words.length - 1] = `${prev}.${part}`
132    } else {
133      words.push(/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1))
134    }
135  }
136  return words.join(' ') + suffix
137}
138const fmtTokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n))
139const gitLabel = (g: GitInfo) => (g.worktree ? `🌿 ${g.branch} · worktree ${g.worktree}` : `🌿 ${g.branch}`)
140const pctOf = (tokens: number, of: number) => Math.min(100, Math.round((tokens / Math.max(1, of)) * 100))
141const toEpoch = (iso?: string) => {
142  const t = iso ? Date.parse(iso) : NaN
143  return Number.isNaN(t) ? null : Math.floor(t / 1000)
144}
145
146// Branch, and the worktree's folder name when cwd is a linked worktree; undefined outside git.
147async function readGit($: EngineInterface, cwd: string): Promise<GitInfo | undefined> {
148  try {
149    const run = await $.process.run(
150      ['git', 'rev-parse', '--path-format=absolute', '--abbrev-ref', 'HEAD', '--show-toplevel', '--git-dir', '--git-common-dir'],
151      { cwd, timeoutMs: 3000 },
152    )
153    if (run.exitCode !== 0) return undefined
154    const [ref = '', top = '', gitDir = '', commonDir = ''] = run.stdout.trim().split(/\r?\n/)
155    let branch = ref
156    if (ref === 'HEAD') {
157      const sha = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { cwd, timeoutMs: 3000 })
158      branch = sha.exitCode === 0 ? `detached@${sha.stdout.trim()}` : 'detached'
159    }
160    const norm = (x: string) => x.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
161    const isWorktree = norm(gitDir) !== norm(commonDir)
162    return { branch, worktree: isWorktree ? base(top) : undefined }
163  } catch {
164    return undefined
165  }
166}
167
168// The auto-compact threshold, read from a local (free) breakdown estimate and
169// cached per model; `force` rereads it after /autocompact changes it.
170async function readCompact($: EngineInterface, model: string, window: number, force = false): Promise<CompactInfo> {
171  const cached = await read($, compactAtom)
172  if (!force && cached && cached.model === model) return cached
173  let info: CompactInfo = { model, at: window, isOn: false }
174  try {
175    const b = (await $.session.usage({ breakdown: 'summary' })).context.breakdown
176    if (b) info = { model, at: b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : window, isOn: b.isAutoCompactEnabled }
177  } catch {
178    // fall back to the full window
179  }
180  await update($, compactAtom, () => info)
181  return info
182}
183
184// The effort before the first model request: the config panel's effort row, when it has one.
185async function readEffortSetting($: EngineInterface) {
186  try {
187    const row = (await $.config.list()).find(r => /effort/i.test(r.key))
188    if (row && (typeof row.value === 'string' || typeof row.value === 'number') && row.value !== '') {
189      await update($, effortAtom, () => String(row.value))
190    }
191  } catch {
192    // shown from the first request instead
193  }
194}
195
196// Reads every figure the band shows; `measured` is session.measure's input when there is one.
197async function refresh($: EngineInterface, measured?: SessionUsage | SessionMeasureInput, forceCompact = false) {
198  const figures = measured ?? (await $.session.usage())
199  const [model, cwd, root] = await Promise.all([$.session.model(), $.session.cwd(), $.session.root()])
200  const compact = await readCompact($, model, figures.context.window, forceCompact)
201  const pick = (kind: string): LimitWindow | undefined => {
202    const w = figures.rateLimits.find(r => r.kind === kind)
203    return w ? { pct: Math.round(w.percentUsed), resetsAt: w.resetsAt } : undefined
204  }
205  const tokens = figures.context.tokens
206  const usage: Usage = {
207    model: shortModel(model),
208    dir: dirLabel(cwd, root),
209    git: await readGit($, cwd),
210    ctxTokens: tokens,
211    ctxWindow: figures.context.window,
212    compactAt: compact.at,
213    isCompactOn: compact.isOn,
214    ctxPct: tokens === undefined ? undefined : pctOf(tokens, compact.at),
215    h5: pick('five_hour'),
216    d7: pick('seven_day'),
217  }
218  await update($, usageAtom, () => usage)
219  await update($, nowAtom, () => Date.now())
220  return usage
221}
222
223// A toast when a figure climbs into yellow or red; a level re-arms once it falls back below.
224async function checkThresholds($: EngineInterface, usage: Usage) {
225  const checks: [string, string, number | undefined][] = [
226    ['ctx', 'Context 距自動壓縮', usage.ctxPct],
227    ['h5', '5 小時額度', usage.h5?.pct],
228    ['d7', '7 天額度', usage.d7?.pct],
229  ]
230  const fired = new Set(await read($, firedAtom))
231  for (const [id, label, pct] of checks) {
232    if (pct === undefined) continue
233    for (const level of LEVELS) {
234      const key = `${id}:${level}`
235      if (pct < level) {
236        fired.delete(key)
237      } else if (!fired.has(key)) {
238        fired.add(key)
239        // Only the highest crossed level toasts; lower ones are marked silently.
240        if (level === Math.max(...LEVELS.filter(l => pct >= l))) {
241          const hint = id === 'ctx' && level === LEVELS[1] ? ',可考慮 /compact' : ''
242          $.ui.toast(`${label}已達 ${pct}%${hint}`, { timeoutMs: 8000 })
243        }
244      }
245    }
246  }
247  await update($, firedAtom, () => [...fired])
248}
249
250// Same shape statusline.js wrote, so usage-check.js and schedulers keep working.
251async function writeSnapshot($: EngineInterface, usage: Usage) {
252  if (!usage.h5 && !usage.d7) return
253  const win = (w?: LimitWindow) => ({
254    used_percentage: w ? w.pct : null,
255    resets_at: toEpoch(w?.resetsAt),
256  })
257  const state = {
258    updated_at: Math.floor(Date.now() / 1000),
259    five_hour: win(usage.h5),
260    seven_day: win(usage.d7),
261  }
262  const file = stateFileFor($.plugin.root)
263  if (!file) return
264  try {
265    await $.fs.write(file, JSON.stringify(state, null, 2))
266  } catch {
267    // never break the band over a failed write
268  }
269}
270
271async function loadBreakdown($: EngineInterface, detail: 'summary' | 'full') {
272  await update($, loadingAtom, () => true)
273  try {
274    const { context } = await $.session.usage({ breakdown: detail })
275    const b = context.breakdown
276    if (!b) return
277    const result: Breakdown = {
278      detail,
279      model: shortModel(b.model),
280      total: b.totalTokens,
281      max: b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : b.rawMaxTokens,
282      pct: 0,
283      rows: b.categories
284        .filter(c => c.kind === 'used')
285        .map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
286      memory: [...b.memoryFiles]
287        .sort((a, z) => z.tokens - a.tokens)
288        .slice(0, 5)
289        .map(m => ({ path: m.path, tokens: m.tokens })),
290      mcpTokens: b.mcpTools.filter(t => t.isLoaded).reduce((s, t) => s + t.tokens, 0),
291      skillsTokens: b.skills?.tokens ?? 0,
292    }
293    result.pct = pctOf(result.total, result.max)
294    await update($, breakdownAtom, () => result)
295  } finally {
296    await update($, loadingAtom, () => false)
297  }
298}
299
300// -- pie chart for the /ctx pane ------------------------------------------------
301// Categorical slots in fixed order (dark-surface steps, validated with dataviz's
302// validate_palette.js); the rest of the window up to auto-compaction is gray.
303const PIE_COLORS = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9'] as const
304const C_FREE = '#3a3a38'
305const PIE_COLS = 24 // 24 columns x 12 rows of half blocks = a 24x24 pixel circle
306const MAX_NAMED = PIE_COLORS.length - 1 // the last slot is for 其他
307
308type Slice = { name: string; tokens: number; color: string }
309
310function pieSlices(b: Breakdown): Slice[] {
311  const used = [...b.rows].sort((a, z) => z.tokens - a.tokens).filter(r => r.tokens > 0)
312  const named = used.length > PIE_COLORS.length ? used.slice(0, MAX_NAMED) : used
313  const rest = used.slice(named.length).reduce((s, r) => s + r.tokens, 0)
314  const slices: Slice[] = named.map((r, i) => ({ name: r.name, tokens: r.tokens, color: PIE_COLORS[i] ?? C_FREE }))
315  if (rest > 0) slices.push({ name: '其他', tokens: rest, color: PIE_COLORS[MAX_NAMED] ?? C_FREE })
316  const free = Math.max(0, b.max - b.total)
317  if (free > 0) slices.push({ name: '尚可用(到壓縮)', tokens: free, color: C_FREE })
318  return slices
319}
320
321const DEFAULT_COLOR = 0x01000000
322const hexToInt = (hex: string) => parseInt(hex.slice(1), 16)
323
324const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
325function toBase64(bytes: Uint8Array) {
326  let out = ''
327  for (let i = 0; i < bytes.length; i += 3) {
328    const a = bytes[i] ?? 0
329    const b = bytes[i + 1] ?? 0
330    const c = bytes[i + 2] ?? 0
331    const n = (a << 16) | (b << 8) | c
332    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + (i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? B64[n & 63]! : '=')
333  }
334  return out
335}
336
337// A clockwise pie from 12 o'clock; each cell is '▀' with the upper pixel as
338// foreground and the lower as background, so pixels come out square.
339function pieCells(slices: Slice[], cols: number) {
340  const rows = cols / 2
341  const total = Math.max(1, slices.reduce((s, x) => s + x.tokens, 0))
342  const ends: number[] = []
343  let acc = 0
344  for (const s of slices) ends.push((acc += s.tokens) / total)
345  const colors = slices.map(s => hexToInt(s.color))
346  const c = (cols - 1) / 2
347  const radius = cols / 2 - 0.5
348  const pixel = (x: number, y: number) => {
349    const dx = x - c
350    const dy = y - c
351    if (dx * dx + dy * dy > radius * radius) return DEFAULT_COLOR
352    let turn = Math.atan2(dx, -dy) / (2 * Math.PI)
353    if (turn < 0) turn += 1
354    const i = ends.findIndex(end => turn < end)
355    return colors[i === -1 ? colors.length - 1 : i] ?? DEFAULT_COLOR
356  }
357  const words = new Uint32Array(cols * rows * 3)
358  for (let row = 0; row < rows; row++) {
359    for (let col = 0; col < cols; col++) {
360      const top = pixel(col, row * 2)
361      const bottom = pixel(col, row * 2 + 1)
362      const k = (row * cols + col) * 3
363      const isBlank = top === DEFAULT_COLOR && bottom === DEFAULT_COLOR
364      words[k] = isBlank ? 0x20 : 0x2580
365      words[k + 1] = top
366      words[k + 2] = bottom
367    }
368  }
369  return toBase64(new Uint8Array(words.buffer))
370}
371
372async function openPane($: EngineInterface) {
373  await $.ui.open({ id: PANE, title: 'Context 明細', focus: true, closeOnEscape: true })
374  void loadBreakdown($, 'summary')
375}
376
377function ctxText(usage: Usage) {
378  if (usage.ctxPct === undefined) return '尚無資料'
379  const w = WEATHER[levelOf(usage.ctxPct)]
380  const target = usage.isCompactOn ? '距自動壓縮' : '自動壓縮已關閉,佔視窗'
381  return `${w.icon} ${w.word} · ${target} ${usage.ctxPct}%(${fmtTokens(usage.ctxTokens ?? 0)} / ${fmtTokens(usage.compactAt)})`
382}
383
384function limitsText(usage: Usage, effort: string | null, nowMs: number) {
385  const line = (label: string, w: LimitWindow | undefined, days: boolean) =>
386    w ? `  ${label}:已用 ${w.pct}%,${untilStr(w.resetsAt, nowMs, days) || '?'} 後重置` : `  ${label}:尚無資料`
387  return [
388    `${usage.model}${effort ? ` (${effort})` : ''} · ${usage.dir}${usage.git ? ` ${gitLabel(usage.git)}` : ''}`,
389    `  Context:${ctxText(usage)}`,
390    line('5h', usage.h5, false),
391    line('7d', usage.d7, true),
392  ].join('\n')
393}
394
395export const register: Register = on => {
396  // -- events -----------------------------------------------------------------
397  on('session.start', async ($, e, next) => {
398    await $.command.register({ name: 'limits', description: '顯示 context、5h/7d 額度與重置倒數' })
399    await $.command.register({ name: 'ctx', description: '開啟 context 用量明細面板' })
400    await refresh($)
401    await readEffortSetting($)
402    // Keeps the reset countdowns moving between measurements.
403    $.clock.every(60_000, () => {
404      void update($, nowAtom, () => Date.now())
405    })
406    return next(e)
407  })
408
409  on('session.measure', async ($, e, next) => {
410    const usage = await refresh($, e)
411    if (e.changed.includes('context') && usage.ctxTokens !== undefined) {
412      const tokens = usage.ctxTokens
413      await update($, historyAtom, list => [...list, tokens].slice(-HISTORY_TURNS))
414    }
415    await checkThresholds($, usage)
416    if (e.changed.includes('rateLimits')) await writeSnapshot($, usage)
417    return next(e)
418  })
419
420  // Each main-loop request carries the effort it is sent with (after /effort or a downgrade).
421  // turn.step streams, so the hook is a generator that forwards the stream untouched.
422  on('turn.step', async function* ($, e, next) {
423    if (e.agentId === undefined) {
424      const effort = e.effort === undefined ? null : String(e.effort)
425      if ((await read($, effortAtom)) !== effort) await update($, effortAtom, () => effort)
426    }
427    return yield* next(e)
428  })
429
430  // /autocompact moves the threshold the forecast measures against.
431  on('command.run', { command: 'autocompact' }, async ($, e, next) => {
432    const ran = await next(e)
433    await refresh($, undefined, true)
434    return ran
435  })
436
437  on('command.run', { command: 'limits' }, async $ => {
438    const usage = await refresh($)
439    return { text: limitsText(usage, await read($, effortAtom), Date.now()) }
440  })
441
442  on('command.run', { command: 'ctx' }, async $ => {
443    await openPane($)
444    return { text: 'Context 明細面板已開啟(Esc 關閉)。' }
445  })
446
447  // -- the band above the prompt ----------------------------------------------
448  // Tinted chips that wrap: model + effort | folder + branch | context | 5h | 7d; /ctx opens the pane.
449  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
450    const usage = await read($, usageAtom)
451    if (e.props.hasSurvey || usage === null) return next(e)
452    const nowMs = await read($, nowAtom)
453    const history = await read($, historyAtom)
454    const effort = await read($, effortAtom)
455    const ui = $.ui.resolve(e)
456    const { Box, Text } = ui
457    const isNarrow = e.props.bodyColumns < 80
458    const hasSvg = e.surface !== 'terminal' && 'Svg' in ui
459
460    // Desktop surfaces draw the line icons; the terminal a glyph in the same color.
461    const icon = (name: IconName, color: string) =>
462      hasSvg ? (
463        <ui.Svg source={iconSvg(name, color)} alt={name} width={14} height={14} />
464      ) : (
465        <Text color={color}>{ICON_GLYPH[name]}</Text>
466      )
467
468    const chip = (key: string, tone: Tone, children: RenderChildren) => (
469      <Box key={key} flexDirection="row" alignItems="center" columnGap={1} backgroundColor={tone.bg} paddingX={1}>
470        {children}
471      </Box>
472    )
473    const divider = (tone: Tone) => <Text color={tone.line}>│</Text>
474
475    const ctx = usage.ctxPct
476    const prev = history.length >= 2 ? history[history.length - 2] : undefined
477    const last = history.length >= 1 ? history[history.length - 1] : undefined
478    const delta = prev !== undefined && last !== undefined ? last - prev : 0
479
480    const rate = (key: string, name: IconName, tone: Tone, w: LimitWindow | undefined, days: boolean) => {
481      if (!w) return chip(key, tone, [icon(name, tone.fg), <Text color={tone.dim}>--</Text>])
482      const until = untilStr(w.resetsAt, nowMs, days)
483      return chip(key, tone, [
484        icon(name, tone.fg),
485        !isNarrow && meter(Text, w.pct, tone),
486        <Text color={levelOf(w.pct) === 0 ? T_TEXT : colorOf(w.pct)} bold>{w.pct}%</Text>,
487        until && divider(tone),
488        until && <Text color={T_TEXT}>{until}</Text>,
489      ])
490    }
491
492    const chips = [
493      !isNarrow &&
494        chip('model', TONE_MODEL, [
495          icon('model', TONE_MODEL.fg),
496          <Text color={T_TEXT} bold>{usage.model}</Text>,
497          effort && divider(TONE_MODEL),
498          effort && <Text color={TONE_MODEL.fg}>{effort}</Text>,
499        ]),
500      !isNarrow &&
501        chip('dir', TONE_DIR, [
502          icon('folder', TONE_DIR.fg),
503          <Text color={T_TEXT}>{usage.dir}</Text>,
504          usage.git && divider(TONE_DIR),
505          usage.git && <Text color={C_BRANCH}>🌿 {usage.git.branch}</Text>,
506          usage.git?.worktree && <Text color={C_ORANGE}>wt:{usage.git.worktree}</Text>,
507        ]),
508      ctx === undefined
509        ? chip('ctx', TONE_DIR, [<Text color={TONE_DIR.dim}>ctx --</Text>])
510        : chip('ctx', TONE_CTX, [
511            <Text color={TONE_CTX.fg}>ctx</Text>,
512            !isNarrow && meter(Text, ctx, TONE_CTX),
513            <Text color={levelOf(ctx) === 0 ? T_TEXT : colorOf(ctx)} bold>{ctx}%</Text>,
514            !isNarrow && Math.abs(delta) >= 1000 && (
515              <Text color={TONE_CTX.dim}>{delta > 0 ? '▲' : '▼'}{fmtTokens(Math.abs(delta))}</Text>
516            ),
517          ]),
518      rate('h5', 'gauge', TONE_H5, usage.h5, false),
519      rate('d7', 'calendar', TONE_D7, usage.d7, true),
520    ]
521
522    const band = (
523      <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={0} alignItems="center">
524        {chips.filter(Boolean)}
525      </Box>
526    )
527
528    // Share the band: whatever the plugins beneath draw (e.g. typo-picker) goes under the chips.
529    const below = await next(e)
530    if (!below) return band
531    return (
532      <Box flexDirection="column">
533        {band}
534        {below}
535      </Box>
536    )
537  })
538
539  // -- the /ctx pane: a pie of what fills the context, against the auto-compact threshold --
540  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
541    const ui = $.ui.resolve(e)
542    const { Box, Text, Button } = ui
543    const b = await read($, breakdownAtom)
544    const isLoading = await read($, loadingAtom)
545
546    const buttons = (
547      <Box flexDirection="row" gap={1} marginTop={1}>
548        <Button key="refresh" label="重新估算" hotkey="r" onPress={() => loadBreakdown($, 'summary')} />
549        <Button key="full" label="精確計算" hotkey="f" onPress={() => loadBreakdown($, 'full')} />
550        <Button key="close" label="關閉" hotkey="q" onPress={() => $.ui.close({ id: PANE })} />
551      </Box>
552    )
553
554    if (b === null) {
555      return (
556        <Box flexDirection="column">
557          <Text dimColor>{isLoading ? '計算中…' : '尚無資料'}</Text>
558          {buttons}
559        </Box>
560      )
561    }
562
563    const slices = pieSlices(b)
564    const legend = (
565      <Box flexDirection="column" justifyContent="center">
566        {slices.map(s => (
567          <Text>
568            <Text color={s.color}>■</Text> {s.name.padEnd(18).slice(0, 18)}
569            <Text dimColor> {fmtTokens(s.tokens).padStart(6)} {String(pctOf(s.tokens, b.max)).padStart(3)}%</Text>
570          </Text>
571        ))}
572      </Box>
573    )
574
575    // The terminal draws a real pie; other surfaces get one 100% stacked bar.
576    const chart =
577      e.surface === 'terminal' && 'Raster' in ui ? (
578        <ui.Raster key="pie" columns={PIE_COLS} rows={PIE_COLS / 2} cells={pieCells(slices, PIE_COLS)} />
579      ) : (
580        <Text>
581          {slices.map(s => (
582            <Text color={s.color}>{'█'.repeat(Math.max(s.tokens > 0 ? 1 : 0, Math.round((s.tokens / b.max) * 40)))}</Text>
583          ))}
584        </Text>
585      )
586
587    return (
588      <Box flexDirection="column">
589        <Text bold>
590          已用 {fmtTokens(b.total)} / 自動壓縮 {fmtTokens(b.max)}(<Text color={colorOf(b.pct)}>{b.pct}%</Text>)
591          <Text dimColor> {b.detail === 'full' ? '精確' : '估算'}{isLoading ? ',更新中…' : ''}</Text>
592        </Text>
593        <Box flexDirection="row" gap={3} marginTop={1}>
594          {chart}
595          {legend}
596        </Box>
597        <Box flexDirection="column" marginTop={1}>
598          {b.memory.length > 0 && <Text dimColor>最大的記憶檔:</Text>}
599          {b.memory.map(m => (
600            <Text dimColor wrap="truncate-start">
601              {'  '}{fmtTokens(m.tokens).padStart(7)} {m.path}
602            </Text>
603          ))}
604        </Box>
605        {buttons}
606      </Box>
607    )
608  })
609}
610
types/index.d.ts 54 lines
1export type LimitWindow = { pct: number; resetsAt?: string }
2
3export type GitInfo = { branch: string; worktree?: string }
4
5export type Usage = {
6  model: string
7  dir: string
8  git?: GitInfo
9  /** Input tokens of the last response; undefined before the first one. */
10  ctxTokens?: number
11  /** The model's full window. */
12  ctxWindow: number
13  /** Where auto-compaction runs, in tokens; the full window when it is off. */
14  compactAt: number
15  isCompactOn: boolean
16  /** ctxTokens over compactAt, 0 to 100. */
17  ctxPct?: number
18  h5?: LimitWindow
19  d7?: LimitWindow
20}
21
22/** The auto-compact threshold as last read, and for which model. */
23export type CompactInfo = { model: string; at: number; isOn: boolean }
24
25export type BreakdownRow = { name: string; tokens: number; kind: string }
26
27export type Breakdown = {
28  detail: 'summary' | 'full'
29  model: string
30  total: number
31  max: number
32  pct: number
33  rows: BreakdownRow[]
34  memory: { path: string; tokens: number }[]
35  mcpTokens: number
36  skillsTokens: number
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'usage': {
42      usage: Usage | null
43      now: number
44      fired: string[]
45      breakdown: Breakdown | null
46      isLoading: boolean
47      compact: CompactInfo | null
48      history: number[]
49      /** The main loop's effort as of its last model request; null before one or on a model without effort. */
50      effort: string | null
51    }
52  }
53}
54