顯示工作進度(任務清單、本回合狀態)與帳號額度剩餘(5 小時 / 7 天 rate limit、context、花費、token 數),並在額度偏低時提醒

Claude Code mod:在輸入框上方顯示
指令:/meter 開詳細面板;/meter hide、/meter show 隱藏或顯示上方橫條。
非官方的個人專案,與 Anthropic 無關。
/meter 開面板。在 /config 找到下列設定列,修改後會自動重新載入:
| 設定 | 預設 | 說明 |
|---|---|---|
work-meter.alerts | 開 | 額度警示開關 |
work-meter.alertThresholds | 20,10 | 警示門檻(剩餘 %),以逗號分隔、由大到小 |
work-meter.showTokens | 開 | 在橫條與面板顯示 token 數 |
work-meter.longTurnNotifySeconds | 0 | 回合耗時超過這個秒數時通知;0 為關閉 |
長回合通知要靠你自己的通知管道,需先在 /config 設定通知方式;沒有設定時不會有任何效果。
claude plugin install work-meter --marketplace https://github.com/evasuka/work-meter.git
也可以在 Claude Code 對話裡輸入 /plugin install work-meter --marketplace https://github.com/evasuka/work-meter.git;出現 Add marketplace? 按 y,scope 建議選 user。不需要任何 GitHub 帳號或認證,請使用完整網址。
安裝後新開的對話會自動載入;已開著的對話請輸入 /reload-plugins。輸入 /meter:出現在指令選單並開啟面板,就代表已載入。
想先看過原始碼,或需要離線使用時:
git clone https://github.com/evasuka/work-meter.git ~/claude-mods/work-meter
claude plugin install work-meter --marketplace ~/claude-mods/work-meter
這種方式 Claude Code 會直接讀那個資料夾(不會另外複製一份),資料夾搬動或刪除後 mod 就失效。
先依方式 B clone,然後:
claude --plugin-dir ~/claude-mods/work-meter
只對這一次啟動的 session 有效。
mod 只在本機執行:不會發出任何網路請求,也不會讀寫檔案或執行外部程式。它讀取目前 session 的用量資訊(rate limit、context、花費)與工具呼叫事件,並顯示在畫面上;橫條會顯示工具呼叫的名稱與參數片段(例如 Bash 指令的第一行)。這些內容只出現在你的畫面上,mod 不會把它們寫入檔案或傳送出去。額度警示以畫面上的提示呈現;長回合通知(預設關閉)只會透過你自己設定的通知管道發出。token 數與警示記錄只保存在 session 的狀態裡,mod 不會把它們寫入檔案。原始碼在 hooks/,可自行檢視。
claude plugin update work-meter@work-meter-marketplace,再於對話裡輸入 /reload-plugins。claude plugin uninstall work-meter@work-meter-marketplace,再執行 claude plugin marketplace remove work-meter-marketplace。claude plugin validate .
claude plugin test .
編輯器型別提示:用 claude --plugin-dir . 載入一次,Claude Code 會在 .claude-plugin/types/ 產生型別宣告。
hooks/register.tsx 388 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { MeterItem, MeterUsage } from '../types'
5import { parseThresholds, planAlerts } from './alerts'
6import {
7 bar,
8 breakdown,
9 describeTool,
10 duration,
11 pad,
12 progress,
13 remaining,
14 remainingColor,
15 resetsIn,
16 tokens,
17 windowLabel,
18} from './format'
19import { EMPTY_TOKENS, recordTurn, total } from './tokens'
20
21const PANE = 'work-meter'
22const PANE_TITLE = '工作進度與額度'
23const ALERT_TOAST_MS = 10_000
24
25const items = atom({ plugin: 'work-meter', key: 'items' } as const, [])
26const turn = atom({ plugin: 'work-meter', key: 'turn' } as const, null)
27const running = atom({ plugin: 'work-meter', key: 'running' } as const, [])
28const usage = atom({ plugin: 'work-meter', key: 'usage' } as const, null)
29const now = atom({ plugin: 'work-meter', key: 'now' } as const, 0)
30const isBandHidden = atom({ plugin: 'work-meter', key: 'isBandHidden' } as const, false)
31const spent = atom({ plugin: 'work-meter', key: 'tokens' } as const, EMPTY_TOKENS)
32const alerted = atom({ plugin: 'work-meter', key: 'alerted' } as const, {})
33
34function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): MeterUsage {
35 return {
36 windows: u.rateLimits.map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })),
37 contextTokens: u.context.tokens,
38 contextWindow: u.context.window,
39 contextPercent: u.context.percent,
40 costUsd: u.cost?.usd,
41 }
42}
43
44function upsertTask(list: MeterItem[], id: string, patch: Partial<MeterItem>): MeterItem[] {
45 const isKnown = list.some(i => i.source === 'task' && i.id === id)
46 if (!isKnown) {
47 return [...list, { id, subject: `任務 #${id}`, status: 'pending', source: 'task', ...patch }]
48 }
49 return list.map(i => (i.source === 'task' && i.id === id ? { ...i, ...patch } : i))
50}
51
52// Ticks the clock the band reads: every second while a turn runs, else every 30s for the reset countdowns.
53async function tick($: EngineInterface) {
54 const t = await $.clock.now()
55 const current = await read($, turn)
56 const isWorking = current !== null && current.endedAt === undefined
57 if (isWorking || t - (await read($, now)) >= 30_000) {
58 await update($, now, () => t)
59 }
60}
61
62export const register: Register = (on, options) => {
63 const isAlertOn = options.alerts !== false
64 const levels = parseThresholds(String(options.alertThresholds ?? ''))
65 const isTokenShown = options.showTokens !== false
66 const longTurnSeconds = Number(options.longTurnNotifySeconds)
67 const longTurnMs = Number.isFinite(longTurnSeconds) && longTurnSeconds > 0 ? longTurnSeconds * 1000 : 0
68
69 on('session.start', async ($, e, next) => {
70 await $.command.register({
71 name: 'meter',
72 description: '開啟工作進度與帳號額度面板;/meter hide|show 切換上方橫條',
73 argumentHint: '[hide|show]',
74 })
75 const measured = toUsage(await $.session.usage())
76 await update($, usage, () => measured)
77 const t = await $.clock.now()
78 await update($, now, () => t)
79 $.clock.every(1000, () => void tick($))
80
81 return next(e)
82 })
83
84 on('command.run', { command: 'meter' }, async ($, e) => {
85 const arg = e.args.trim()
86 if (arg === 'hide' || arg === 'show') {
87 await update($, isBandHidden, () => arg === 'hide')
88 return { text: arg === 'hide' ? '已隱藏工作進度橫條(/meter show 可再顯示)。' : '已顯示工作進度橫條。' }
89 }
90 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
91
92 return { text: opened.isPlaced ? '已開啟工作進度面板。' : '工作進度面板等待中:終端機寬度不足。' }
93 })
94
95 on('session.measure', async ($, e, next) => {
96 const measured = toUsage(e)
97 await update($, usage, () => measured)
98
99 if (isAlertOn) {
100 const t = await $.clock.now()
101 const plan = planAlerts(await read($, alerted), measured.windows, levels, t)
102 await update($, alerted, () => plan.next)
103 for (const message of plan.messages) $.ui.toast(message, { timeoutMs: ALERT_TOAST_MS })
104 }
105
106 return next(e)
107 })
108
109 on('turn.start', async ($, e, next) => {
110 const t = await $.clock.now()
111 await update($, turn, () => ({ startedAt: t, tools: 0 }))
112 await update($, running, () => [])
113 await update($, now, () => t)
114
115 return next(e)
116 })
117
118 on('turn.complete', async ($, e, next) => {
119 await update($, spent, prev => recordTurn(prev, e.usage, e.agentId === undefined))
120 if (e.agentId !== undefined) return next(e)
121 const t = await $.clock.now()
122 await update($, turn, prev => (prev === null ? null : { ...prev, endedAt: t }))
123 await update($, running, () => [])
124
125 if (longTurnMs > 0 && !e.isAborted && e.durationMs >= longTurnMs) {
126 // Goes through the person's own notification channel; "not sent" (no channel set up) is fine.
127 await $.ui.notify(`回合完成(耗時 ${duration(e.durationMs)})`, { title: 'work-meter' })
128 }
129
130 return next(e)
131 })
132
133 // A /clear starts a new conversation in the same process: its token totals start over.
134 on('session.end', async ($, e, next) => {
135 if (e.reason === 'clear') await update($, spent, () => EMPTY_TOKENS)
136
137 return next(e)
138 })
139
140 // Every tool call: count it, and show the main thread's running ones.
141 on('tool.call', async ($, e, next) => {
142 await update($, turn, prev => (prev === null || prev.endedAt !== undefined ? prev : { ...prev, tools: prev.tools + 1 }))
143 if (e.agentId !== undefined) return next(e)
144 const entry = { id: e.tool_use_id, label: describeTool(String(e.tool), e as unknown as Record<string, unknown>) }
145 await update($, running, list => [...list, entry].slice(-20))
146 try {
147 return await next(e)
148 } finally {
149 await update($, running, list => list.filter(r => r.id !== entry.id))
150 }
151 }).catch(($, e, next) => next(e))
152
153 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
154 const ran = await next(e)
155 if (ran.deny === undefined && ran.isError !== true && ran.result?.task) {
156 const { id } = ran.result.task
157 await update($, items, list =>
158 upsertTask(list, id, { subject: e.subject, activeForm: e.activeForm, status: 'pending' }),
159 )
160 }
161
162 return ran
163 }).catch(($, e, next) => next(e))
164
165 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
166 const ran = await next(e)
167 if (ran.deny !== undefined || ran.isError === true) return ran
168 if (e.status === 'deleted') {
169 await update($, items, list => list.filter(i => !(i.source === 'task' && i.id === e.taskId)))
170 } else {
171 const patch: Partial<MeterItem> = {}
172 if (e.subject !== undefined) patch.subject = e.subject
173 if (e.activeForm !== undefined) patch.activeForm = e.activeForm
174 if (e.status !== undefined) patch.status = e.status
175 await update($, items, list => upsertTask(list, e.taskId, patch))
176 }
177
178 return ran
179 }).catch(($, e, next) => next(e))
180
181 // A full listing resyncs the tasks, keeping each one's activeForm.
182 on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
183 const ran = await next(e)
184 if (ran.deny === undefined && ran.isError !== true && Array.isArray(ran.result?.tasks)) {
185 const listed = ran.result.tasks
186 await update($, items, list => {
187 const kept = new Map(list.filter(i => i.source === 'task').map(i => [i.id, i]))
188 const tasks: MeterItem[] = listed.map(t => ({
189 id: t.id,
190 subject: t.subject,
191 activeForm: kept.get(t.id)?.activeForm,
192 status: t.status,
193 source: 'task',
194 }))
195 return [...tasks, ...list.filter(i => i.source !== 'task')]
196 })
197 }
198
199 return ran
200 }).catch(($, e, next) => next(e))
201
202 on('tool.call', { tool: 'TaskGet' }, async ($, e, next) => {
203 const ran = await next(e)
204 const task = ran.deny === undefined && ran.isError !== true ? ran.result?.task : undefined
205 if (task) {
206 await update($, items, list => upsertTask(list, task.id, { subject: task.subject, status: task.status }))
207 }
208
209 return ran
210 }).catch(($, e, next) => next(e))
211
212 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
213 if (e.agentId === undefined) {
214 const todos: MeterItem[] = e.todos.map((t, i) => ({
215 id: `todo-${i}`,
216 subject: t.content,
217 activeForm: t.activeForm,
218 status: t.status,
219 source: 'todo',
220 }))
221 await update($, items, list => [...list.filter(i => i.source !== 'todo'), ...todos])
222 }
223
224 return next(e)
225 }).catch(($, e, next) => next(e))
226
227 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
228 if (e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
229 const list = await read($, items)
230 const current = await read($, turn)
231 const u = await read($, usage)
232 const t = await read($, now)
233 const calls = await read($, running)
234 const spentTokens = await read($, spent)
235 const hasWork = list.length > 0 || current !== null
236 if (!hasWork && u === null) return next(e)
237
238 const { Box, Text } = $.ui.resolve(e)
239 const p = progress(list)
240 const isWorking = current !== null && current.endedAt === undefined
241 const tool = calls.at(-1)
242 const turnBase =
243 current === null
244 ? ''
245 : isWorking
246 ? `工作中 ${duration(t - current.startedAt)} · 工具 ${current.tools} 次${tool ? ` · ${tool.label}` : ''}`
247 : `上回合 ${duration((current.endedAt ?? t) - current.startedAt)} · 工具 ${current.tools} 次`
248 const lastTokens =
249 isTokenShown && !isWorking && spentTokens.last !== null ? ` · ${tokens(total(spentTokens.last))} tok` : ''
250 const sessionTokens =
251 isTokenShown && total(spentTokens.session) > 0 ? ` │ 本 session ${tokens(total(spentTokens.session))} tok` : ''
252 const turnText = turnBase === '' ? '' : `${turnBase}${lastTokens}${sessionTokens}`
253
254 return (
255 <Box flexDirection="column">
256 {hasWork && (
257 <Text key="work" wrap="truncate-end">
258 {p.total > 0 && (
259 <Text>
260 <Text dimColor>進度 </Text>
261 <Text color="success">{bar(p.done / p.total, 10)}</Text>
262 {` ${p.done}/${p.total}`}
263 {p.active && <Text color="claude">{` ▶ ${p.active.activeForm ?? p.active.subject}`}</Text>}
264 {turnText !== '' && <Text dimColor> │ </Text>}
265 </Text>
266 )}
267 {turnText !== '' && <Text dimColor={!isWorking}>{turnText}</Text>}
268 </Text>
269 )}
270 {u !== null && (
271 <Text key="usage" wrap="truncate-end">
272 <Text dimColor>額度 </Text>
273 {u.windows.length === 0 && <Text dimColor>(尚無 rate limit 資料) </Text>}
274 {u.windows.map(w => {
275 const left = remaining(w)
276 const reset = resetsIn(w, t)
277 return (
278 <Text>
279 {`${windowLabel(w.kind)} `}
280 <Text color={remainingColor(left)} bold>{`剩 ${left}%`}</Text>
281 {reset !== undefined && <Text dimColor>{` (${reset}後重置)`}</Text>}
282 <Text dimColor> │ </Text>
283 </Text>
284 )
285 })}
286 {u.contextPercent !== undefined && (
287 <Text>
288 {'context '}
289 <Text color={remainingColor(100 - u.contextPercent)}>{`${u.contextPercent}%`}</Text>
290 </Text>
291 )}
292 {u.costUsd !== undefined && <Text dimColor>{` │ US$${u.costUsd.toFixed(2)}`}</Text>}
293 </Text>
294 )}
295 </Box>
296 )
297 })
298
299 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
300 const { Box, Text } = $.ui.resolve(e)
301 const list = await read($, items)
302 const current = await read($, turn)
303 const u = await read($, usage)
304 const t = await read($, now)
305 const calls = await read($, running)
306 const spentTokens = await read($, spent)
307 const p = progress(list)
308 const width = Math.min(24, Math.max(8, e.props.bodyColumns - 34))
309 const isWorking = current !== null && current.endedAt === undefined
310 const hasTokens = isTokenShown && (spentTokens.last !== null || total(spentTokens.session) > 0)
311
312 return (
313 <Box flexDirection="column">
314 <Text bold>
315 工作進度{p.total > 0 && <Text dimColor>{` ${p.done}/${p.total} 完成`}</Text>}
316 </Text>
317 {p.total > 0 && (
318 <Text>
319 <Text color="success">{bar(p.done / p.total, width + 12)}</Text>
320 {` ${Math.round((p.done / p.total) * 100)}%`}
321 </Text>
322 )}
323 {list.length === 0 && <Text dimColor>(目前沒有任務清單)</Text>}
324 {list.map(item =>
325 item.status === 'completed' ? (
326 <Text dimColor wrap="truncate-end">{`✔ ${item.subject}`}</Text>
327 ) : item.status === 'in_progress' ? (
328 <Text color="claude" bold wrap="truncate-end">{`▶ ${item.activeForm ?? item.subject}`}</Text>
329 ) : (
330 <Text wrap="truncate-end">{`○ ${item.subject}`}</Text>
331 ),
332 )}
333 <Text> </Text>
334 <Text bold>本回合</Text>
335 {current === null ? (
336 <Text dimColor>(尚未開始)</Text>
337 ) : (
338 <Text>
339 {isWorking ? '工作中 ' : '已結束 · 耗時 '}
340 {duration((current.endedAt ?? t) - current.startedAt)}
341 {` · 工具 ${current.tools} 次`}
342 </Text>
343 )}
344 {calls.map(c => (
345 <Text dimColor wrap="truncate-end">{` 執行中:${c.label}`}</Text>
346 ))}
347 {hasTokens && <Text> </Text>}
348 {hasTokens && <Text bold>Token</Text>}
349 {hasTokens && spentTokens.last !== null && (
350 <Text wrap="truncate-end">{`上回合 ${breakdown(spentTokens.last)}`}</Text>
351 )}
352 {hasTokens && <Text wrap="truncate-end">{`本 session ${breakdown(spentTokens.session)}`}</Text>}
353 <Text> </Text>
354 <Text bold>帳號額度</Text>
355 {u === null || (u.windows.length === 0 && u.contextPercent === undefined) ? (
356 <Text dimColor>(等待第一次 API 回應後顯示)</Text>
357 ) : null}
358 {u !== null && u.windows.length === 0 && (
359 <Text dimColor>沒有 rate limit 資料(API key 帳號不提供,訂閱帳號於回應後出現)</Text>
360 )}
361 {u?.windows.map(w => {
362 const left = remaining(w)
363 const reset = resetsIn(w, t)
364 return (
365 <Text wrap="truncate-end">
366 {pad(windowLabel(w.kind), 9)}
367 <Text color={remainingColor(left)}>{bar(left / 100, width)}</Text>
368 <Text color={remainingColor(left)} bold>{` 剩 ${left}%`}</Text>
369 {reset !== undefined && <Text dimColor>{` ${reset}後重置`}</Text>}
370 </Text>
371 )
372 })}
373 {u?.contextPercent !== undefined && (
374 <Text wrap="truncate-end">
375 {pad('Context', 9)}
376 <Text color={remainingColor(100 - u.contextPercent)}>{bar(u.contextPercent / 100, width)}</Text>
377 {` 已用 ${u.contextPercent}%`}
378 {u.contextTokens !== undefined && (
379 <Text dimColor>{` ${tokens(u.contextTokens)} / ${tokens(u.contextWindow)}`}</Text>
380 )}
381 </Text>
382 )}
383 {u?.costUsd !== undefined && <Text dimColor>{`本 session 花費 US$${u.costUsd.toFixed(2)}`}</Text>}
384 </Box>
385 )
386 })
387}
388hooks/alerts.ts 50 lines1import type { MeterAlerts, MeterWindow } from '../types'
2import { remaining, resetsIn, windowLabel } from './format'
3
4const DEFAULT_LEVELS = [20, 10]
5
6export type AlertPlan = { messages: string[]; next: MeterAlerts }
7
8// "20,10" -> [20, 10]: whole-percent levels between 1 and 99, high to low, no duplicates.
9export function parseThresholds(text: string): number[] {
10 const levels = new Set<number>()
11 for (const part of text.split(',')) {
12 const n = Number(part.trim())
13 if (Number.isFinite(n) && n >= 1 && n <= 99) levels.add(n)
14 }
15 return levels.size === 0 ? DEFAULT_LEVELS : [...levels].sort((a, b) => b - a)
16}
17
18// What to announce for the latest readings, and what to remember so the same level is not announced twice.
19export function planAlerts(
20 prev: MeterAlerts,
21 windows: readonly MeterWindow[],
22 thresholds: readonly number[],
23 now: number,
24): AlertPlan {
25 const next: MeterAlerts = { ...prev }
26 const messages: string[] = []
27 const levels = [...thresholds].sort((a, b) => b - a)
28
29 for (const w of windows) {
30 const left = remaining(w)
31 const crossed = levels.filter(level => left <= level)
32 const target = crossed.length === 0 ? null : (crossed[crossed.length - 1] ?? null)
33 const announced = prev[w.kind] ?? null
34 const label = windowLabel(w.kind)
35
36 if (target === null) {
37 if (announced !== null) messages.push(`${label}額度已恢復(剩 ${left}%)`)
38 next[w.kind] = null
39 } else if (announced === null || target < announced) {
40 const reset = resetsIn(w, now)
41 messages.push(`${label}額度只剩 ${left}%${reset === undefined ? '' : `(${reset}後重置)`}`)
42 next[w.kind] = target
43 } else {
44 next[w.kind] = target
45 }
46 }
47
48 return { messages, next }
49}
50hooks/format.ts 89 lines1import type { MeterItem, MeterTok, MeterWindow } from '../types'
2
3export function duration(ms: number): string {
4 const s = Math.max(0, Math.round(ms / 1000))
5 if (s < 60) return `${s}s`
6 const m = Math.floor(s / 60)
7 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
8 const h = Math.floor(m / 60)
9 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
10 return `${Math.floor(h / 24)}d${h % 24}h`
11}
12
13export function bar(fraction: number, width: number): string {
14 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
15 return '█'.repeat(filled) + '░'.repeat(width - filled)
16}
17
18export function tokens(n: number): string {
19 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
20 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
21 return String(n)
22}
23
24export function breakdown(t: MeterTok): string {
25 return `輸入 ${tokens(t.input)} · 快取讀 ${tokens(t.cacheRead)} · 快取寫 ${tokens(t.cacheWrite)} · 輸出 ${tokens(t.output)}`
26}
27
28export function windowLabel(kind: string): string {
29 switch (kind) {
30 case 'five_hour':
31 return '5 小時'
32 case 'seven_day':
33 return '7 天'
34 case 'spend_limit':
35 return '花費上限'
36 default:
37 return kind.replace(/_/g, ' ')
38 }
39}
40
41export function remaining(w: MeterWindow): number {
42 return Math.max(0, Math.round((100 - w.percentUsed) * 10) / 10)
43}
44
45export function remainingColor(left: number): 'success' | 'warning' | 'error' {
46 if (left >= 50) return 'success'
47 if (left >= 20) return 'warning'
48 return 'error'
49}
50
51export function resetsIn(w: MeterWindow, now: number): string | undefined {
52 if (w.resetsAt === undefined) return undefined
53 const at = Date.parse(w.resetsAt)
54 return Number.isNaN(at) ? undefined : duration(at - now)
55}
56
57// Terminal cells: CJK and full-width forms take two.
58export function cells(text: string): number {
59 let n = 0
60 for (const ch of text) {
61 const code = ch.codePointAt(0) ?? 0
62 n += code >= 0x1100 && (code <= 0x115f || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)) ? 2 : 1
63 }
64 return n
65}
66
67export function pad(text: string, width: number): string {
68 return text + ' '.repeat(Math.max(0, width - cells(text)))
69}
70
71export function progress(items: readonly MeterItem[]) {
72 const done = items.filter(i => i.status === 'completed').length
73 const active = items.find(i => i.status === 'in_progress')
74 return { done, total: items.length, active }
75}
76
77const ARG_KEYS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'subject', 'prompt'] as const
78
79export function describeTool(tool: string, args: Record<string, unknown>): string {
80 const name = tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') || tool : tool
81 for (const key of ARG_KEYS) {
82 const value = args[key]
83 if (typeof value !== 'string' || value.trim() === '') continue
84 const text = key.endsWith('_path') ? (value.split('/').pop() ?? value) : (value.trim().split('\n')[0] ?? '')
85 return `${name}: ${text.length > 48 ? `${text.slice(0, 47)}…` : text}`
86 }
87 return name
88}
89hooks/tokens.ts 42 lines1import type { MeterTok, MeterTokens } from '../types'
2
3// The four counts a model response reports, as the engine spells them.
4type UsageLike = {
5 input_tokens: number
6 output_tokens: number
7 cache_read_input_tokens: number
8 cache_creation_input_tokens: number
9}
10
11const ZERO: MeterTok = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
12
13export const EMPTY_TOKENS: MeterTokens = { last: null, session: ZERO }
14
15export function fromUsage(usage: UsageLike | undefined): MeterTok {
16 if (usage === undefined) return ZERO
17 return {
18 input: usage.input_tokens,
19 output: usage.output_tokens,
20 cacheRead: usage.cache_read_input_tokens,
21 cacheWrite: usage.cache_creation_input_tokens,
22 }
23}
24
25export function total(t: MeterTok): number {
26 return t.input + t.output + t.cacheRead + t.cacheWrite
27}
28
29export function recordTurn(prev: MeterTokens, usage: UsageLike | undefined, isMain: boolean): MeterTokens {
30 if (usage === undefined) return prev
31 const turn = fromUsage(usage)
32 return {
33 last: isMain ? turn : prev.last,
34 session: {
35 input: prev.session.input + turn.input,
36 output: prev.session.output + turn.output,
37 cacheRead: prev.session.cacheRead + turn.cacheRead,
38 cacheWrite: prev.session.cacheWrite + turn.cacheWrite,
39 },
40 }
41}
42types/index.d.ts 47 lines1export type MeterStatus = 'pending' | 'in_progress' | 'completed'
2
3export type MeterItem = {
4 id: string
5 subject: string
6 activeForm?: string
7 status: MeterStatus
8 source: 'task' | 'todo'
9}
10
11export type MeterTurn = { startedAt: number; endedAt?: number; tools: number }
12
13export type MeterRunning = { id: string; label: string }
14
15export type MeterWindow = { kind: string; percentUsed: number; resetsAt?: string }
16
17export type MeterUsage = {
18 windows: MeterWindow[]
19 contextTokens?: number
20 contextWindow: number
21 contextPercent?: number
22 costUsd?: number
23}
24
25export type MeterTok = { input: number; output: number; cacheRead: number; cacheWrite: number }
26
27// last: the most recent main-thread turn; session: every turn since the conversation began, subagents included.
28export type MeterTokens = { last: MeterTok | null; session: MeterTok }
29
30// Per rate-limit window kind: the lowest alert threshold already announced, null while above every threshold.
31export type MeterAlerts = Record<string, number | null>
32
33declare module 'claude-code' {
34 interface PluginState {
35 'work-meter': {
36 items: MeterItem[]
37 turn: MeterTurn | null
38 running: MeterRunning[]
39 usage: MeterUsage | null
40 now: number
41 isBandHidden: boolean
42 tokens: MeterTokens
43 alerted: MeterAlerts
44 }
45 }
46}
47