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

My Claude Code plugins, in one marketplace.
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
| Plugin | What it does | Needs |
|---|---|---|
status-bar | A 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. |
python | Three 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 |
diagrams | improved-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-english | Shows 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 |
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.
hooks/register.tsx 277 lines1import { 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}
277types/index.d.ts 22 lines1export 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