SLOPSHOPPER

usage-status

Shows the model, the context fill and the rate-limit windows with pace markers in one line above the prompt

newbandtimer
v0.2.0MITupdated 2026-10-07mrtnzlml/skills/mods/usage-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-status
› 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 Opus 5.5 app │ ctx 49% │ 5h ▰▰▰▰▱▱▱▱▱▱▱▱ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 app │ ctx 49% │ 5h ▰▰▰▰▱▱▱▱▱▱▱▱ 31%
README

✂️ skills

You asked a question. Claude wrote 300 words. You needed 65.

This plugin makes Claude stop writing after the answer. One skill, always on, and every rule is measured. Here is one question, asked twice with the same model. Both answers are copied word for word from one eval run.

Should we use PostgreSQL or SQLite for a single-user desktop app that stores about 50,000 records?

With the plugin: 65 words.

Use SQLite. It runs inside your app as a library, so users install nothing and manage no server. It stores the whole database in one file, which makes backup and migration simple. For 50,000 records it is fast, and one user means you avoid its main limit, which is many writers at the same time. PostgreSQL makes sense when many clients write over a network.

Use SQLite. For a single-user desktop app with about 50,000 records, it's the clear choice.

Why SQLite fits:

  • Nothing to install or run. It's a library inside your app, and the whole database is one file. With PostgreSQL, every user would need a database server installed, configured, started and upgraded, which is a lot to ask of a desktop app.
  • 50k records is small. SQLite handles millions of rows easily. With sensible indexes, queries at your size will take milliseconds.
  • Backup and migration are simple. Copying the file is a backup. Moving the file moves the data.
  • It's reliable. It supports ACID transactions and is heavily tested. It's used inside browsers, phones and many desktop apps.
  • Support is everywhere. Every major language and ORM supports it, and some platforms include it by default (for example, Python's sqlite3 module).

When PostgreSQL would make sense instead:

  • Many users or processes writing to the same database at once. SQLite allows only one writer at a time, which doesn't matter for a single user.
  • A shared database on a server that several clients connect to.
  • A need for Postgres-specific features, such as advanced JSONB indexing, PostGIS, or detailed roles and permissions.
  • A near-term plan to turn the app into a multi-user web service. Even then, it's usually easier to start with SQLite and migrate later, especially if you use an ORM or query builder.

Tips if you go with SQLite:

  • Turn on WAL mode (PRAGMA journal_mode=WAL;). Reads and writes block each other less, and it's more resilient.
  • Turn on PRAGMA foreign_keys=ON;, since it's off by default.
  • Store the database file in the OS's standard app-data folder, not next to the executable.
  • Add indexes for the columns you filter or sort on.

Both pick SQLite for the same reason. One of them took 235 extra words to say it.

<img src="assets/word-count.svg" alt="Same answer: 300 words without the plugin, 65 words with it" width="520">

Try it

git clone https://github.com/mrtnzlml/skills.git
claude --plugin-dir ./skills

Recommended settings

  • Use medium /effort on Opus 5.5.
  • Turn off auto-memory. (source)

What is inside

One skill, working-rules:

  • Length limits for every kind of output: replies, task reports, reviews, commits, PRs.
  • Plain English for readers whose first language is not English. No idioms, no jargon.
  • Customer data stays out of anything that leaves the session.
  • Small code changes. Adopted from multica-ai/andrej-karpathy-skills, which is based on Andrej Karpathy's observations. That repository declares MIT in its skill frontmatter and has no LICENSE file.

Always on

Claude loads a skill only when it decides the skill fits, and it does not always decide that. So a hook injects the rules into every session with SessionStart and into every subagent with SubagentStart. The price is about 1.2k tokens each time.

See exactly what Claude receives:

bash hooks-handlers/inject-skills.sh | jq -r .hookSpecificOutput.additionalContext

Usage band

A second plugin, usage-status, shows your usage in one row above the prompt: the model, the effort, the folder, the context fill, and the 5-hour and 7-day rate limits.

<img src="assets/usage-band.svg" alt="Claude Code with the usage band above the prompt, with a pace marker in each rate-limit bar" width="894">

The white marker in a bar shows where steady use would be now. The bar is green at or under that pace, yellow up to 20 points ahead, and red when it is more than 20 points ahead or 90% used. If the row does not fit, the rate limits move to a second row.

Install it from the Claude Code prompt:

/plugin install usage-status --marketplace mrtnzlml/skills

The plugin is a mod: TypeScript hooks in mods/usage-status/hooks/register.tsx. CI runs its tests with claude plugin test mods/usage-status.

Testimonials

Thank you for this, the change of the conversation effectiveness is quite drastic. I have to ask for elaborations and explanations quite a bit more as opposed to sifting through bulk. It's like it turned into smug knowitall vs gullible mentor it was previously. It feels more dangerous somehow as its always sure about itself 😄

— Jan Š.

Contributing

Open an issue or a PR. A new rule needs an eval case that shows a difference with and without the plugin, and bash scripts/check.sh must pass.

License

MIT

Source 2 files
hooks/register.tsx 207 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { Header, Limit, Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-status', key: 'usage' } as const, null)
7const header = atom({ plugin: 'usage-status', key: 'header' } as const, null)
8
9// The same 256-color codes as ~/.claude/statusline.py, as hex.
10const MODEL = '#87afff' // 111
11const META = '#585858' // 240
12const VALUE = '#8a8a8a' // 245
13const TRACK = '#303030' // 236
14const SAGE = '#87af87' // 108
15const SAND = '#d7af5f' // 179
16const CLAY = '#d75f5f' // 167
17
18const MARK = '#e4e4e4' // 254
19
20const BAR_WIDTH = 12
21const FILLED = '▰'
22const EMPTY = '▱'
23const MARKER = '┃'
24const SEPARATOR = '  │  '
25const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
26const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
27const EFFORT: Record<string, string> = { medium: 'med' }
28
29type Segment = { text: string; color: string }
30
31export function levelColor(pct: number): string {
32  if (pct >= 90) return CLAY
33  if (pct >= 70) return SAND
34  return SAGE
35}
36
37export function countdown(resetsAt: string | undefined, now: number): string | undefined {
38  if (!resetsAt) return undefined
39  const secs = Math.floor((Date.parse(resetsAt) - now) / 1000)
40  if (Number.isNaN(secs)) return undefined
41  if (secs <= 0) return 'now'
42  const days = Math.floor(secs / 86400)
43  const hours = Math.floor((secs % 86400) / 3600)
44  const mins = Math.floor((secs % 3600) / 60)
45  if (days) return `${days}d${hours}h`
46  if (hours) return `${hours}h${mins}m`
47  return `${mins}m`
48}
49
50// How much of the window has passed, 0 to 100: where steady use would be now.
51export function paceShare(limit: Limit, now: number): number | undefined {
52  const windowMs = WINDOW_MS[limit.kind]
53  if (windowMs === undefined || !limit.resetsAt) return undefined
54  const left = Date.parse(limit.resetsAt) - now
55  if (Number.isNaN(left)) return undefined
56  return Math.min(100, Math.max(0, ((windowMs - left) / windowMs) * 100))
57}
58
59export function paceColor(pct: number, pace: number | undefined): string {
60  if (pace === undefined || pct >= 90) return levelColor(pct)
61  const ahead = pct - pace
62  if (ahead <= 0) return SAGE
63  if (ahead <= 20) return SAND
64  return CLAY
65}
66
67// The bar's cells, runs of one kind joined; the marker takes the cell of the pace.
68export function barSegments(pct: number, pace: number | undefined): Segment[] {
69  const filled = Math.min(BAR_WIDTH, Math.max(0, Math.round((pct / 100) * BAR_WIDTH)))
70  const marker = pace === undefined ? -1 : Math.min(BAR_WIDTH - 1, Math.floor((pace / 100) * BAR_WIDTH))
71  const color = paceColor(pct, pace)
72  const segments: Segment[] = []
73  for (let i = 0; i < BAR_WIDTH; i++) {
74    const cell =
75      i === marker ? { text: MARKER, color: MARK } : i < filled ? { text: FILLED, color } : { text: EMPTY, color: TRACK }
76    const last = segments.at(-1)
77    if (last && last.color === cell.color && cell.text !== MARKER && !last.text.includes(MARKER)) {
78      last.text += cell.text
79    } else {
80      segments.push(cell)
81    }
82  }
83  return segments
84}
85
86function limitSegments(limit: Limit, now: number): Segment[] {
87  const left = countdown(limit.resetsAt, now)
88  return [
89    { text: (LABELS[limit.kind] ?? limit.kind) + ' ', color: META },
90    ...barSegments(limit.percentUsed, paceShare(limit, now)),
91    { text: ` ${Math.round(limit.percentUsed)}%`, color: VALUE },
92    ...(left === undefined ? [] : [{ text: ' ' + left, color: META }]),
93  ]
94}
95
96function width(segments: Segment[]): number {
97  return segments.reduce((sum, s) => sum + [...s.text].length, 0)
98}
99
100// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5
101export function displayName(model: string): string {
102  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/.exec(model)
103  if (!m) return model
104  const [, family = '', major, minor, long] = m
105  const version = minor === undefined ? major : `${major}.${minor}`
106  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version}${long ? ' 1M' : ''}`
107}
108
109function basename(path: string): string {
110  return path.replace(/\/+$/, '').split('/').pop() ?? path
111}
112
113function toUsage(context: SessionContextUsage, limits: SessionRateLimit[]): Usage {
114  return {
115    contextPercent: context.percent,
116    limits: limits
117      .filter(l => l.kind in LABELS)
118      .map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
119  }
120}
121
122async function readHeader($: EngineInterface, effort: string | undefined): Promise<Header> {
123  const model = await $.session.model()
124  const cwd = await $.session.cwd()
125  const rows = await $.config.list()
126  const isThinking = rows.some(row => row.key === 'thinking' && row.value === true)
127  return { model, cwd, effort, isThinking }
128}
129
130export const register: Register = on => {
131  on('session.start', async ($, e, next) => {
132    const result = await next(e)
133    const current = await readHeader($, undefined)
134    await update($, header, () => current)
135    const now = await $.session.usage()
136    await update($, usage, () => toUsage(now.context, now.rateLimits))
137    // Countdowns move with the clock, not with the session.
138    $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
139
140    return result
141  })
142
143  on('session.measure', async ($, e, next) => {
144    await update($, usage, () => toUsage(e.context, e.rateLimits))
145
146    return next(e)
147  })
148
149  on('turn.step', async function* ($, e, next) {
150    if (e.agentId === undefined) {
151      const effort = e.effort === undefined ? undefined : String(e.effort)
152      const current = await readHeader($, effort)
153      await update($, header, () => current)
154    }
155
156    return yield* next(e)
157  })
158
159  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160    if (e.props.hasSurvey) return next(e)
161    const top = await read($, header)
162    if (top === null) return next(e)
163    const measured = await read($, usage)
164    const ctx = measured?.contextPercent
165    const now = await $.clock.now()
166    const { Box, Text } = $.ui.resolve(e)
167
168    const meta = [
169      ...(top.effort === undefined ? [] : [EFFORT[top.effort] ?? top.effort]),
170      ...(top.isThinking ? ['think'] : []),
171    ]
172    const head: Segment[] = [
173      { text: displayName(top.model), color: MODEL },
174      ...(meta.length ? [{ text: ' · ' + meta.join(' · '), color: META }] : []),
175      { text: '  ' + basename(top.cwd), color: VALUE },
176      ...(ctx === undefined
177        ? []
178        : [
179            { text: SEPARATOR + 'ctx ', color: META },
180            { text: `${ctx}%`, color: levelColor(ctx) },
181          ]),
182    ]
183    const limits = (measured?.limits ?? []).flatMap((limit, i) => [
184      ...(i === 0 ? [] : [{ text: SEPARATOR, color: META }]),
185      ...limitSegments(limit, now),
186    ])
187
188    const fits = limits.length === 0 || width(head) + SEPARATOR.length + width(limits) <= e.props.bodyColumns
189    const rows = fits
190      ? [[...head, ...(limits.length ? [{ text: SEPARATOR, color: META }, ...limits] : [])]]
191      : [head, limits]
192
193    return (
194      // One empty row between the transcript and the band.
195      <Box flexDirection="column" marginTop={1}>
196        {rows.map((row, i) => (
197          <Text key={`row${i}`}>
198            {row.map(segment => (
199              <Text color={segment.color}>{segment.text}</Text>
200            ))}
201          </Text>
202        ))}
203      </Box>
204    )
205  })
206}
207
types/index.d.ts 12 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = { contextPercent?: number; limits: Limit[] }
4
5export type Header = { model: string; cwd: string; effort?: string; isThinking: boolean }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'usage-status': { usage: Usage | null; header: Header | null }
10  }
11}
12