SLOPSHOPPER

status-bar

Two-row band above the prompt: model, folder, git, cost, timer, context and rate limits

newbandguardtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-bar
› fix the failing auth test and add an audit log call ╭─────────────────╮ │ status-bar │ ⏺ Read(src/auth.ts) │ Claude finished │ ⎿ 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 ⎇ feat/auth-refresh +14/-3 $0.42 30:00 ████░░░░░░ 49% 98k/200k · 5h █░░░░ 31% ↻NaNm ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 📁 app ⎇ feat/auth-refresh +14/-3 $0.42 30:00 ████░░░░░░ 49% 98k/200k · 5h █░░░░ 31% ↻NaNm
README

guille-plugins

My Claude Code plugins, in one marketplace.

Install

claude plugin marketplace add LGuillermoAngaritaG/claude-plugins
claude plugin install status-bar@guille-plugins
claude plugin install python@guille-plugins
claude plugin install diagrams@guille-plugins
claude plugin install claudish-to-english@guille-plugins

To get later changes:

claude plugin marketplace update guille-plugins

Plugins

PluginWhat it doesNeeds
status-barA two-row band above the prompt: model, folder, git branch and diff, cost, timer, context use, and the 5-hour and 7-day limits. It also writes ~/.claude/rate-limit-cache.json so other tools can read your usage, and it notifies you (a toast, plus a desktop notification on macOS) when a turn finishes, when Claude asks a question or needs approval, and when the context passes 85%.Claude Code 2.1.286 or newer. It uses the function-hooks plugin API, which is early access and may change.
pythonThree skills: python-scaffold starts a project with a layered layout, python-development adds code to that layout, python-light-development keeps a small project flat.uv
diagramsimproved-drawio builds draw.io architecture diagrams on a computed grid, renders them, and checks the image before you see it.Python with playwright and Chromium
claudish-to-englishShows a plain-English rewrite of Claude Code messages next to the original. By Mike Gvozdev, MIT licence, moved here from my fork.The Claude Code CLI, logged in

Develop

claude plugin validate .
claude plugin test plugins/status-bar

status-bar, python and diagrams set no version, so every pushed commit is a new release for them. claudish-to-english sets one, so its users get changes only when that number goes up.

Source 2 files
hooks/register.tsx 277 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Snapshot } from '../types'
5
6const snap = atom({ plugin: 'status-bar', key: 'snap' } as const, null)
7
8const ORANGE = '#ff8700'
9const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
10
11// A row is parts two cells apart; a part is groups one cell apart; a group is
12// colored segments with nothing between. A narrow band drops the lowest `keep` first.
13type Seg = readonly [text: string, color?: string]
14export type Part = { keep: number; groups: Seg[][] }
15
16const seg = (text: string, color?: string): Seg => [text, color]
17const part = (keep: number, ...groups: Seg[][]): Part => ({ keep, groups })
18
19export const modelName = (id: string) =>
20  id.replace(
21    /^claude-([a-z])([a-z]*)-(\d+)-(\d{1,2})(-\d{8})?/,
22    (_, first: string, rest: string, major: string, minor: string) =>
23      `${first.toUpperCase()}${rest} ${major}.${minor}`,
24  )
25
26export const fmtTokens = (n: number) => {
27  const short = (v: number) => v.toFixed(1).replace(/\.0$/, '')
28
29  return n >= 1e6 ? `${short(n / 1e6)}M` : n >= 1000 ? `${short(n / 1000)}k` : `${n}`
30}
31
32export const fmtUntil = (seconds: number) => {
33  if (seconds <= 0) return 'now'
34  const d = Math.floor(seconds / 86400)
35  const h = Math.floor((seconds % 86400) / 3600)
36  const m = Math.floor((seconds % 3600) / 60)
37
38  return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : `${m}m`
39}
40
41export const fmtTimer = (seconds: number) => {
42  const h = Math.floor(seconds / 3600)
43  const mm = Math.floor((seconds % 3600) / 60)
44  const ss = String(Math.floor(seconds % 60)).padStart(2, '0')
45
46  return h > 0 ? `${h}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`
47}
48
49export const pctColor = (pct: number) => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green')
50
51export const bar = (pct: number, width: number) => {
52  const filled = Math.max(0, Math.min(width, Math.floor((pct * width) / 100)))
53
54  return ['█'.repeat(filled), '░'.repeat(width - filled)] as const
55}
56
57// ponytail: counts an astral character (the folder icon) as two cells and the rest as one; exact only in a fixed-width font
58const cells = (text: string) =>
59  [...text].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0xffff ? 2 : 1), 0)
60
61const width = (one: Part) =>
62  one.groups.reduce((n, group) => n + 1 + group.reduce((m, [text]) => m + cells(text), 0), -1)
63
64export const fit = (parts: Part[], columns: number): Part[] => {
65  const total = parts.reduce((n, one) => n + 2 + width(one), -2)
66
67  if (total <= columns || parts.length <= 1) return parts
68  const weakest = parts.reduce((a, b) => (b.keep < a.keep ? b : a))
69
70  return fit(
71    parts.filter(one => one !== weakest),
72    columns,
73  )
74}
75
76export const rows = (s: Snapshot): Part[][] => {
77  const [fill, pad] = bar(s.pct, 10)
78  const diff =
79    s.add > 0 || s.del > 0 ? [[seg(`+${s.add}`, 'green'), seg('/', 'gray'), seg(`-${s.del}`, 'red')]] : []
80
81  return [
82    [
83      part(5, [seg(s.model, /opus|1m/i.test(s.model) ? ORANGE : 'cyan')]),
84      part(2, [seg('📁', 'gray')], [seg(s.dir)]),
85      ...(s.branch === null ? [] : [part(3, [seg('⎇', 'gray')], [seg(s.branch, 'magenta')], ...diff)]),
86      part(4, [seg(`$${s.usd.toFixed(2)}`, 'yellow')]),
87      part(1, [seg(fmtTimer((s.now - s.startedAt) / 1000), 'gray')]),
88    ],
89    [
90      part(5, [seg(fill, pctColor(s.pct)), seg(pad, 'gray')]),
91      part(6, [seg(`${s.pct}%`)]),
92      part(2, [seg(`${fmtTokens(Math.floor((s.window * s.pct) / 100))}/${fmtTokens(s.window)}`)]),
93      ...s.limits.map((one, index) => {
94        const [used, free] = bar(one.pct, 5)
95
96        return part(
97          4 - index,
98          [seg(`· ${one.label}`, 'gray')],
99          [seg(used, pctColor(one.pct)), seg(free, 'gray')],
100          [seg(`${one.pct}%`, pctColor(one.pct))],
101          [seg(`↻${fmtUntil((one.resetsAt - s.now) / 1000)}`, 'gray')],
102        )
103      }),
104    ],
105  ]
106}
107
108// A toast in the app plus a desktop notification: terminal-notifier when it is installed
109// (it keeps one notification per group instead of a pile), else macOS's own osascript.
110// On other systems only the toast shows.
111const notify = async ($: EngineInterface, group: string, text: string, sound: string) => {
112  $.ui.toast(text)
113  const dir = (await read($, snap))?.dir ?? ''
114  const isSent = await $.process
115    .run(['terminal-notifier', '-title', 'Claude Code', '-subtitle', dir, '-message', text, '-sound', sound, '-group', `claude-${group}`])
116    .then(
117      ran => ran.exitCode === 0,
118      () => false,
119    )
120
121  if (!isSent) {
122    const script = `display notification ${JSON.stringify(text)} with title "Claude Code" subtitle ${JSON.stringify(dir)} sound name ${JSON.stringify(sound)}`
123    await $.process.run(['osascript', '-e', script]).catch(() => undefined)
124  }
125}
126
127let lastAskAt = 0
128
129// A question dialog can also raise a permission notice: one notification for both.
130const notifyAsk = async ($: EngineInterface, text: string) => {
131  const now = await $.clock.now()
132
133  if (now - lastAskAt < 2000) return
134  lastAskAt = now
135  await notify($, 'ask', text, 'Ping')
136}
137
138let wasFull = false
139
140const refresh = async ($: EngineInterface) => {
141  const [usage, model, cwd, now] = await Promise.all([
142    $.session.usage(),
143    $.session.model(),
144    $.session.cwd(),
145    $.clock.now(),
146  ])
147  const git = (...args: string[]) =>
148    $.process.run(['git', ...args], { cwd }).then(
149      ran => (ran.exitCode === 0 ? ran.stdout.trim() : undefined),
150      () => undefined,
151    )
152  const branch = (await git('branch', '--show-current')) || (await git('rev-parse', '--short', 'HEAD'))
153  const stats = branch ? ((await git('diff', 'HEAD', '--shortstat')) ?? '') : ''
154  const limits: Limit[] = usage.rateLimits.flatMap(one => {
155    const label = LABELS[one.kind]
156
157    return label && one.resetsAt
158      ? [{ label, pct: Math.floor(one.percentUsed), resetsAt: Date.parse(one.resetsAt) }]
159      : []
160  })
161  const next: Snapshot = {
162    model: modelName(model),
163    dir: cwd.split('/').pop() ?? cwd,
164    branch: branch ?? null,
165    add: Number(/(\d+) insertion/.exec(stats)?.[1] ?? 0),
166    del: Number(/(\d+) deletion/.exec(stats)?.[1] ?? 0),
167    usd: usage.cost?.usd ?? 0,
168    now,
169    startedAt: usage.startedAt,
170    pct: Math.floor(usage.context.percent ?? 0),
171    window: usage.context.window,
172    limits,
173  }
174  await update($, snap, () => next)
175
176  const isFull = next.pct >= 85
177
178  if (isFull && !wasFull) void notify($, 'context', `Context is ${next.pct}% full`, 'Ping')
179  wasFull = isFull
180
181  // The file the old status-line.sh wrote; the autonomous-projects usage script reads it.
182  // ponytail: plain write, not write-then-rename ($.fs has no rename); that reader treats a torn read as unknown
183  const five = limits.find(one => one.label === '5h')
184  const home = await $.env.get('HOME')
185
186  if (five && home) {
187    const entry = (one?: Limit) => ({
188      used_percentage: one?.pct ?? null,
189      resets_at: one ? Math.floor(one.resetsAt / 1000) : null,
190    })
191    const cache = {
192      snapshot_at: Math.floor(now / 1000),
193      five_hour: entry(five),
194      seven_day: entry(limits.find(one => one.label === '7d')),
195    }
196    await $.fs
197      .write(`${home}/.claude/rate-limit-cache.json`, `${JSON.stringify(cache)}\n`)
198      .catch(() => undefined)
199  }
200}
201
202let isQueued = false
203
204// One refresh per burst of events, a quarter second later, so a tool's result never waits on git.
205const soon = ($: EngineInterface) => {
206  if (isQueued) return
207  isQueued = true
208  $.clock.after(250, () => {
209    isQueued = false
210    void refresh($)
211  })
212}
213
214export const register: Register = on => {
215  on('session.start', async ($, e, next) => {
216    await refresh($)
217    // ponytail: slow tick only for the timer, the reset countdowns and git changes made outside the session
218    $.clock.every(30_000, () => void refresh($))
219
220    return next(e)
221  })
222
223  on('session.measure', ($, e, next) => {
224    soon($)
225
226    return next(e)
227  })
228
229  on('tool.call', async ($, e, next) => {
230    if (e.tool === 'AskUserQuestion') void notifyAsk($, 'Claude has a question')
231    const ran = await next(e)
232    soon($)
233
234    return ran
235  })
236
237  on('classic.Notification', ($, e, next) => {
238    if (e.notification_type === 'permission_prompt') void notifyAsk($, 'Claude needs your approval')
239
240    return next(e)
241  })
242
243  on('turn.complete', ($, e, next) => {
244    if (e.agentId === undefined && !e.isAborted) {
245      void notify($, 'stop', e.reason === 'error' ? 'Claude stopped on an error' : 'Claude finished', 'Glass')
246    }
247
248    return next(e)
249  })
250
251  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
252    const s = await read($, snap)
253
254    if (e.props.hasSurvey || s === null) {
255      return next(e)
256    }
257
258    const { Box, Text } = $.ui.resolve(e)
259
260    return (
261      <Box flexDirection="column">
262        {rows(s).map(row => (
263          <Box gap={2}>
264            {fit(row, e.props.bodyColumns).map(one => (
265              <Box gap={1}>
266                {one.groups.map(group => (
267                  <Box>{group.map(([text, color]) => h(Text, color ? { color } : null, text))}</Box>
268                ))}
269              </Box>
270            ))}
271          </Box>
272        ))}
273      </Box>
274    )
275  })
276}
277
types/index.d.ts 22 lines
1export type Limit = { label: string; pct: number; resetsAt: number }
2
3export type Snapshot = {
4  model: string
5  dir: string
6  branch: string | null
7  add: number
8  del: number
9  usd: number
10  now: number
11  startedAt: number
12  pct: number
13  window: number
14  limits: Limit[]
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'status-bar': { snap: Snapshot | null }
20  }
21}
22