One line above the prompt: context fill, rate-limit windows, a cache chip (кеш 59:48, m:ss) while the prompt cache is warm, and the buttons Handoff, Compact…

Fourteen Claude Code mods, packaged as one plugin marketplace (qa-mods). Made for a manual QA workflow: guard rails around risky commands, evidence for what was tested, and a status band above the prompt.
Run in your own Terminal (a sandboxed Claude session cannot write ~/.claude).
claude plugin marketplace add oleksandrkodua/qa-mods
claude plugin install hud@qa-mods
Replace hud with any plugin from the table.
To install everything from a local clone, or to set up a fresh machine, see INSTALL.md and install-pack.sh.
| Plugin | What it does |
|---|---|
sandbox-guard | Denies writes to protected Claude config paths and hands back a Terminal command |
remote-gate | Asks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out |
blast-radius | Shows what a risky Bash command will touch before it runs |
retry-analyzer | Detects repeated identical failing tool calls and tells Claude to change strategy |
secret-redactor | Masks tokens and keys in tool output |
evidence-saver | Saves screenshots from tool results to a folder you pick |
replay-theater | /replay: step through the session's file edits one by one |
verification-guard | Checks claims like "tests pass" against tool calls that actually ran |
handoff | Offers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt |
notify | A toast and a macOS notification when a long command or turn finishes |
quick-actions | Compact and Clear buttons, each asks to confirm first |
hud | Context fill and rate-limit windows above the prompt, a кеш 59:48 chip (m:ss) while the prompt cache is warm, five buttons, and /hud with the figures as text |
next-steps-uk | Fork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps |
plan-progress | Fork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods |
claude plugin test hud # from the repo root
cd hud && node --test tests/format.test.mjs tests/handoff-prompt.test.mjs
A bare node --test also picks up register.test.ts, which only runs under claude plugin test.
No license file for the original mods yet. The two forks keep their upstream MIT notices: next-steps-uk/NOTICE.md and plan-progress/NOTICE.md (plus plan-progress/LICENSE.upstream).
hooks/register.tsx 241 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Limit } from '../types'
5
6import { bandLabel, cacheChip, cacheLeft, isReset, limitLabel, report, tone, untilReset } from './format'
7
8const limits = atom({ plugin: 'hud', key: 'limits' } as const, [])
9const usd = atom({ plugin: 'hud', key: 'usd' } as const, null)
10const stale = atom({ plugin: 'hud', key: 'stale' } as const, false)
11const lastReplyAt = atom({ plugin: 'hud', key: 'lastReplyAt' } as const, 0)
12const tick = atom({ plugin: 'hud', key: 'tick' } as const, 0)
13const ctx = atom({ plugin: 'hud', key: 'ctx' } as const, 0)
14const showTimes = atom({ plugin: 'hud', key: 'showTimes' } as const, false)
15
16const ASK: Record<string, string> = {
17 compact: 'Стиснути розмову? Її буде замінено підсумком, щоб звільнити контекст; подробиці втрачаються.',
18 clear: 'Очистити всю розмову? Почнеться з порожнього контексту, це неможливо скасувати.',
19}
20const CHOICES = ['Підтвердити', 'Скасувати']
21
22// The same text as HANDOFF_PROMPT in handoff/hooks/logic.ts (a test keeps the two equal): the button fills the
23// box itself, so no command runs and the chat gets no `/handoff` bubble.
24export const HANDOFF_PROMPT = `Підготуй передачу контексту в новий чат. Нічого не коміть і не деплой.
25
261. Онови (або створи в корені проєкту) HANDOFF.md і CONTEXT.md:
27 - HANDOFF.md: що зроблено в цій сесії, що відкрито і що перевірити наступним кроком, що НЕ відкочувати.
28 - CONTEXT.md: рішення і причини, додай записи поруч з наявними, нічого не стирай.
29 - Не вигадуй: чого не підтвердив, познач як «не перевірено» і напиши, де шукав.
302. Покажи diff обох файлів.
313. Наприкінці дай короткий промпт для нового чату: «Спершу прочитай HANDOFF.md, потім CONTEXT.md», плюс один рядок, з якого кроку продовжити.`
32
33const COLOR = { ok: '#30A46C', warn: '#E09A1E', hot: '#FF1F1F' } as const
34
35/**
36 * HUD: one line above the prompt: context fill, rate-limit windows and a `кеш 59:48` chip (m:ss, ticking every second) while the prompt-cache
37 * countdown runs (cost is left out so the five buttons fit a narrow window; `/hud` still reports it), then the buttons (handoff,
38 * Compact, Clear, Progress), and /hud with the figures as text
39 * (VS Code does not draw the band). The Handoff button fills the prompt box itself (no command runs); the Progress
40 * button runs a command another mod registers (`progress`); a missing mod just makes it do nothing. Compact and Clear ask first (confirm /
41 * cancel) and then run the real command: it is done here because the engine refuses `command.run` from inside a
42 * `command.run` hook, so a command of another mod could not do it.
43 * The band stacks with other mods' bands: the hook asks the hooks beneath (`next`) first
44 * and adds its own line above the result.
45 */
46export const register: Register = (on, options) => {
47 const ttl = Number(options?.cacheTtlMinutes) > 0 ? Number(options.cacheTtlMinutes) : 60
48 const warnAt = Number(options?.warnPercent) > 0 ? Number(options.warnPercent) : 80
49 const redAt = Number(options?.redPercent) > 0 ? Number(options.redPercent) : 85
50 const ctxWarn = Number(options?.ctxWarnPercent) > 0 ? Number(options.ctxWarnPercent) : 50
51 const ctxRed = Number(options?.ctxRedPercent) > 0 ? Number(options.ctxRedPercent) : 80
52 const alarmed = new Set<string>()
53 const warned = new Set<string>()
54 let stop: (() => void) | null = null
55 let lastReply = 0
56
57 on('session.start', async ($, e, next) => {
58 // a name already taken must not break the mod
59 await $.command.register({ name: 'hud', description: 'Show rate limits, session cost and the prompt-cache countdown as text' }).catch(() => undefined)
60
61 // The windows belong to the account, not the session: show the last ones seen until a reply
62 // refreshes them, so the band is not empty for the whole first turn.
63 try {
64 const now = await $.clock.now()
65 // no reading yet (or the call failing) is the same as an empty one: fall back to the saved windows
66 const fresh = (await $.session.usage().catch(() => ({ rateLimits: [] as Limit[] }))).rateLimits
67
68 if (fresh.length > 0) {
69 await update($, limits, () => fresh.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })))
70 } else {
71 const saved = (await $.store.get('limits')) as { savedAt: number; limits: Limit[] } | undefined
72 const usable = (saved?.limits ?? []).filter(l => (l.resetsAt ? Date.parse(l.resetsAt) > now : now - (saved?.savedAt ?? 0) < 6 * 3600000))
73
74 if (usable.length > 0) {
75 await update($, limits, () => usable)
76 await update($, stale, () => true)
77 }
78 }
79 } catch {
80 // a missing reading only leaves the band empty until the first reply
81 }
82
83 return next(e)
84 })
85
86 on('session.measure', async ($, e, next) => {
87 const now = await $.clock.now()
88
89 // an empty reading (no reply yet, or not a subscription) must not wipe the saved windows
90 if (e.rateLimits.length > 0) {
91 const fresh = e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
92
93 await update($, limits, () => fresh)
94 await update($, stale, () => false)
95 await $.store.set('limits', { savedAt: now, limits: fresh }).catch(() => undefined)
96 }
97 await update($, usd, () => e.cost?.usd ?? null)
98 await update($, ctx, () => Math.round(e.context.percent ?? 0))
99
100 // a turn finished (context or cost moved): that response refreshed the prompt cache
101 if (e.changed.includes('context') || e.changed.includes('cost')) {
102 lastReply = now
103 await update($, lastReplyAt, () => now)
104 }
105
106 for (const l of e.rateLimits) {
107 if (l.percentUsed >= warnAt && !warned.has(l.kind)) {
108 warned.add(l.kind)
109 $.ui.toast(`HUD: ліміт ${limitLabel(l.kind)} використано на ${Math.round(l.percentUsed)}%`)
110 } else if (l.percentUsed < warnAt) {
111 warned.delete(l.kind)
112 }
113
114 if (l.percentUsed >= redAt && !alarmed.has(l.kind)) {
115 alarmed.add(l.kind)
116 $.ui.toast(`HUD: ліміт ${limitLabel(l.kind)} майже вичерпано, ${Math.round(l.percentUsed)}%`)
117 } else if (l.percentUsed < redAt) {
118 alarmed.delete(l.kind)
119 }
120 }
121
122 // one-second redraw only while a cache countdown is running
123 if (stop === null && lastReply > 0) {
124 stop = $.clock.every(1000, () => {
125 void (async () => {
126 await update($, tick, n => n + 1)
127
128 if (cacheLeft(lastReply, ttl, await $.clock.now()) === 0) {
129 stop?.()
130 stop = null
131 }
132 })()
133 })
134 }
135
136 return next(e)
137 })
138
139 on('command.run', { command: 'hud' }, async $ => ({
140 text: report({
141 limits: await read($, limits),
142 usd: await read($, usd),
143 lastReplyAt: await read($, lastReplyAt),
144 ttlMinutes: ttl,
145 now: await $.clock.now(),
146 warnAt,
147 redAt,
148 stale: await read($, stale),
149 }),
150 }))
151
152 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
153 const below = await next(e)
154 const list = await read($, limits)
155 const fill = await read($, ctx)
156 const times = await read($, showTimes)
157
158 if (e.props.hasSurvey) return below
159
160 await read($, tick)
161
162 const now = await $.clock.now()
163 const { Box, Text, Button } = $.ui.resolve(e)
164 const run = (command: string) => () => void $.command.run({ command }).catch(() => undefined)
165 // fills the box (after a draft the person already typed, instead of over it); where it cannot be filled, copies the text
166 const fillHandoff = () =>
167 void (async () => {
168 const draft = await $.prompt.read().catch(() => ({ text: '' }))
169 const hasDraft = String(draft?.text ?? '').trim() !== ''
170 const filled = await $.prompt
171 .fill({ text: hasDraft ? `\n\n${HANDOFF_PROMPT}` : HANDOFF_PROMPT, mode: hasDraft ? 'append' : 'replace' })
172 .catch(() => ({ isFilled: false }))
173
174 if (filled.isFilled) {
175 $.ui.toast(hasDraft ? 'Промпт додано після твого тексту в полі вводу. Перевір і натисни Enter.' : 'Промпт у полі вводу. Перевір і натисни Enter.')
176
177 return
178 }
179
180 const copied = await $.ui.copy({ text: HANDOFF_PROMPT }).catch(() => ({ isCopied: false }))
181
182 $.ui.toast(copied.isCopied ? 'Поле вводу зараз не заповнити: промпт скопійовано, встав його сам.' : 'Поле вводу зараз не заповнити.')
183 })()
184 const confirmRun = (command: 'compact' | 'clear') => () =>
185 void (async () => {
186 const answer = await $.ui.ask(ASK[command]!, CHOICES).catch(() => CHOICES[1])
187
188 if (answer === CHOICES[0]) await $.command.run({ command }).catch(() => undefined)
189 else $.ui.toast(`${command === 'compact' ? 'Стиснення' : 'Очищення'} скасовано: нічого не змінено.`)
190 })()
191 const dot = (k: string) => (
192 <Text key={`dot-${k}`} dimColor>
193 ·
194 </Text>
195 )
196
197 const chip = cacheChip(await read($, lastReplyAt), ttl, now)
198 const parts = [
199 fill >= ctxWarn ? (
200 <Text key="ctx" color={fill >= ctxRed ? COLOR.hot : COLOR.warn}>{`Контекст ${fill}%`}</Text>
201 ) : (
202 <Text key="ctx" dimColor>{`Контекст ${fill}%`}</Text>
203 ),
204 ...(list.length === 0
205 ? []
206 : [
207 <Box key="limits" flexDirection="row" alignItems="center" gap={1}>
208 {list.flatMap((l, i) => [
209 ...(i > 0 ? [dot(`l${i}`)] : []),
210 isReset(l.resetsAt, now) ? (
211 <Text key={`limit-${l.kind}`} dimColor>{`${bandLabel(l.kind)} скинуто`}</Text>
212 ) : (
213 <Text key={`limit-${l.kind}`}>
214 <Text dimColor>{`${bandLabel(l.kind)} `}</Text>
215 <Text color={COLOR[tone(l.percentUsed, warnAt, redAt)]} bold={l.percentUsed >= redAt}>
216 {times ? untilReset(l.resetsAt, now) || '—' : `${Math.round(l.percentUsed)}%`}
217 </Text>
218 </Text>
219 ),
220 ])}
221 <Button key="b-times" label={times ? '%' : '⏱'} onPress={() => void update($, showTimes, v => !v)} />
222 </Box>,
223 ]),
224 ...(chip ? [<Text key="cache" dimColor>{chip}</Text>] : []),
225 ]
226
227 return (
228 <Box flexDirection="column">
229 <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
230 {parts.flatMap((p, i) => (i === 0 ? [p] : [dot(String(i)), p]))}
231 <Button key="b-handoff" label="Handoff" onPress={fillHandoff} />
232 <Button key="b-compact" label="Compact" onPress={confirmRun('compact')} />
233 <Button key="b-clear" label="Clear" onPress={confirmRun('clear')} />
234 <Button key="b-progress" label="Прогрес" onPress={run('progress')} />
235 </Box>
236 {below}
237 </Box>
238 )
239 })
240}
241hooks/format.ts 100 lines1import type { Limit } from '../types'
2
3
4const LABEL: Record<string, string> = { five_hour: '5 год', seven_day: '7 дн', spend_limit: 'ліміт витрат' }
5
6export const limitLabel = (kind: string) => LABEL[kind] ?? kind
7
8/** The band's one-line labels: 5г, 7д. */
9export const bandLabel = (kind: string) => ({ five_hour: '5г', seven_day: '7д' })[kind] ?? limitLabel(kind)
10
11/** `m:ss` for under an hour, `h:mm:ss` from an hour up; never negative. */
12export function countdown(ms: number): string {
13 const total = Math.max(0, Math.floor(ms / 1000))
14 const h = Math.floor(total / 3600)
15 const m = Math.floor((total % 3600) / 60)
16 const s = total % 60
17 const two = (n: number) => String(n).padStart(2, '0')
18
19 return h > 0 ? `${h}:${two(m)}:${two(s)}` : `${m}:${two(s)}`
20}
21
22/** "1г 07х" style time until a reset; empty when unknown or already passed. */
23export function untilReset(resetsAt: string | undefined, now: number): string {
24 if (!resetsAt) return ''
25
26 const ms = Date.parse(resetsAt) - now
27
28 if (!Number.isFinite(ms) || ms <= 0) return ''
29
30 const mins = Math.round(ms / 60000)
31 const d = Math.floor(mins / 1440)
32 const h = Math.floor((mins % 1440) / 60)
33 const m = mins % 60
34
35 if (d > 0) return `${d}д ${h}г`
36
37 return h > 0 ? `${h}г ${String(m).padStart(2, '0')}х` : `${m}х`
38}
39
40/** True when the window's reset time has passed: the saved percent belongs to a window that no longer exists. */
41export function isReset(resetsAt: string | undefined, now: number): boolean {
42 if (!resetsAt) return false
43
44 const ms = Date.parse(resetsAt)
45
46 return Number.isFinite(ms) && ms <= now
47}
48
49export type Tone = 'ok' | 'warn' | 'hot'
50
51export const tone = (percent: number, warnAt: number, redAt = 85): Tone => (percent >= redAt ? 'hot' : percent >= warnAt ? 'warn' : 'ok')
52
53export const money = (usd: number) => `$${usd.toFixed(2)}`
54
55/** Cache time left: the last reply plus the TTL. null before the first reply. */
56export function cacheLeft(lastReplyAt: number, ttlMinutes: number, now: number): number | null {
57 if (!lastReplyAt) return null
58
59 return Math.max(0, lastReplyAt + ttlMinutes * 60000 - now)
60}
61
62/** The band's cache chip: "кеш 59:48", minutes:seconds counting down; empty before the first reply and once it has expired. */
63export function cacheChip(lastReplyAt: number, ttlMinutes: number, now: number): string {
64 const left = cacheLeft(lastReplyAt, ttlMinutes, now)
65
66 const total = Math.floor((left ?? 0) / 1000)
67
68 if (!total) return ''
69
70 return `кеш ${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
71}
72
73/** The same figures as plain text, for /hud (the only place VS Code can show them). */
74export function report(args: { limits: Limit[]; usd: number | null; lastReplyAt: number; ttlMinutes: number; now: number; warnAt: number; redAt?: number; stale?: boolean }): string {
75 const { limits, usd, lastReplyAt, ttlMinutes, now, warnAt, redAt = 85, stale } = args
76 const lines = [stale ? 'Ліміти (збережені з минулої сесії, оновляться після першої відповіді):' : 'Ліміти:']
77
78 if (limits.length === 0) lines.push(' даних немає (їх віддає лише підписка, і лише після першої відповіді)')
79
80 for (const l of limits) {
81 if (isReset(l.resetsAt, now)) {
82 lines.push(` ${limitLabel(l.kind).padEnd(8)} скинуто (було ${Math.round(l.percentUsed)}%, нове значення прийде з наступною відповіддю)`)
83 continue
84 }
85
86 const reset = untilReset(l.resetsAt, now)
87 const flag = l.percentUsed >= redAt ? ` ‼ понад ${redAt}%` : l.percentUsed >= warnAt ? ` ⚠ понад ${warnAt}%` : ''
88
89 lines.push(` ${limitLabel(l.kind).padEnd(8)} ${String(Math.round(l.percentUsed)).padStart(3)}%${reset ? ` (скидання через ${reset})` : ''}${flag}`)
90 }
91
92 lines.push(`Витрати сесії: ${usd === null ? 'невідомо' : money(usd)}`)
93
94 const left = cacheLeft(lastReplyAt, ttlMinutes, now)
95
96 lines.push(`Кеш: ${left === null ? 'ще немає відповіді' : left === 0 ? 'прострочений' : `≈ ${countdown(left)} (оцінка: остання відповідь + ${ttlMinutes} хв)`}`)
97
98 return lines.join('\n')
99}
100types/index.d.ts 21 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 hud: {
6 limits: Limit[]
7 usd: number | null
8 // true while the limits shown were saved by an earlier session and no reply has refreshed them
9 stale: boolean
10 // epoch ms of the last finished turn; 0 before the first
11 lastReplyAt: number
12 // bumped every second while the cache countdown runs
13 tick: number
14 // context fill, percent, from the last measurement
15 ctx: number
16 // the limit windows show the time to their reset instead of the percent
17 showTimes: boolean
18 }
19 }
20}
21