A two-row band above the Claude Code prompt: rate-limit and context usage, plus a little orange ball that hops along your project's task track. Unofficial.

A two-row band above the Claude Code prompt. The right side shows your rate-limit usage. The left side has an orange ball that hops along your project's task track.

Unofficial. usage-ball is a fan-made mod. It is not affiliated with, endorsed by, or sponsored by Anthropic.
zᶻ 5h ⣿⣿⣿⣿⣀⣀⣀⣀⣀⣀ 42% (2h13m) [-]
✓━━━━✓━━━━●┄┄┄┄○┄┄┄┄○ 2/5 Write tests · ctx ⣿⣿⣿⡇⣀ 63% 7d ⣿⣿⣀⣀⣀⣀⣀⣀⣀⣀ 18% (4d6h)
Usage (right, plus ctx on the left)
5h and 7d: how much of your subscription's 5-hour and weekly windows you have used, with time until reset.ctx: how full this conversation's context window is.⚠️ 9% left, and the ⚠️ flashes for 3 seconds after each reply. Crossing the line also pops up one toast per window.—.Progress (left)
z, then zᶻ, then zᶻz, settling on zᶻ after 10 seconds.## 進度 (progress) checklist in HANDOFF.md at the folder where you started Claude Code: ```markdown ## 進度## Task N headings in HANDOFF.md. A task counts as done when its section has a 狀態:完成 (status: done) or 狀態:已合併 (status: merged) line, or when docs/handoff-archive/ has a file named like …task-N….md.ctx.The band always stays two rows high. On narrow terminals it drops, in order: the task name, then shortens the bars, then the reset times, then the bars, until only the numbers are left.
The mod only reads numbers Claude Code already has and files in your project folder. It never makes network requests, calls a model, or spends tokens.
ctx has numbers.In Claude Code:
/plugin marketplace add nccu1133060/usage-ball
/plugin install usage-ball@usage-ball
Or try it without installing, from a clone:
git clone https://github.com/nccu1133060/usage-ball
claude --plugin-dir ./usage-ball
| Option | Default | Meaning |
|---|---|---|
warnPercent | 80 | Warning line for the 5h and 7d windows |
ctxWarnPercent | 70 | Warning line for context. Lower, because Claude Code compacts long conversations |
Change them with /plugin configure usage-ball@usage-ball in Claude Code, or claude plugin install usage-ball@usage-ball --config warnPercent=75. Unset options use the defaults.
⣿ ⡇ ⣀ ✓ ━ ┄ ● ○ ⚠️ ᶻ ✨) were checked to be one cell wide in Windows Terminal. Other terminals or fonts may draw some of them wider, which shifts the alignment.HANDOFF.md is read from the folder where Claude Code started, after each reply.claude plugin test . # 51 tests
claude plugin validate .
tsc -p . # after the mod has loaded once, which writes .claude-plugin/types/
DESIGN.md is the visual spec. PRODUCT.md describes the product, and docs/plan.md the build plan.
usage-ball 是一個 Claude Code 外掛(mod)。它在輸入框上方加一條 2 行的橫幅:
ctx:這次對話的上下文用了幾成。用量低於 50% 是綠色,50% 以上是黃色,到警戒線(預設額度 80%、上下文 70%)變紅色,並顯示 ⚠️ 剩餘 %。專案進度依序讀取:HANDOFF.md 的 ## 進度 勾選框 → ## Task N 標題加上 狀態:完成 → 這次對話的任務清單。Claude 在等你回話時,小球會睡覺冒 Z。
安裝:在 Claude Code 輸入 /plugin marketplace add nccu1133060/usage-ball,再輸入 /plugin install usage-ball@usage-ball。
本專案為非官方粉絲作品,與 Anthropic 無關。
hooks/register.tsx 340 lines1import type { CoreEngineInterface, Register, SessionUsage } from 'claude-code'
2import { composeBand, layoutBand } from './band'
3import type { UsageState, DisplayState } from '../types'
4import { crossingAlerts } from './usage/alerts'
5import { formatReset, levelColor } from './usage/format'
6import { nextBarPercent, warningVisible } from './usage/animate'
7import { parseChecklist, parseTasks, type Progress } from './progress/parse'
8import { applyTaskTool, taskProgress } from './progress/tasks'
9import { drawTrack, taskName, type Pose } from './progress/track'
10import { svgWidth, trackSvg, TRACK_HEIGHT } from './progress/svg'
11import { barSvg } from './usage/bar'
12
13const usageRef = { plugin: 'usage-ball', key: 'usage' } as const
14const alertRef = { plugin: 'usage-ball', key: 'alerts' } as const
15const displayRef = { plugin: 'usage-ball', key: 'display' } as const
16const flashRef = { plugin: 'usage-ball', key: 'flash' } as const
17
18const progressRef = { plugin: 'usage-ball', key: 'progress' } as const
19const tasksRef = { plugin: 'usage-ball', key: 'tasks' } as const
20
21async function readText($: CoreEngineInterface, path: string): Promise<string | undefined> {
22 try {
23 const text = await $.fs.read(path)
24 return typeof text === 'string' ? text : undefined
25 } catch {
26 return undefined
27 }
28}
29
30async function listNames($: CoreEngineInterface, path: string): Promise<string[]> {
31 try {
32 return (await $.fs.list(path)).filter(entry => entry.kind === 'file').map(entry => entry.name)
33 } catch {
34 return []
35 }
36}
37
38const ballRef = { plugin: 'usage-ball', key: 'ball' } as const
39const sleepRef = { plugin: 'usage-ball', key: 'sleep' } as const
40const hopRef = { plugin: 'usage-ball', key: 'hop' } as const
41// Bounce heights in px, one per 100ms frame: up and down in 0.6s.
42const HOP = [0, 6, 10, 12, 10, 6]
43
44type Timer = { cancel: () => void }
45
46function zzz(tick: number): string {
47 return tick >= 10 ? 'zᶻ' : ['z', 'zᶻ', 'zᶻz'][tick % 3] ?? 'z'
48}
49
50async function refreshProgress($: CoreEngineInterface, timers: { ball?: Timer }) {
51 const handoff = await readText($, 'HANDOFF.md')
52 const fromFile = handoff === undefined ? undefined
53 : parseChecklist(handoff) ?? parseTasks(handoff, await listNames($, 'docs/handoff-archive'))
54 const progress: Progress | undefined = fromFile ?? taskProgress((await $.state.get(tasksRef)).value ?? [])
55 await $.state.set(progressRef, progress ?? { done: 0, total: 0 })
56 await animateBall($, timers)
57}
58
59async function animateBall($: CoreEngineInterface, timers: { ball?: Timer }) {
60 const progress = (await $.state.get(progressRef)).value ?? { done: 0, total: 0 }
61 const ball = (await $.state.get(ballRef)).value
62 if (!ball || ball.total !== progress.total || progress.done < ball.shown) {
63 timers.ball?.cancel()
64 timers.ball = undefined
65 await $.state.set(ballRef, { shown: progress.done, total: progress.total, phase: 'rest', step: 0, sparkle: false })
66 return
67 }
68 if (progress.done <= ball.shown || timers.ball) return
69 const advance = async () => {
70 const held = (await $.state.get(ballRef)).value
71 const target = (await $.state.get(progressRef)).value
72 if (!held || !target) return
73 if (held.phase === 'jump' && held.step < 3) {
74 await $.state.set(ballRef, { ...held, step: held.step + 1 })
75 timers.ball = $.clock.after(100, advance)
76 } else if (held.phase === 'jump') {
77 const shown = held.shown + 1
78 const isFinished = shown >= held.total
79 await $.state.set(ballRef, { ...held, shown, phase: isFinished ? 'bounce' : 'rest', step: 0, sparkle: isFinished })
80 timers.ball = isFinished ? $.clock.after(150, advance) : target.done > shown ? $.clock.after(100, advance) : undefined
81 } else if (held.phase === 'bounce' && held.step < 5) {
82 await $.state.set(ballRef, { ...held, step: held.step + 1 })
83 timers.ball = $.clock.after(150, advance)
84 } else if (held.phase === 'bounce') {
85 await $.state.set(ballRef, { ...held, phase: 'rest', step: 0 })
86 timers.ball = $.clock.after(2000, advance)
87 } else if (held.sparkle) {
88 await $.state.set(ballRef, { ...held, sparkle: false })
89 timers.ball = undefined
90 } else if (target.done > held.shown) {
91 await $.state.set(ballRef, { ...held, phase: 'jump', step: 1 })
92 timers.ball = $.clock.after(100, advance)
93 } else {
94 timers.ball = undefined
95 }
96 }
97 await advance()
98}
99
100async function recordTask($: CoreEngineInterface, tool: string, input: object, result: unknown, timers: { ball?: Timer }) {
101 const list = (await $.state.get(tasksRef)).value ?? []
102 await $.state.set(tasksRef, applyTaskTool(list, tool, input as Record<string, unknown>, result))
103 await refreshProgress($, timers)
104}
105
106async function startSleep($: CoreEngineInterface, timers: { sleep?: Timer }) {
107 timers.sleep?.cancel()
108 await $.state.set(sleepRef, { tick: 0 })
109 timers.sleep = $.clock.every(1000, async () => {
110 const tick = ((await $.state.get(sleepRef)).value?.tick ?? 0) + 1
111 await $.state.set(sleepRef, { tick })
112 if (tick >= 10) {
113 timers.sleep?.cancel()
114 timers.sleep = undefined
115 }
116 })
117}
118
119async function progressView($: CoreEngineInterface, isAsleep: boolean) {
120 const progress = (await $.state.get(progressRef)).value
121 if (!progress || progress.total === 0) {
122 const z = isAsleep ? zzz((await $.state.get(sleepRef)).value?.tick ?? 0) : undefined
123 const pose: Pose = { kind: 'rest', sleep: z }
124 return { air: z ? ' ' + z : '', ground: '●', done: 0, total: 0, name: '', shown: 0, pose }
125 }
126 const ball = (await $.state.get(ballRef)).value ?? { shown: progress.done, total: progress.total, phase: 'rest', step: 0, sparkle: false }
127 const pose = ball.phase === 'jump' ? { kind: 'jump' as const, frame: Math.min(3, Math.max(1, ball.step)) as 1 | 2 | 3 }
128 : ball.phase === 'bounce' ? { kind: 'bounce' as const, up: ball.step % 2 === 0, sparkle: true }
129 : ball.sparkle ? { kind: 'bounce' as const, up: false, sparkle: true }
130 : { kind: 'rest' as const, sleep: isAsleep ? zzz((await $.state.get(sleepRef)).value?.tick ?? 0) : undefined }
131 const shown = Math.min(ball.shown, progress.total)
132 const track = drawTrack(progress.total, shown, pose)
133 return { air: track.air, ground: track.ground, done: progress.done, total: progress.total, name: taskName(progress.current), shown, pose: pose as Pose }
134}
135
136async function processReading(
137 $: CoreEngineInterface,
138 reading: Pick<SessionUsage, 'context' | 'rateLimits'>,
139 warn: number,
140 ctxWarn: number,
141 timers: { bar?: { cancel: () => void } },
142) {
143 const previous = (await $.state.get(usageRef)).value ?? {}
144 const fiveHour = reading.rateLimits.find(item => item.kind === 'five_hour')
145 const sevenDay = reading.rateLimits.find(item => item.kind === 'seven_day')
146 const current: UsageState = {
147 fiveHour: fiveHour && { percent: fiveHour.percentUsed, resetsAt: fiveHour.resetsAt },
148 sevenDay: sevenDay && { percent: sevenDay.percentUsed, resetsAt: sevenDay.resetsAt },
149 context: reading.context.percent,
150 }
151 await $.state.set(usageRef, current)
152 const { kinds, state } = crossingAlerts(previous, current, (await $.state.get(alertRef)).value, warn, ctxWarn)
153 await $.state.set(alertRef, state)
154 const now = await $.clock.now()
155 for (const kind of kinds) {
156 const window = kind === '5h' ? current.fiveHour : kind === '7d' ? current.sevenDay : undefined
157 const reset = window?.resetsAt ? ` — resets in ${formatReset(window.resetsAt, now).trim().slice(1, -1)}` : ''
158 $.ui.toast(`${kind} usage passed ${kind === 'ctx' ? ctxWarn : warn}%${reset}`)
159 }
160 const changed = previous.fiveHour?.percent !== current.fiveHour?.percent
161 || previous.sevenDay?.percent !== current.sevenDay?.percent
162 || previous.context !== current.context
163 if (changed) {
164 timers.bar?.cancel()
165 const prior = (await $.state.get(displayRef)).value
166 const display: DisplayState = {
167 fiveHour: prior?.fiveHour ?? previous.fiveHour?.percent ?? 0,
168 sevenDay: prior?.sevenDay ?? previous.sevenDay?.percent ?? 0,
169 context: prior?.context ?? previous.context ?? 0,
170 startedAt: now,
171 }
172 await $.state.set(displayRef, display)
173 const step = async () => {
174 const target = (await $.state.get(usageRef)).value ?? {}
175 const held = (await $.state.get(displayRef)).value
176 if (!held) return
177 const elapsed = (await $.clock.now()) - held.startedAt
178 const frame: DisplayState = {
179 fiveHour: nextBarPercent(held.fiveHour ?? 0, target.fiveHour?.percent ?? 0, 10, elapsed),
180 sevenDay: nextBarPercent(held.sevenDay ?? 0, target.sevenDay?.percent ?? 0, 10, elapsed),
181 context: nextBarPercent(held.context ?? 0, target.context ?? 0, 5, elapsed),
182 startedAt: held.startedAt,
183 }
184 await $.state.set(displayRef, frame)
185 if (elapsed < 1000 && (frame.fiveHour !== target.fiveHour?.percent || frame.sevenDay !== target.sevenDay?.percent || frame.context !== target.context)) {
186 timers.bar = $.clock.after(100, step)
187 }
188 }
189 timers.bar = $.clock.after(100, step)
190 }
191}
192
193export const register: Register = (on, options) => {
194 const warn = typeof options.warnPercent === 'number' ? options.warnPercent : 80
195 const ctxWarn = typeof options.ctxWarnPercent === 'number' ? options.ctxWarnPercent : 70
196 const timers: { bar?: Timer; ball?: Timer; sleep?: Timer } = {}
197 let flashTimer: { cancel: () => void } | undefined
198 let minuteTimer: { cancel: () => void } | undefined
199 let hopTimer: { cancel: () => void } | undefined
200 on('session.start', async ($, e, next) => {
201 await processReading($, await $.session.usage(), warn, ctxWarn, timers)
202 await refreshProgress($, timers)
203 await startSleep($, timers)
204 return next(e)
205 })
206 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
207 const ran = await next(e)
208 if (ran.deny === undefined && ran.isError !== true) await recordTask($, e.tool, e, ran.result, timers)
209 return ran
210 })
211 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
212 const ran = await next(e)
213 if (ran.deny === undefined && ran.isError !== true) await recordTask($, e.tool, e, ran.result, timers)
214 return ran
215 })
216 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
217 const ran = await next(e)
218 if (ran.deny === undefined && ran.isError !== true) await recordTask($, e.tool, e, ran.result, timers)
219 return ran
220 })
221 on('session.measure', async ($, e, next) => {
222 await processReading($, e, warn, ctxWarn, timers)
223 return next(e)
224 })
225 on('turn.start', async ($, e, next) => {
226 timers.sleep?.cancel()
227 timers.sleep = undefined
228 return next(e)
229 })
230 on('turn.complete', async ($, e, next) => {
231 await refreshProgress($, timers)
232 await startSleep($, timers)
233 const usage = (await $.state.get(usageRef)).value
234 const high = usage && [usage.fiveHour?.percent, usage.sevenDay?.percent].some(p => p !== undefined && Math.round(p) >= warn)
235 || usage?.context !== undefined && Math.round(usage.context) >= ctxWarn
236 if (high) {
237 flashTimer?.cancel()
238 await $.state.set(flashRef, { tick: 0 })
239 const tick = async () => {
240 const current = (await $.state.get(flashRef)).value?.tick ?? 0
241 const following = current + 1
242 await $.state.set(flashRef, { tick: following })
243 if (following < 12) flashTimer = $.clock.after(250, tick)
244 }
245 flashTimer = $.clock.after(250, tick)
246 }
247 return next(e)
248 })
249 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
250 if (e.props.hasSurvey) return next(e)
251 if (!minuteTimer) minuteTimer = $.clock.every(60_000, async () => {
252 const display = (await $.state.get(displayRef)).value
253 await $.state.set(displayRef, { ...display, startedAt: display?.startedAt ?? 0, minute: Math.floor((await $.clock.now()) / 60_000) })
254 })
255 const usage = (await $.state.get(usageRef)).value as UsageState | undefined
256 const display = (await $.state.get(displayRef)).value
257 const flash = (await $.state.get(flashRef)).value
258 const now = await $.clock.now()
259 const progress = await progressView($, !e.props.isWorking)
260 const showWarning = warningVisible((flash?.tick ?? 12) * 250)
261 if (e.surface !== 'terminal') {
262 // Proportional fonts: bars and track are vector images; columns are boxes, never padding spaces.
263 const { Box, Text, Svg } = $.ui.resolve(e)
264 const width = e.props.bodyColumns
265 const band = layoutBand(width, usage ?? {}, now, warn, ctxWarn, showWarning, display, progress)
266 if (e.props.isWorking && !hopTimer) hopTimer = $.clock.every(100, async () => {
267 await $.state.set(hopRef, { tick: ((await $.state.get(hopRef)).value?.tick ?? 0) + 1 })
268 })
269 if (!e.props.isWorking && hopTimer) { hopTimer.cancel(); hopTimer = undefined }
270 const hopTick = (await $.state.get(hopRef)).value?.tick ?? 0
271 const hop = e.props.isWorking ? HOP[hopTick % HOP.length]! : 0
272 const track = trackSvg({ total: Math.max(1, progress.total), shown: progress.shown, pose: progress.pose, hop })
273 const level = (percent: number | undefined, limit: number) =>
274 percent === undefined ? { dimColor: true } : { color: levelColor(percent, limit) }
275 const bar = (label: string, shownPercent: number, percent: number | undefined, limit: number, size: number) =>
276 <Svg source={barSvg(shownPercent, size, percent === undefined ? 'success' : levelColor(percent, limit))} alt={`${label} ${percent === undefined ? 'unknown' : Math.round(percent) + '%'}`} width={size} height={8} />
277 const quotaSize = band.narrowBars ? 60 : 120
278 const quota = (part: typeof band.parts.five, percent: number | undefined, shownPercent: number | undefined) =>
279 <Box flexDirection="row" alignItems="center" columnGap={2}>
280 <Box width={3} flexShrink={0}><Text dimColor>{part.label.trim()}</Text></Box>
281 {band.showBar && bar(part.label.trim(), shownPercent ?? percent ?? 0, percent, warn, quotaSize)}
282 <Box width={band.parts.amountWidth} flexShrink={0} justifyContent="flex-end"><Text {...level(percent, warn)}>{part.amount.trim()}</Text></Box>
283 {band.showReset && <Box width={8} flexShrink={0} justifyContent="flex-end"><Text dimColor>{part.reset.trim()}</Text></Box>}
284 </Box>
285 return <Box flexDirection="row" justifyContent="space-between" alignItems="flex-end" columnGap={5} width={width}>
286 <Box flexDirection="row" alignItems="flex-end" flexShrink={1}>
287 <Svg source={track} alt={progress.total ? `progress ${progress.done} of ${progress.total}` : 'no progress yet'} width={svgWidth(track)} height={TRACK_HEIGHT} />
288 <Box flexDirection="row" alignItems="center" columnGap={1} marginLeft={1}>
289 {band.parts.counter.trim() && <Text wrap="truncate-end">{band.parts.counter.trim()}</Text>}
290 <Text dimColor>ctx</Text>
291 {band.showBar && <Box marginLeft={1}>{bar('ctx', display?.context ?? usage?.context ?? 0, usage?.context, ctxWarn, band.narrowBars ? 36 : 64)}</Box>}
292 <Text {...level(usage?.context, ctxWarn)}>{band.parts.ctx.trim()}</Text>
293 </Box>
294 </Box>
295 <Box flexDirection="column" flexShrink={0}>
296 {quota(band.parts.five, usage?.fiveHour?.percent, display?.fiveHour)}
297 {quota(band.parts.seven, usage?.sevenDay?.percent, display?.sevenDay)}
298 </Box>
299 </Box>
300 }
301 const { Box, Text } = $.ui.resolve(e)
302 const width = Math.max(0, e.props.bodyColumns - 4)
303 const rows = composeBand(width, usage ?? {}, now, warn, ctxWarn, showWarning, display, progress)
304 const paint = (line: string) => {
305 const pieces = []
306 const pattern = /●|[┄○…]+|ctx|5h|7d|[⣿⡇⣀]+|⚠️? \d+% left|\d+%|—|\([^)]*\)/gu
307 let cursor = 0
308 let section: 'ctx' | '5h' | '7d' = '5h'
309 for (const match of line.matchAll(pattern)) {
310 const at = match.index ?? 0
311 if (at > cursor) pieces.push(line.slice(cursor, at))
312 const part = match[0]
313 if (/^[┄○…]+$/.test(part)) {
314 pieces.push(<Text dimColor>{part}</Text>)
315 cursor = at + part.length
316 continue
317 }
318 if (part === '●') {
319 pieces.push(<Text color="#D97757">{part}</Text>)
320 cursor = at + part.length
321 continue
322 }
323 if (part === 'ctx' || part === '5h' || part === '7d') section = part
324 const percent = section === 'ctx' ? usage?.context : section === '5h' ? usage?.fiveHour?.percent : usage?.sevenDay?.percent
325 const themed = part.startsWith('⣿') || part.startsWith('⡇') || part.startsWith('⣀') || part.includes('%')
326 pieces.push(themed && percent !== undefined
327 ? <Text color={levelColor(percent, section === 'ctx' ? ctxWarn : warn)}>{part}</Text>
328 : <Text dimColor>{part}</Text>)
329 cursor = at + part.length
330 }
331 if (cursor < line.length) pieces.push(line.slice(cursor))
332 return pieces
333 }
334 return <Box flexDirection="column">
335 <Text wrap="truncate-end">{paint(rows[0])}</Text>
336 <Text wrap="truncate-end">{paint(rows[1])}</Text>
337 </Box>
338 })
339}
340hooks/band.tsx 84 lines1import { brailleBar, cellWidth, charWidth, usageRow } from './usage/format'
2import type { UsageState, DisplayState } from '../types'
3
4function clip(text: string, width: number): string {
5 let result = ''
6 let cells = 0
7 for (const char of text) {
8 if (char === '\uFE0F') {
9 if (result) result += char
10 } else if (cells + charWidth(char) <= width) {
11 result += char
12 cells += charWidth(char)
13 } else {
14 break
15 }
16 }
17 return result
18}
19
20export type BandProgress = { air: string; ground: string; done: number; total: number; name: string }
21
22export function layoutBand(
23 width: number,
24 usage: UsageState,
25 now: number,
26 warn = 80,
27 ctxWarn = 70,
28 warningVisible = true,
29 display?: DisplayState,
30 progress?: BandProgress,
31) {
32 let showName = true
33 let quotaCells = 10
34 let contextCells = 5
35 let showReset = true
36 let showBar = true
37
38 const make = () => {
39 const five = usageRow('5h', usage.fiveHour?.percent, showReset ? usage.fiveHour?.resetsAt : undefined, now, warn, showBar ? quotaCells : 0, warningVisible, display?.fiveHour)
40 const seven = usageRow('7d', usage.sevenDay?.percent, showReset ? usage.sevenDay?.resetsAt : undefined, now, warn, showBar ? quotaCells : 0, warningVisible, display?.sevenDay)
41 const amountWidth = Math.max(cellWidth(five.amount), cellWidth(seven.amount))
42 const row = (part: typeof five) => `${part.label}${part.bar} ${part.amount.padEnd(amountWidth + part.amount.length - cellWidth(part.amount))} ${part.reset}`
43 const ctx = usage.context === undefined ? ' —' : Math.round(usage.context) >= ctxWarn
44 ? `${warningVisible ? '⚠️' : ' '} ${100 - Math.round(usage.context)}% left`
45 : `${Math.round(usage.context)}%`.padStart(4)
46 const counter = !progress ? ''
47 : progress.total === 0 ? ' '
48 : ` ${progress.done}/${progress.total}${showName && progress.name ? ' ' + progress.name : ''} · `
49 const ctxBar = showBar ? brailleBar(display?.context ?? usage.context ?? 0, contextCells) : ''
50 const left = ` ${progress?.ground ?? ''}${counter}ctx ${ctxBar ? ctxBar + ' ' : ''}${ctx}`
51 const trimReset = (text: string) => showReset ? text : text.slice(0, -9)
52 return { top: trimReset(row(five)), bottom: trimReset(row(seven)), left, five, seven, amountWidth, counter, ctxBar, ctx }
53 }
54
55 let parts = make()
56 const fits = () => cellWidth(parts.left) + 5 + Math.max(cellWidth(parts.top), cellWidth(parts.bottom)) <= width
57 if (!fits()) { showName = false; parts = make() }
58 if (!fits()) { quotaCells = 5; contextCells = 3; parts = make() }
59 if (!fits()) { showReset = false; parts = make() }
60 if (!fits()) { showBar = false; parts = make() }
61
62 return { parts, showReset, showBar, narrowBars: quotaCells < 10, air: progress?.air ?? '', ground: progress?.ground ?? '' }
63}
64
65export function composeBand(
66 width: number,
67 usage: UsageState,
68 now: number,
69 warn = 80,
70 ctxWarn = 70,
71 warningVisible = true,
72 display?: DisplayState,
73 progress?: BandProgress,
74): [string, string] {
75 const { parts } = layoutBand(width, usage, now, warn, ctxWarn, warningVisible, display, progress)
76 const air = progress?.air ? ` ${progress.air}` : ''
77 const topPad = Math.max(0, width - cellWidth(air) - cellWidth(parts.top))
78 const bottomPad = Math.max(5, width - cellWidth(parts.left) - cellWidth(parts.bottom))
79 return [
80 clip(air + ' '.repeat(topPad) + parts.top, width),
81 clip(parts.left + ' '.repeat(bottomPad) + parts.bottom, width),
82 ]
83}
84hooks/usage/alerts.ts 32 lines1import type { UsageState } from '../../types'
2
3export type AlertState = { notified: string[]; ctxArmed: boolean }
4
5export function crossingAlerts(
6 previous: UsageState,
7 current: UsageState,
8 held: AlertState | undefined,
9 warn: number,
10 ctxWarn: number,
11): { kinds: string[]; state: AlertState } {
12 const state: AlertState = { notified: [...(held?.notified ?? [])], ctxArmed: held?.ctxArmed ?? true }
13 const kinds: string[] = []
14 for (const [kind, window] of [['5h', current.fiveHour], ['7d', current.sevenDay]] as const) {
15 if (!window || Math.round(window.percent) < warn) continue
16 const key = `${kind}:${window.resetsAt ?? 'session'}`
17 if (!state.notified.includes(key)) {
18 kinds.push(kind)
19 state.notified.push(key)
20 }
21 }
22 if (current.context !== undefined) {
23 const high = Math.round(current.context) >= ctxWarn
24 if (!high) state.ctxArmed = true
25 else if (state.ctxArmed) {
26 kinds.push('ctx')
27 state.ctxArmed = false
28 }
29 }
30 return { kinds, state }
31}
32hooks/usage/format.ts 59 lines1export function brailleBar(percent: number, cells: number): string {
2 const halves = Math.round(Math.max(0, Math.min(100, percent)) / 100 * cells * 2)
3 return Array.from({ length: cells }, (_, i) => {
4 const filled = halves - i * 2
5 return filled >= 2 ? '⣿' : filled === 1 ? '⡇' : '⣀'
6 }).join('')
7}
8
9export function levelColor(percent: number, warn: number): 'success' | 'warning' | 'error' {
10 const rounded = Math.round(percent)
11 return rounded < 50 ? 'success' : rounded < warn ? 'warning' : 'error'
12}
13
14export function formatReset(resetsAt: string | undefined, now: number): string {
15 if (!resetsAt) return ' '.repeat(8)
16 const minutes = Math.max(0, Math.ceil((Date.parse(resetsAt) - now) / 60_000))
17 const text = minutes < 60
18 ? `(${minutes}m)`
19 : minutes < 1440
20 ? `(${Math.floor(minutes / 60)}h${minutes % 60}m)`
21 : `(${Math.floor(minutes / 1440)}d${Math.floor(minutes % 1440 / 60)}h)`
22 return text.padEnd(8)
23}
24
25const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/
26
27export function charWidth(char: string): number {
28 return char === '\uFE0F' ? 0 : WIDE.test(char) ? 2 : 1
29}
30
31export function cellWidth(text: string): number {
32 return Array.from(text).reduce((sum, char) => sum + charWidth(char), 0)
33}
34
35export function usageRow(
36 label: string,
37 percent: number | undefined,
38 resetsAt: string | undefined,
39 now: number,
40 warn: number,
41 cells: number,
42 warningVisible = true,
43 barPercent = percent,
44) {
45 const rounded = percent === undefined ? undefined : Math.round(percent)
46 const color = rounded === undefined ? 'success' : levelColor(rounded, warn)
47 const bar = brailleBar(barPercent ?? 0, cells)
48 const amount = rounded === undefined
49 ? ' —'
50 : rounded >= warn
51 ? `${warningVisible ? '⚠️' : ' '} ${100 - rounded}% left`
52 : `${rounded}%`.padStart(4)
53 const reset = formatReset(resetsAt, now)
54 const labelField = label.padEnd(3) + ' '
55 const amountField = amount.padEnd(12 + amount.length - cellWidth(amount))
56 const text = `${labelField}${bar} ${amountField} ${reset}`
57 return { label: labelField, bar, amount, amountField, reset, color, text }
58}
59hooks/usage/animate.ts 13 lines1export function nextBarPercent(current: number, target: number, cells: number, elapsedMs: number): number {
2 if (elapsedMs >= 1000) return target
3 const half = 100 / (cells * 2)
4 const currentHalves = Math.round(current / half)
5 const targetHalves = Math.round(target / half)
6 if (currentHalves === targetHalves) return target
7 return (currentHalves + Math.sign(targetHalves - currentHalves)) * half
8}
9
10export function warningVisible(elapsedMs: number): boolean {
11 return elapsedMs >= 3000 || Math.floor(Math.max(0, elapsedMs) / 250) % 2 === 0
12}
13hooks/progress/parse.ts 37 lines1export type Progress = { done: number; total: number; current?: string }
2
3function section(md: string, heading: RegExp): string | undefined {
4 const lines = md.split(/\r?\n/)
5 const start = lines.findIndex(line => heading.test(line))
6 if (start < 0) return undefined
7 const end = lines.findIndex((line, i) => i > start && /^##\s/.test(line))
8 return lines.slice(start + 1, end < 0 ? undefined : end).join('\n')
9}
10
11export function parseChecklist(md: string): Progress | undefined {
12 const body = section(md, /^##\s*進度\s*$/)
13 if (body === undefined) return undefined
14 const items = [...body.matchAll(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/gm)].map(m => ({ done: m[1] !== ' ', text: (m[2] ?? '').trim() }))
15 if (items.length === 0) return undefined
16 return { done: items.filter(i => i.done).length, total: items.length, current: items.find(i => !i.done)?.text }
17}
18
19export function parseTasks(md: string, archive: string[]): Progress | undefined {
20 const tasks = new Map<number, { title: string; done: boolean }>()
21 for (const name of archive) {
22 const match = /task-(\d+)/i.exec(name)
23 if (match) tasks.set(Number(match[1]), { title: `Task ${match[1]}`, done: true })
24 }
25 const blocks = md.split(/^(?=##\s+Task\s*\d+)/m)
26 for (const block of blocks) {
27 const heading = /^##\s+(Task\s*(\d+).*)$/m.exec(block)
28 if (!heading) continue
29 const done = /狀態:\s*(完成|已合併)/.test(block)
30 const number = Number(heading[2] ?? 0)
31 tasks.set(number, { title: (heading[1] ?? '').trim(), done: done || tasks.get(number)?.done === true })
32 }
33 if (tasks.size === 0) return undefined
34 const ordered = [...tasks.entries()].sort((a, b) => a[0] - b[0]).map(([, task]) => task)
35 return { done: ordered.filter(t => t.done).length, total: ordered.length, current: ordered.find(t => !t.done)?.title }
36}
37hooks/progress/tasks.ts 36 lines1import type { Progress } from './parse'
2
3export type SessionTask = { id: string; subject: string; activeForm?: string; status: string }
4
5export function applyTaskTool(list: SessionTask[], tool: string, input: Record<string, unknown>, result?: unknown): SessionTask[] {
6 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
7 return input.todos.map((todo: { content: string; status: string; activeForm?: string }, i) => ({
8 id: String(i + 1), subject: todo.content, activeForm: todo.activeForm, status: todo.status,
9 }))
10 }
11 if (tool === 'TaskCreate' && typeof input.subject === 'string') {
12 const returned = (result as { task?: { id?: unknown } } | undefined)?.task?.id
13 const id = typeof returned === 'string' ? returned : String(list.reduce((max, task) => Math.max(max, Number(task.id) || 0), 0) + 1)
14 const activeForm = typeof input.activeForm === 'string' ? input.activeForm : undefined
15 return [...list, { id, subject: input.subject, activeForm, status: 'pending' }]
16 }
17 if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
18 if (input.status === 'deleted') return list.filter(task => task.id !== input.taskId)
19 return list.map(task => task.id !== input.taskId ? task : {
20 ...task,
21 subject: typeof input.subject === 'string' ? input.subject : task.subject,
22 activeForm: typeof input.activeForm === 'string' ? input.activeForm : task.activeForm,
23 status: typeof input.status === 'string' ? input.status : task.status,
24 })
25 }
26 return list
27}
28
29export function taskProgress(list: SessionTask[]): Progress | undefined {
30 if (list.length === 0) return undefined
31 const active = list.find(task => task.status === 'in_progress')
32 const next = active ?? list.find(task => task.status !== 'completed')
33 const current = next && (next === active ? next.activeForm ?? next.subject : next.subject)
34 return { done: list.filter(task => task.status === 'completed').length, total: list.length, current }
35}
36hooks/progress/track.ts 55 lines1import { cellWidth } from '../usage/format'
2
3export type Pose = { kind: 'rest'; sleep?: string } | { kind: 'jump'; frame: 1 | 2 | 3 } | { kind: 'bounce'; up: boolean; sparkle: boolean }
4
5function gapFor(total: number): number {
6 return total > 8 ? 2 : 4
7}
8
9export function jumpOffset(gap: number, frame: number): number {
10 return Math.round(frame * (gap + 1) / 4)
11}
12
13export function drawTrack(total: number, done: number, pose: Pose): { air: string; ground: string; ballColumn: number } {
14 const gap = gapFor(total)
15 const ball = Math.min(done, total - 1)
16 const first = total > 16 ? Math.max(0, ball - 3) : 0
17 const last = total > 16 ? Math.min(total - 1, ball + 4) : total - 1
18 const lead = first > 0 ? '…' : ''
19 const nodeColumn = lead.length + (ball - first) * (gap + 1)
20 const airborne = pose.kind === 'jump' ? jumpOffset(gap, pose.frame) : 0
21 const cells: string[] = [lead]
22 for (let i = first; i <= last; i++) {
23 if (i > first) {
24 const filled = i <= ball ? gap : i === ball + 1 && airborne > 0 ? airborne - 1 : 0
25 cells.push('━'.repeat(filled) + '┄'.repeat(gap - filled))
26 }
27 cells.push(i < ball || (i === ball && airborne > 0) ? '✓' : i === ball ? '●' : '○')
28 }
29 if (last < total - 1) cells.push('…')
30 const ballColumn = nodeColumn + airborne
31 if (pose.kind === 'bounce') {
32 if (pose.up) cells[cells.length - 1] = '✓'
33 return {
34 air: pose.up ? ' '.repeat(ballColumn) + '●' : '',
35 ground: cells.join('') + (pose.sparkle ? ' ✨' : ''),
36 ballColumn,
37 }
38 }
39 const air = airborne > 0 ? ' '.repeat(ballColumn) + '●'
40 : pose.kind === 'rest' && pose.sleep ? ' '.repeat(ballColumn + 1) + pose.sleep
41 : ''
42 return { air, ground: cells.join(''), ballColumn }
43}
44
45export function taskName(title: string | undefined): string {
46 const name = (title ?? '').replace(/^Task\s*\d+\s*[::]?\s*/i, '').trim()
47 if (cellWidth(name) <= 24) return name
48 let cut = ''
49 for (const char of name) {
50 if (cellWidth(cut + char) > 23) break
51 cut += char
52 }
53 return cut + '…'
54}
55hooks/progress/svg.ts 50 lines1import type { Pose } from './track'
2
3export const BALL = '#D97757'
4const WHITE = '#FFFFFF'
5const GRAY = '#8A8A8A'
6export const TRACK_HEIGHT = 40
7const HEIGHT = TRACK_HEIGHT
8const GROUND = 29
9
10export function trackSvg({ total, shown, pose, hop = 0 }: { total: number; shown: number; pose: Pose; hop?: number }): string {
11 const step = total > 8 ? 18 : 28
12 const ball = Math.min(shown, total - 1)
13 const first = total > 16 ? Math.max(0, ball - 3) : 0
14 const last = total > 16 ? Math.min(total - 1, ball + 4) : total - 1
15 const lead = first > 0 ? 14 : 0
16 const x = (i: number) => 8 + lead + (i - first) * step
17 const airborne = pose.kind === 'jump' ? pose.frame : 0
18 const lifted = pose.kind === 'bounce' && pose.up
19 const ballX = x(ball) + airborne / 4 * step
20 const ballY = airborne > 0 ? GROUND - [0, 10, 14, 10][airborne]! : lifted ? GROUND - 12 : pose.kind === 'rest' ? GROUND - hop : GROUND
21 const parts: string[] = []
22 for (let i = first + 1; i <= last; i++) {
23 parts.push(i <= ball
24 ? `<line x1="${x(i - 1)}" y1="${GROUND}" x2="${x(i)}" y2="${GROUND}" stroke="${WHITE}" stroke-width="2"/>`
25 : `<line x1="${x(i - 1)}" y1="${GROUND}" x2="${x(i)}" y2="${GROUND}" stroke="${GRAY}" stroke-width="1.5" stroke-dasharray="2 3"/>`)
26 }
27 for (let i = first; i <= last; i++) {
28 const done = i < ball || (i === ball && (airborne > 0 || lifted))
29 if (done) parts.push(`<circle cx="${x(i)}" cy="${GROUND}" r="4.5" fill="${WHITE}"/>`)
30 else if (i > ball) parts.push(`<circle cx="${x(i)}" cy="${GROUND}" r="4" fill="none" stroke="${GRAY}" stroke-width="1.5"/>`)
31 }
32 parts.push(`<circle cx="${ballX}" cy="${ballY}" r="6" fill="${BALL}"/>`)
33 if (first > 0) parts.push(`<text x="0" y="${GROUND + 4}" font-size="13" fill="${GRAY}">…</text>`)
34 if (last < total - 1) parts.push(`<text x="${x(last) + 8}" y="${GROUND + 4}" font-size="13" fill="${GRAY}">…</text>`)
35 let width = Math.max(x(last) + (last < total - 1 ? 24 : 10), ballX + 8)
36 if (pose.kind === 'rest' && pose.sleep) {
37 parts.push(`<text x="${x(ball) + 8}" y="14" font-size="13" font-family="sans-serif" fill="${GRAY}">${pose.sleep}</text>`)
38 width = Math.max(width, x(ball) + 36)
39 }
40 if (pose.kind === 'bounce' && pose.sparkle) {
41 parts.push(`<text x="${x(ball) + 10}" y="${GROUND + 5}" font-size="13">✨</text>`)
42 width = Math.max(width, x(ball) + 30)
43 }
44 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">${parts.join('')}</svg>`
45}
46
47export function svgWidth(source: string): number {
48 return Number(source.match(/width="([\d.]+)"/)?.[1] ?? 0)
49}
50hooks/usage/bar.ts 8 lines1export const LEVEL_HEX = { success: '#4EBA65', warning: '#E2B33C', error: '#FF6B80' } as const
2
3export function barSvg(percent: number, width: number, level: keyof typeof LEVEL_HEX): string {
4 const filled = Math.round(Math.max(0, Math.min(100, percent)) / 100 * width)
5 const fill = filled > 0 ? `<rect x="0" y="0" width="${filled}" height="8" rx="4" fill="${LEVEL_HEX[level]}"/>` : ''
6 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8"><rect x="0" y="0" width="${width}" height="8" rx="4" fill="#8A8A8A" fill-opacity="0.3"/>${fill}</svg>`
7}
8types/index.d.ts 19 lines1export type UsageWindow = { percent: number; resetsAt?: string }
2export type UsageState = {
3 fiveHour?: UsageWindow
4 sevenDay?: UsageWindow
5 context?: number
6}
7export type AlertState = { notified: string[]; ctxArmed: boolean }
8export type DisplayState = { fiveHour?: number; sevenDay?: number; context?: number; startedAt: number; minute?: number }
9export type FlashState = { tick: number }
10export type BallState = { shown: number; total: number; phase: 'rest' | 'jump' | 'bounce'; step: number; sparkle: boolean }
11export type ProgressState = { done: number; total: number; current?: string }
12export type SessionTaskState = { id: string; subject: string; activeForm?: string; status: string }
13
14declare module 'claude-code' {
15 interface PluginState {
16 'usage-ball': { usage: UsageState; alerts: AlertState; display: DisplayState; flash: FlashState; progress: ProgressState; tasks: SessionTaskState[]; ball: BallState; sleep: { tick: number }; hop: { tick: number } }
17 }
18}
19