SLOPSHOPPER

hud

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…

newbandcommandtoasttimer
v0.6.7no licenseupdated 2026-10-09oleksandrkodua/qa-mods/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hud ⎿ hud: Ліміти: ⎿ hud: 5 год 31% ⎿ hud: Витрати сесії: $0.42 ⎿ hud: Кеш: ≈ 59:59 (оцінка: остання відповідь + 60 хв) Контекст 49% · 5г 31% [ ⏱ ] · кеш 59:59 [ Handoff ] [ Compact ] [ Clear ] [ Прогрес ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Контекст 49% · 5г 31% [ ⏱ ] · кеш 59:59 [ Handoff ] [ Compact ] [ Clear ] [ Прогрес ] ⟨Claude Code's own drawing⟩
README

qa-mods

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.

Install

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.

Plugins

PluginWhat it does
sandbox-guardDenies writes to protected Claude config paths and hands back a Terminal command
remote-gateAsks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out
blast-radiusShows what a risky Bash command will touch before it runs
retry-analyzerDetects repeated identical failing tool calls and tells Claude to change strategy
secret-redactorMasks tokens and keys in tool output
evidence-saverSaves screenshots from tool results to a folder you pick
replay-theater/replay: step through the session's file edits one by one
verification-guardChecks claims like "tests pass" against tool calls that actually ran
handoffOffers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt
notifyA toast and a macOS notification when a long command or turn finishes
quick-actionsCompact and Clear buttons, each asks to confirm first
hudContext 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-ukFork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps
plan-progressFork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods

Tests

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.

License

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).

Source 3 files
hooks/register.tsx 241 lines
1import { 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}
241
hooks/format.ts 100 lines
1import 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}
100
types/index.d.ts 21 lines
1export 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