Plan usage windows, reset times and session API-equivalent cost in a band above the prompt.

Lawrence Chase's Claude Code marketplace: code review, diagram, and test-evidence skills, plus a set of small Claude Code mods that add band lines and panes to the terminal UI.
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
/plugin install test-drive@lchase
/plugin install cache-clock@lchase
/plugin install usage-gauge@lchase
/plugin install auto-handoff@lchase
/plugin install agent-watch@lchase
/plugin install recording-mode@lchase
Each plugin is independent; install only what you want. @lchase is the marketplace name (from .claude-plugin/marketplace.json), not the GitHub slug.
New versions ship whenever the version string in a plugin's manifest changes. Claude Code refreshes marketplaces in the background by default, so you normally pick up updates without doing anything. To pull them right now:
/plugin marketplace update lchase # refresh this marketplace's manifest
/plugin update smart-review@lchase # apply a pending update to one plugin
/plugin update tldraw@lchase
/plugin marketplace update (no name) refreshes every marketplace. From a shell: claude plugin marketplace update lchase and claude plugin update <plugin>@lchase.
If a plugin gets into a bad state (stale cached script, half-applied update), do a clean reinstall:
/plugin uninstall tldraw
/plugin marketplace update lchase
/plugin install tldraw@lchase
Nuclear option, re-register the whole marketplace:
/plugin marketplace remove lchase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
Before this repo became a multi-plugin marketplace it published one plugin from the repo root under a marketplace named chase. If you installed it back then, clear the old registration first:
/plugin uninstall smart-review
/plugin uninstall tldraw # only if you installed the short-lived tldraw@chase
/plugin marketplace remove chase
/plugin marketplace add lchase/skills
/plugin install smart-review@lchase
/plugin install tldraw@lchase
The tldraw skill's command also changed: /tldraw:tldraw → /tldraw:diagram.
| Plugin | What it does | Skills / commands | Harnesses |
|---|---|---|---|
| smart-review | Ensemble code review: six specialized lenses over a diff, merged into one deduplicated, severity-ranked verdict | /smart-review:{review,min,max,add-pr-review,review-pr-comments} + auto-trigger | Claude Code, Cursor, Codex, Gemini CLI, any AGENTS.md agent |
| tldraw | Natural-language description → editable tldraw document (.tldr) + rendered PNG/SVG | /tldraw:diagram + auto-trigger | Claude Code only (needs Node + npx) |
| test-drive | Before/after evidence of a UI or API change: record the app before you edit, replay the same scripted scenario after, with a deterministic verdict | /test-drive:{baseline,compare} + auto-trigger | Claude Code only (needs Node; UI also needs Playwright and ffmpeg) |
| cache-clock | Mod: band line showing whether the prompt cache is warm, with countdown, reuse %, cached tokens, and estimated cold-rewrite cost | Compact button, /cache-clock | Claude Code only |
| usage-gauge | Mod: band line with plan usage windows (5-hour, weekly, spend limit), reset times, pace warning, session cost on API billing, threshold toasts | /usage-gauge (detail pane) | Claude Code only |
| auto-handoff | Mod: band line showing how full the chat is; saves a HANDOFF.md at a threshold (auto or on request), then clear and continue or resume in a new session | /auto-handoff | Claude Code only |
| agent-watch | Mod: band line showing who is doing the work this turn (main chat, helper agents, models, tasks, status, skills), plus a pane with per-agent blocks, per-model totals, timeline | /agent-watch | Claude Code only |
| recording-mode | Mod: screen-record without leaking secrets; tells the model to keep keys, emails, private words off screen and masks them in the transcript display only, red REC chip | /rec | Claude Code only |
An ensemble code review: several specialized lenses, one merged verdict, not another pile of comments.
Most code review, human or AI, is one reviewer making a single pass and leaving a list of comments. This runs six specialized lenses instead (spec-conformance, correctness, security, performance, design, and tests), then merges the results into one verdict: duplicates collapsed, findings that several lenses independently raise weighted up, conflicting advice reconciled, severity rolled up, structural problems first, nits capped. The merge is the point. An ensemble without it is just louder, and a longer checklist handed to one reviewer only dilutes its attention and repeats its own misses.
Two modes: a fast single pass (min) for tight loops, and an orchestrated ensemble (max) for pre-merge review. Auto-routing picks between them by change size and sensitivity.
The skill lives in plugins/smart-review/skills/smart-review/ as harness-neutral markdown. Each harness gets a thin manifest pointing at that same directory, so no skill content is copied or forked.
| Harness | Install | min | max | Slash commands / auto-trigger |
|---|---|---|---|---|
| Claude Code | /plugin marketplace add lchase/skills then /plugin install smart-review@lchase | ✅ | ✅ isolated parallel subagents | ✅ |
| Cursor | point Cursor at plugins/smart-review/.cursor-plugin/ | ✅ | ✅ sequential ensemble | via session-start hook |
| Codex | plugins/smart-review/.codex-plugin/ manifest | ✅ | ✅ sequential ensemble | no |
| Gemini CLI | gemini extensions install https://github.com/lchase/skills (reads root gemini-extension.json) | ✅ | ✅ sequential ensemble | via GEMINI.md |
| Any AGENTS.md agent | reads root AGENTS.md | ✅ | ✅ sequential ensemble | no |
max's isolated parallel subagents are a Claude Code capability. A model panel (plugins/smart-review/skills/smart-review/references/ensemble.md, off by default) rides on top when the harness can route more than one model. Otherwise max is single-model. Elsewhere max runs the same spec-gate, six lenses, and merge as a disciplined sequential walk. Design and internals: plugins/smart-review/SMART-REVIEW.md.
Claude Code slash commands:
/smart-review:review: auto (routes to min or max by size + sensitivity)/smart-review:min: fast single-pass review/smart-review:max: orchestrated ensemble review/smart-review:add-pr-review <PR number or URL>: reviews a GitHub PR and, after you confirm what to publish, posts the findings as PR comments/smart-review:review-pr-comments <PR number or URL>: triages a PR's existing unresolved review comments, fixes or explains each after your approval, replies, and resolves the threadThe skill also triggers automatically when you ask Claude to review a diff, PR, or branch.
Turn a described diagram (flowchart, architecture / system diagram, process map, decision tree, state machine) into two artifacts: an editable .tldr document (opens at tldraw.com or in the tldraw editor) and a rendered PNG/SVG for docs and PRs.
Claude writes a {nodes, edges} spec from your description; a bundled Node script does a simple layered auto-layout, emits the .tldr, and renders images by loading it into a headless-Chromium tldraw instance via @kitschpatrol/tldraw-cli (first run downloads Chromium, ~1 min, cached).
/plugin install tldraw@lchase
Claude Code only. Skill: /tldraw:diagram (also auto-triggers on "draw this diagram", "diagram this flow", "make a tldraw of…"). Details: plugins/tldraw/skills/diagram/SKILL.md.
A test drive is an e2e test that happens to produce video, and the "before" is part of the process rather than something reconstructed later. Run /test-drive:baseline before you edit: it writes a short browser scenario and records the app as it stands. Make the change, then run /test-drive:compare: it replays the identical scenario with Playwright and composes a labeled side-by-side MP4. The pass/warn/fail verdict comes from the scenario's assertions, not from reading the video.
Backend changes work the same way with "kind": "api": the scenario is a list of HTTP steps (method, path, body, expected status, JSON fields, and server-log lines to expect or avoid). There is nothing to screen-record, so each step captures the request, the response, and the server log lines it produced, and compare writes a before/after report.md instead of a video. Secrets are redacted from the stored transcript (best effort, extendable per scenario with redact), and the finished report is scanned and flagged if anything still looks secret.
/plugin install test-drive@lchase
Claude Code only. UI scenarios need ffmpeg and Playwright in the repo under test; API scenarios need only Node 18+. Only the working tree is ever run, so there is no second checkout to boot. Details: baseline and compare.
Five small plugins that change Claude Code's own interface instead of adding a task skill. Each draws a band line above the prompt (some add a side pane or slash command), is independent of the others, and works on Claude Code only.
cacheMinutes (5 or 60, default 60) to match your cache TTL./usage-gauge opens a detail pane.HANDOFF.md at a threshold (automatically or on request), then clears and continues, or resumes in a new session. /auto-handoff./agent-watch opens a pane with per-agent blocks, per-model totals, and a timeline./rec to toggle./plugin install cache-clock@lchase # or usage-gauge, auto-handoff, agent-watch, recording-mode
.claude-plugin/marketplace.json # the "lchase" marketplace: 8 plugin entries
AGENTS.md # repo-level agent pointer (→ smart-review)
gemini-extension.json + GEMINI.md # Gemini CLI installs the repo as an extension (→ smart-review)
plugins/smart-review/ # plugin 1, multi-harness code review
.claude-plugin/plugin.json
skills/smart-review/ # the harness-neutral core (SKILL.md + references/)
agents/ commands/ hooks/ # Claude Code packaging
.cursor-plugin/ .codex-plugin/ # thin per-harness manifests
scripts/ # validate-adapters.sh, bump-version.sh
evals/ # dev-only eval kit (not shipped to installers)
SMART-REVIEW.md # design writeup
plugins/tldraw/ # plugin 2, Claude Code only
.claude-plugin/plugin.json
skills/diagram/ # SKILL.md + scripts/build-tldr.mjs + references/
plugins/test-drive/ # plugin 3, Claude Code only
.claude-plugin/plugin.json
skills/{baseline,compare}/ # SKILL.md each
scripts/{lib.mjs,record.mjs,record-api.mjs,redact.mjs,verdict.mjs,compose.sh,report.mjs} # shared by both skills
references/ # UI and API scenario schemas + examples
plugins/{cache-clock,usage-gauge,auto-handoff,agent-watch,recording-mode}/ # plugins 4-8, Claude Code mods
.claude-plugin/plugin.json # each with its own version
hooks/ # register.tsx (the mod) + hooks.json, tests where present
types/ # mod API typings
claude plugin validate . # marketplace + all plugins
./plugins/smart-review/scripts/validate-adapters.sh # smart-review's per-harness manifests
Releasing: users only get an update when a plugin's version string changes, so bump it on every user-facing change and push to main.
./plugins/smart-review/scripts/bump-version.sh <version>..claude-plugin/plugin.json.Once pushed, installed clients pick it up on their next background marketplace refresh, or immediately with /plugin marketplace update lchase.
hooks/register.tsx 305 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Limit, Reading, Settings, Snapshot } from '../types'
5import {
6 CHIP_BG,
7 CHIP_FG,
8 addReading,
9 bar,
10 colorFor,
11 duration,
12 money,
13 paceOutMs,
14 paceText,
15 parseWarn,
16 pctText,
17 resetText,
18 shortLabel,
19 windowLabel,
20 windowTitle,
21} from './lib'
22
23const PANE = 'usage-gauge'
24const MAX_TURNS = 10
25// 11 cells, the width of auto-handoff's ' HANDOFF ⇄ ' chip, so stacked bands line up
26const CHIP_WIDTH = 11
27
28// label right-aligned, icon on the right edge
29const rightAligned = (label: string, icon: string): string => `${label} ${icon} `.padStart(CHIP_WIDTH)
30const DEFAULTS: Settings = { isOn: true, warnA: 75, warnB: 90 }
31
32const snap = atom({ plugin: 'usage-gauge', key: 'snap' } as const, null)
33const readings = atom({ plugin: 'usage-gauge', key: 'readings' } as const, {})
34const turns = atom({ plugin: 'usage-gauge', key: 'turns' } as const, [])
35const prevCost = atom({ plugin: 'usage-gauge', key: 'prevCost' } as const, null)
36const settings = atom({ plugin: 'usage-gauge', key: 'settings' } as const, DEFAULTS)
37
38type Measured = {
39 rateLimits: readonly Limit[]
40 context: { percent?: number }
41 cost?: { usd: number }
42}
43
44function isSettings(v: unknown): v is Settings {
45 const s = v as Settings | null
46 return (
47 typeof s === 'object' &&
48 s !== null &&
49 typeof s.isOn === 'boolean' &&
50 typeof s.warnA === 'number' &&
51 typeof s.warnB === 'number'
52 )
53}
54
55async function saveSettings($: EngineInterface, next: Settings) {
56 await update($, settings, () => next)
57 await $.store.set('settings', next)
58}
59
60async function ingest($: EngineInterface, m: Measured) {
61 const now: number = await $.clock.now()
62 const limits: Limit[] = m.rateLimits.map(l => ({
63 kind: l.kind,
64 percentUsed: l.percentUsed,
65 resetsAt: l.resetsAt,
66 }))
67 const next: Snapshot = {
68 rateLimits: limits,
69 contextPercent: m.context.percent,
70 costUsd: m.cost?.usd,
71 }
72 await update($, snap, () => next)
73 await update($, readings, all => {
74 const out: Record<string, Reading[]> = {}
75 for (const l of limits) out[l.kind] = addReading(all?.[l.kind], l.percentUsed, now)
76 return out
77 })
78
79 const cfg: Settings = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
80 if (!cfg.isOn) {
81 $.ui.status(undefined)
82 return
83 }
84 const parts = limits.map(l => `${shortLabel(l.kind)} ${pctText(l.percentUsed)}`)
85 $.ui.status(parts.length > 0 ? parts.join(' · ') : undefined)
86
87 const stored = (await $.store.get('fired')) as Record<string, string> | undefined
88 const fired: Record<string, string> = { ...(stored ?? {}) }
89 let isDirty = false
90 for (const l of limits) {
91 for (const t of [cfg.warnA, cfg.warnB]) {
92 const key = `${l.kind}:${t}`
93 const mark = l.resetsAt ?? ''
94 if (l.percentUsed >= t) {
95 if (fired[key] !== mark) {
96 fired[key] = mark
97 isDirty = true
98 const at = resetText(l, now)
99 const isSoon = l.resetsAt !== undefined && Date.parse(l.resetsAt) - now < 86_400_000
100 const when = at === undefined ? '' : `, resets ${isSoon && at !== 'now' ? 'in ' : ''}${at}`
101 $.ui.toast(`${windowTitle(l.kind)} at ${t}%${when}`)
102 }
103 } else if (key in fired) {
104 delete fired[key]
105 isDirty = true
106 }
107 }
108 }
109 if (isDirty) await $.store.set('fired', fired)
110}
111
112export const register: Register = on => {
113 on('session.start', async ($, e, next) => {
114 const saved = await $.store.get('settings')
115 if (isSettings(saved)) await update($, settings, () => saved)
116 await $.command.register({
117 name: 'usage-gauge',
118 description: 'Plan usage windows, resets and session cost',
119 argumentHint: 'on | off | warn <a> <b> | status',
120 })
121 await ingest($, await $.session.usage())
122
123 return next(e)
124 })
125
126 on('session.measure', async ($, e, next) => {
127 await ingest($, e)
128
129 return next(e)
130 })
131
132 on('turn.complete', async ($, e, next) => {
133 if (!e.agentId) {
134 const usage = await $.session.usage()
135 if (usage.cost) {
136 const total = usage.cost.usd
137 const before = await read($, prevCost)
138 await update($, prevCost, () => total)
139 if (before !== null && total > before) {
140 await update($, turns, list => [...list, total - before].slice(-MAX_TURNS))
141 }
142 }
143 }
144
145 return next(e)
146 })
147
148 on('command.run', { command: 'usage-gauge' }, async ($, e) => {
149 const arg = e.args.trim()
150 const cfg: Settings = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
151 const word = arg.split(/\s+/)[0] ?? ''
152
153 if (arg === '') {
154 await $.ui.open({ id: PANE, title: 'Usage gauge' })
155
156 return { text: 'Usage gauge pane opened.' }
157 }
158 if (word === 'on' || word === 'off') {
159 await saveSettings($, { ...cfg, isOn: word === 'on' })
160 if (word === 'off') $.ui.status(undefined)
161 else await ingest($, await $.session.usage())
162
163 return { text: `Usage gauge ${word}.` }
164 }
165 if (word === 'warn') {
166 const parsed = parseWarn(arg)
167 if (typeof parsed === 'string') return { text: parsed }
168 await saveSettings($, { ...cfg, warnA: parsed.a, warnB: parsed.b })
169
170 return { text: `Toasts at ${parsed.a}% and ${parsed.b}%.` }
171 }
172 if (word === 'status') {
173 const s = await read($, snap)
174 const now: number = await $.clock.now()
175 const lines = [
176 `Usage gauge ${cfg.isOn ? 'on' : 'off'}, toasts at ${cfg.warnA}% and ${cfg.warnB}%.`,
177 ]
178 for (const l of s?.rateLimits ?? []) {
179 const at = resetText(l, now)
180 lines.push(`${windowLabel(l.kind)} ${pctText(l.percentUsed)}${at ? ` resets ${at}` : ''}`)
181 }
182 if (s?.costUsd !== undefined) lines.push(`session ${money(s.costUsd)}`)
183 if ((s?.rateLimits.length ?? 0) === 0) lines.push('No plan windows reported (API billing).')
184
185 return { text: lines.join('\n') }
186 }
187
188 return { text: 'Usage: /usage-gauge on | off | warn <a> <b> | status' }
189 })
190
191 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
192 const cfg: Settings = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
193 const s = await read($, snap)
194 if (e.props.hasSurvey || !cfg.isOn || s === null) return next(e)
195
196 const now: number = await $.clock.now()
197 const all = await read($, readings)
198 const history = await read($, turns)
199 const last = history[history.length - 1]
200 const hasPlan = s.rateLimits.length > 0
201 const hasCtx = s.contextPercent !== undefined
202 if (!hasPlan && !hasCtx && s.costUsd === undefined) return next(e)
203
204 const { Box, Text } = $.ui.resolve(e)
205 const sep = <Text dimColor> │ </Text>
206 const segs: RenderChildren[] = []
207
208 for (const l of s.rateLimits) {
209 const c = colorFor(l.percentUsed)
210 const at = resetText(l, now)
211 const out = l.kind === 'five_hour' ? paceOutMs(all?.[l.kind], l.resetsAt, now) : undefined
212 segs.push(
213 <Text>
214 {windowLabel(l.kind)} <Text color={c}>{bar(l.percentUsed, 6)} {pctText(l.percentUsed)}</Text>
215 {at ? <Text dimColor> ↻{at}</Text> : null}
216 {out !== undefined ? <Text color="#fbbf24"> ⚠ at this pace: out in {paceText(out)}</Text> : null}
217 </Text>,
218 )
219 }
220 if (!hasPlan && hasCtx) {
221 const pct = s.contextPercent as number
222 segs.push(
223 <Text>
224 <Text color={colorFor(pct)}>ctx {bar(pct, 6)} {pctText(pct)}</Text>
225 </Text>,
226 )
227 }
228 if (s.costUsd !== undefined) {
229 segs.push(
230 <Text>
231 session {money(s.costUsd)}
232 {last !== undefined ? <Text dimColor> (+{money(last)})</Text> : null}
233 </Text>,
234 )
235 }
236
237 const line = (
238 <Text wrap="truncate-end">
239 <Text bold color={CHIP_FG} backgroundColor={CHIP_BG}>{rightAligned(hasPlan ? 'PLAN' : 'API', '◔')}</Text>
240 {' '}
241 {segs.flatMap((seg, i) => (i === 0 ? [seg] : [sep, seg]))}
242 </Text>
243 )
244 const below = await next(e)
245
246 return below ? (
247 <Box flexDirection="column">
248 {line}
249 {below}
250 </Box>
251 ) : (
252 line
253 )
254 })
255
256 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
257 const { Box, Text } = $.ui.resolve(e)
258 const s = await read($, snap)
259 const cfg: Settings = ((await read($, settings)) as Settings | undefined) ?? DEFAULTS
260 const all = await read($, readings)
261 const history = await read($, turns)
262 const now: number = await $.clock.now()
263 const width = Math.max(10, Math.min(40, (e.props.bodyColumns ?? 60) - 24))
264
265 return (
266 <Box flexDirection="column">
267 <Text bold>Usage gauge {cfg.isOn ? '' : '(off)'}</Text>
268 {(s?.rateLimits ?? []).length === 0 && (
269 <Text dimColor>No plan windows reported (API billing).</Text>
270 )}
271 {(s?.rateLimits ?? []).map(l => {
272 const at = resetText(l, now)
273 const out = paceOutMs(all?.[l.kind], l.resetsAt, now)
274 return (
275 <Box flexDirection="column" marginTop={1}>
276 <Text bold>{windowTitle(l.kind)}</Text>
277 <Text>
278 <Text color={colorFor(l.percentUsed)}>{bar(l.percentUsed, width)}</Text> {pctText(l.percentUsed)}
279 </Text>
280 <Text dimColor>
281 {at ? `resets ${at}${l.resetsAt && Date.parse(l.resetsAt) > now ? ` (in ${duration(Date.parse(l.resetsAt) - now)})` : ''}` : 'no reset time reported'}
282 </Text>
283 <Text dimColor={out === undefined} color={out === undefined ? undefined : '#fbbf24'}>
284 {out === undefined ? 'pace: on track, or too early to tell' : `pace: out in ${paceText(out)} at this rate`}
285 </Text>
286 </Box>
287 )
288 })}
289 <Box flexDirection="column" marginTop={1}>
290 <Text bold>
291 Session {s?.costUsd !== undefined ? money(s.costUsd) : ''}
292 </Text>
293 {history.length === 0 && <Text dimColor>No finished turns yet.</Text>}
294 {history.length > 0 && <Text dimColor>Last {history.length} turns</Text>}
295 {history.map((c, i) => (
296 <Text>
297 {String(history.length - i).padStart(2)} ago {money(c)}
298 </Text>
299 ))}
300 </Box>
301 </Box>
302 )
303 })
304}
305hooks/lib.ts 126 lines1import type { Limit, Reading } from '../types'
2
3export const GREEN = '#4ade80'
4export const AMBER = '#fbbf24'
5export const RED = '#f87171'
6export const CHIP_BG = '#3b2160'
7export const CHIP_FG = '#b8aece'
8
9const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
10const MIN = 60_000
11const PACE_MIN_SPAN = 4.5 * MIN
12const PACE_KEEP = 30 * MIN
13
14export function colorFor(pct: number): string {
15 if (pct > 85) return RED
16 if (pct >= 60) return AMBER
17 return GREEN
18}
19
20export function bar(pct: number, cells: number): string {
21 const filled = Math.max(0, Math.min(cells, Math.round((pct / 100) * cells)))
22 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
23}
24
25export function windowLabel(kind: string): string {
26 if (kind === 'five_hour') return '5h'
27 if (kind === 'seven_day') return 'week'
28 return kind
29}
30
31export function windowTitle(kind: string): string {
32 if (kind === 'five_hour') return '5-hour limit'
33 if (kind === 'seven_day') return 'Weekly limit'
34 return `${kind} limit`
35}
36
37export function shortLabel(kind: string): string {
38 if (kind === 'five_hour') return '5h'
39 if (kind === 'seven_day') return 'wk'
40 return kind
41}
42
43export function pctText(pct: number): string {
44 return `${Math.round(pct)}%`
45}
46
47export function duration(ms: number): string {
48 const total = Math.max(0, Math.round(ms / MIN))
49 const h = Math.floor(total / 60)
50 const m = total % 60
51 if (h === 0) return `${m}m`
52 return `${h}h${String(m).padStart(2, '0')}m`
53}
54
55function clock(date: Date): string {
56 const h = date.getHours()
57 const m = date.getMinutes()
58 const h12 = h % 12 === 0 ? 12 : h % 12
59 const suffix = h < 12 ? 'am' : 'pm'
60 return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, '0')}${suffix}`
61}
62
63/** `2h11m` within a day, otherwise `Thu 9am`; undefined when there is no reset time. */
64export function resetText(limit: Limit, now: number): string | undefined {
65 if (!limit.resetsAt) return undefined
66 const at = Date.parse(limit.resetsAt)
67 if (Number.isNaN(at)) return undefined
68 const left = at - now
69 if (left <= 0) return 'now'
70 if (left < 24 * 60 * MIN) return duration(left)
71 const date = new Date(at)
72 return `${DAYS[date.getDay()]} ${clock(date)}`
73}
74
75export function money(usd: number): string {
76 return `$${usd.toFixed(2)}`
77}
78
79/** Append a reading; a drop in percent means the window reset, so history restarts. */
80export function addReading(list: Reading[] | undefined, pct: number, t: number): Reading[] {
81 const prior = list ?? []
82 const last = prior[prior.length - 1]
83 const base = last && pct < last.pct ? [] : prior
84 const tail = base[base.length - 1]
85 if (tail && tail.pct === pct) return base
86 return [...base, { pct, t }].filter(r => t - r.t <= PACE_KEEP).slice(-20)
87}
88
89/**
90 * Minutes until the window runs out at the rate the readings show, when that is
91 * before it resets. Needs two readings about five minutes apart.
92 */
93export function paceOutMs(
94 readings: Reading[] | undefined,
95 resetsAt: string | undefined,
96 now: number,
97): number | undefined {
98 if (!readings || readings.length < 2 || !resetsAt) return undefined
99 const first = readings[0]!
100 const last = readings[readings.length - 1]!
101 const span = last.t - first.t
102 const gain = last.pct - first.pct
103 if (span < PACE_MIN_SPAN || gain <= 0 || last.pct >= 100) return undefined
104 const rate = gain / span
105 const runOut = last.t + (100 - last.pct) / rate
106 const reset = Date.parse(resetsAt)
107 if (Number.isNaN(reset) || runOut >= reset) return undefined
108 return Math.max(MIN, runOut - now)
109}
110
111export function paceText(outMs: number): string {
112 const mins = Math.max(1, Math.round(outMs / MIN))
113 if (mins < 60) return `~${mins}m`
114 return `~${Math.floor(mins / 60)}h${String(mins % 60).padStart(2, '0')}m`
115}
116
117export function parseWarn(args: string): { a: number; b: number } | string {
118 const parts = args.trim().split(/\s+/).slice(1)
119 const [a, b] = parts.map(Number)
120 const ok = (n: number | undefined): n is number =>
121 n !== undefined && Number.isFinite(n) && n > 0 && n <= 100
122 if (parts.length !== 2 || !ok(a) || !ok(b)) return 'Usage: /usage-gauge warn <a> <b> (percent, e.g. 75 90)'
123 if (a >= b) return 'The first threshold must be below the second.'
124 return { a, b }
125}
126types/index.d.ts 21 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Snapshot = {
3 rateLimits: Limit[]
4 contextPercent?: number
5 costUsd?: number
6}
7export type Reading = { pct: number; t: number }
8export type Settings = { isOn: boolean; warnA: number; warnB: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'usage-gauge': {
13 snap: Snapshot | null
14 readings: Record<string, Reading[]>
15 turns: number[]
16 prevCost: number | null
17 settings: Settings
18 }
19 }
20}
21