One plain line above the prompt: context, 5h and weekly limits, live new-token rate, cache countdown and advisor calls; open it for details and click to choose…

A Claude Code mod that puts one plain status line above the prompt:
● Context 12% │ ● 5h 4% │ ● Week 1% │ ✦ Advisor 3 ◆ Cache warm 57:57 · ≈ $0.46 ▾
The band, idle and right after an advisor call:
Opened with /band:
Choose what shows by clicking the chips under ⚙ Settings (here Cost is switched off, so the band shows no dollar figure):
These are illustrations of the band, drawn with sample numbers, not screenshots.
✦ Advisor N): shows once the advisor has been consulted. For a few seconds after a call it reads ✦ Advisor consulted · #3 · 6s./band (or the ▾ button) opens limits, cache, session token totals and an Advisor section with call count, time spent, a 60-minute trail and the last calls. At the bottom, ⚙ Settings opens a chip for every switch; see Settings./band hide and /band show turn the band off and on.Everything on the band can be switched on or off; all of it is on by default.
| Setting | What it controls |
|---|---|
showContext | Context gauge |
show5h | 5-hour limit gauge |
showWeek | Weekly limit gauge |
showAdvisor | Advisor count, the "Advisor consulted" note and the Advisor section in the details |
showCache | Cache countdown on the right |
showCost | Every ≈ $ figure, on the band and in the details |
showRate | Live new-token rate while Claude works (off: the cache countdown shows instead) |
showAlerts | Warnings: context nearly full, a limit running out, the cache about to expire |
detailsNow | Details section "Right now" |
detailsLimits | Details section "Limits" |
detailsCache | Details section "Cache" |
detailsSession | Details section "This session" |
advisorFlashSeconds | Seconds the band says "Advisor consulted" after a call (default 12, 0 turns the note off) |
Click them. Open the details (/band, or the ▾ button) and press ⚙ Settings. Every switch is a chip: ● is on, ○ is off. Click one and it takes effect at once; the advisor-note chip steps through 0 (off), 5, 12, 30 and 60 seconds, and Reset to defaults puts everything back. /band settings opens the details with the settings showing.
Or type them, in any session (this works in the desktop app too):
/band set list every setting and its value
/band set showCost off
/band set advisorFlashSeconds 30
/band reset back to the defaults
Values are on / off for switches and a number of seconds for advisorFlashSeconds. Your choices are saved in the mod's own store, so they are still there in the next session.
If every item on the band is off, the band draws no row at all; /band still opens the details, and /band hide hides everything.
You need a Claude Code build that supports mods (written and tested on 2.1.299). In a terminal session:
/plugin install status-band --marketplace sertacdincel-byte/claude-status-band
Answer y to add the marketplace, then pick a scope. From a shell:
claude plugin install status-band --marketplace sertacdincel-byte/claude-status-band
This repository is private, so GitHub access must be set up on your computer (for example gh auth login, or an SSH key on an account that has been invited to the repository). Restart Claude Code after installing.
Clone the repository, then either run once with
claude --plugin-dir <path-to-clone>
or load it every time by adding the folder to ~/.claude/settings.json (several folders are separated by ;):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:/path/to/claude-status-band"
}
}
The API reports an advisor call's name, start and end, so the band shows the call count, how long each call ran and when. It does not report the advisor's tokens or whether it "reviewed" or "declined". The advisor's cost is in the ≈ $ total; its tokens aren't broken out.
The band updates when the step that carried the call comes back, not while the advisor is running, so "Advisor consulted" is after the fact. The Ctrl+O to read hint appears on the terminal surface only.
claude plugin validate .
claude plugin test .
hooks/register.tsx is the whole mod, hooks/register.test.ts its tests, types/index.d.ts the contract for the values it keeps.
Prompt'un üstünde tek satırlık durum bandı: context, 5 saatlik ve haftalık limit, cache geri sayımı ve advisor çağrı sayısı. Kurulum için yukarıdaki /plugin install satırını kullanın; depo private olduğu için GitHub erişiminiz olmalı. Kurduktan sonra Claude Code'u yeniden başlatın. Detaylar için /band.
hooks/register.tsx 981 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepServerToolUse, TurnUsage } from 'claude-code'
3
4import type { Advisor, Ctx, Limit, Limits, Prefs, Sample, Step, Totals, Ttl, Turn, Warm } from '../types'
5
6const P = 'status-band'
7const COMMAND = 'band'
8const SEC = 1000
9const MIN = 60 * SEC
10const TTL_MS: Record<Ttl, number> = { '5m': 5 * MIN, '1h': 60 * MIN }
11// A cache write over a cache read: 1h writes bill 2x base input, 5m 1.25x, reads 0.1x.
12const WRITE_WEIGHT: Record<Ttl, number> = { '5m': 1.25, '1h': 2 }
13const KEEP = 15 * MIN
14// How many advisor calls it remembers.
15const ADVISOR_KEEP = 50
16const ADVISOR_SPAN = 60 * MIN
17
18const C = { ok: '#30A46C', warn: '#E09A1E', bad: '#E5484D', live: '#3B82F6', pro: '#8E4EC6', track: '#8080804D' }
19type Level = 'ok' | 'warn' | 'bad'
20
21const ZERO: Totals = { sent: 0, wrote: 0, reread: 0 }
22const NO_LIMITS: Limits = { context: null, rateLimits: [], usd: null }
23const NO_ADVISOR: Advisor = { calls: [], count: 0, firstAt: null }
24
25export const DEFAULT_PREFS: Prefs = {
26 showContext: true,
27 show5h: true,
28 showWeek: true,
29 showAdvisor: true,
30 showCache: true,
31 showCost: true,
32 showRate: true,
33 showAlerts: true,
34 detailsNow: true,
35 detailsLimits: true,
36 detailsCache: true,
37 detailsSession: true,
38 advisorFlashSeconds: 12,
39}
40
41const open = atom({ plugin: 'status-band', key: 'open' } as const, false)
42const hidden = atom({ plugin: 'status-band', key: 'hidden' } as const, false)
43const now = atom({ plugin: 'status-band', key: 'now' } as const, 0)
44const limits = atom({ plugin: 'status-band', key: 'limits' } as const, NO_LIMITS)
45const steps = atom({ plugin: 'status-band', key: 'steps' } as const, [])
46const totals = atom({ plugin: 'status-band', key: 'totals' } as const, ZERO)
47const turn = atom({ plugin: 'status-band', key: 'turn' } as const, null)
48const warm = atom({ plugin: 'status-band', key: 'warm' } as const, null)
49const ttlAtom = atom({ plugin: 'status-band', key: 'ttl' } as const, '1h')
50const history = atom({ plugin: 'status-band', key: 'history' } as const, {})
51const advisor = atom({ plugin: 'status-band', key: 'advisor' } as const, NO_ADVISOR)
52const prefs = atom({ plugin: 'status-band', key: 'prefs' } as const, DEFAULT_PREFS)
53const settingsOpen = atom({ plugin: 'status-band', key: 'settingsOpen' } as const, false)
54
55// ---------- arithmetic ----------
56
57export const level = (p: number | undefined): Level => (p === undefined ? 'ok' : p >= 90 ? 'bad' : p >= 70 ? 'warn' : 'ok')
58
59export const fmtTok = (n: number): string =>
60 n >= 10_000_000
61 ? `${Math.round(n / 1_000_000)}M`
62 : n >= 1_000_000
63 ? `${(n / 1_000_000).toFixed(1)}M`
64 : n >= 10_000
65 ? `${Math.round(n / 1000)}k`
66 : n >= 1000
67 ? `${(n / 1000).toFixed(1)}k`
68 : String(Math.round(n))
69
70export const fmtDur = (ms: number): string => {
71 const s = Math.max(0, Math.round(ms / SEC))
72 if (s < 60) return `${s}s`
73 const m = Math.floor(s / 60)
74 if (m < 60) return `${m}m`
75 const h = Math.floor(m / 60)
76 if (h < 24) return `${h}h ${m % 60}m`
77 return `${Math.floor(h / 24)}d ${h % 24}h`
78}
79
80const clockText = (ms: number): string => {
81 const s = Math.max(0, Math.ceil(ms / SEC))
82 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
83}
84
85const usd = (n: number): string => `$${n.toFixed(2)}`
86
87const newTokens = (u: TurnUsage): number => u.input_tokens + u.cache_creation_input_tokens + u.output_tokens
88
89/** New tokens per minute, averaged over the last two minutes. */
90export const rateAt = (list: Step[], t: number): number =>
91 list.filter(s => s.at > t - 2 * MIN).reduce((a, s) => a + s.n, 0) / 2
92
93/** Per-minute sums for the last `n` minutes, oldest first. */
94export const buckets = (list: Step[], t: number, n: number): number[] => {
95 const out = new Array<number>(n).fill(0)
96 for (const s of list) {
97 const ago = Math.floor((t - s.at) / MIN)
98 if (ago >= 0 && ago < n) out[n - 1 - ago] = (out[n - 1 - ago] ?? 0) + s.n
99 }
100 return out
101}
102
103const SPARK = '▁▂▃▄▅▆▇█'
104export const spark = (values: number[]): string => {
105 const max = Math.max(1, ...values)
106 return values.map(v => SPARK[Math.min(7, Math.round((v / max) * 7))]).join('')
107}
108
109/** Which of `cells` equal slices of the last `span` ms hold at least one of `times`. */
110export const marks = (times: number[], t: number, cells: number, span: number): boolean[] => {
111 const out = new Array<boolean>(cells).fill(false)
112 for (const x of times) {
113 const ago = t - x
114 if (ago < 0 || ago >= span) continue
115 out[cells - 1 - Math.floor((ago / span) * cells)] = true
116 }
117 return out
118}
119
120const advisorShare = (waitedMs: number, sinceMs: number): string => {
121 if (sinceMs <= 0 || waitedMs <= 0) return ''
122 const p = (100 * waitedMs) / sinceMs
123 return `≈ ${p < 1 ? '<1' : Math.min(100, Math.round(p))}% of session`
124}
125
126const findLimit = (l: Limits, kind: string): Limit | undefined => l.rateLimits.find(r => r.kind === kind)
127const resetIn = (l: Limit | undefined, t: number): number | null =>
128 l?.resetsAt ? Date.parse(l.resetsAt) - t : null
129
130/** When a window reaches 100% at the pace of the last half hour; null when it isn't climbing. */
131export const etaFull = (samples: Sample[] | undefined, p: number, t: number): number | null => {
132 const s0 = (samples ?? []).find(s => s.t > t - 30 * MIN)
133 if (!s0 || t - s0.t < 3 * MIN || p <= s0.p) return null
134 return (100 - p) / ((p - s0.p) / (t - s0.t))
135}
136
137const limitName = (kind: string): string => (kind === 'five_hour' ? '5h' : 'Week')
138
139/** The one thing worth saying on the band right now, if anything. */
140export function alertFor(
141 l: Limits,
142 hist: Record<string, Sample[]>,
143 w: Warm | null,
144 ttl: Ttl,
145 t: number,
146 working: boolean,
147): { level: Level; text: string } | null {
148 const tracked = ['five_hour', 'seven_day']
149 .map(k => findLimit(l, k))
150 .filter((x): x is Limit => x !== undefined)
151 .sort((a, b) => b.percentUsed - a.percentUsed)
152 const ctx = l.context?.percent
153
154 for (const lim of tracked) {
155 if (level(lim.percentUsed) !== 'bad') continue
156 const eta = etaFull(hist[lim.kind], lim.percentUsed, t)
157 const reset = resetIn(lim, t)
158 if (eta !== null && (reset === null || eta < reset))
159 return { level: 'bad', text: `${limitName(lim.kind)} limit runs out in ~${fmtDur(eta)} at this rate` }
160 return {
161 level: 'bad',
162 text: `${limitName(lim.kind)} limit ${Math.round(lim.percentUsed)}%${reset !== null ? ` — resets in ${fmtDur(reset)}` : ''}`,
163 }
164 }
165 if (level(ctx) === 'bad') return { level: 'bad', text: 'Context almost full — run /compact' }
166 if (level(ctx) === 'warn') return { level: 'warn', text: 'Context filling up — run /compact' }
167 for (const lim of tracked) {
168 if (level(lim.percentUsed) !== 'warn') continue
169 const reset = resetIn(lim, t)
170 return {
171 level: 'warn',
172 text: `${limitName(lim.kind)} limit ${Math.round(lim.percentUsed)}%${reset !== null ? ` — resets in ${fmtDur(reset)}` : ''}`,
173 }
174 }
175 if (!working && w !== null) {
176 const left = w.at + TTL_MS[ttl] - t
177 const warnAt = ttl === '1h' ? 5 * MIN : MIN
178 if (left > 0 && left <= warnAt)
179 return { level: 'warn', text: `Cache expires in ${fmtDur(left)} — reply now to keep it cheap` }
180 }
181 return null
182}
183
184// ---------- state ----------
185
186async function refreshUsage($: EngineInterface) {
187 try {
188 const u = await $.session.usage()
189 await noteLimits($, u.context, u.rateLimits, u.cost?.usd)
190 } catch {
191 // figures are a nicety; keep the last ones
192 }
193}
194
195async function noteLimits($: EngineInterface, context: Ctx | undefined, rateLimits: Limit[], cost: number | undefined) {
196 const t = await $.clock.now()
197 await update($, limits, prev => ({
198 context: context ? { tokens: context.tokens, window: context.window, percent: context.percent } : prev.context,
199 rateLimits: rateLimits.length ? rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })) : prev.rateLimits,
200 usd: typeof cost === 'number' ? cost : prev.usd,
201 }))
202 if (rateLimits.length)
203 await update($, history, h => {
204 const out: Record<string, Sample[]> = {}
205 for (const r of rateLimits) {
206 const kept = (h[r.kind] ?? []).filter(s => s.t > t - 30 * MIN && s.p <= r.percentUsed)
207 out[r.kind] = [...kept, { t, p: r.percentUsed }].slice(-60)
208 }
209 return out
210 })
211}
212
213async function noteStep($: EngineInterface, at: number, u: TurnUsage) {
214 const n = newTokens(u)
215 await update($, steps, list => [...list.filter(s => s.at > at - KEEP), { at, n }])
216 await update($, totals, x => ({
217 sent: x.sent + u.input_tokens + u.cache_creation_input_tokens,
218 wrote: x.wrote + u.output_tokens,
219 reread: x.reread + u.cache_read_input_tokens,
220 }))
221 await update($, turn, x => (x === null || x.endedAt !== null ? x : { ...x, tokens: x.tokens + n }))
222}
223
224/**
225 * The API runs the advisor inside the request, so a step's result is where it shows up.
226 * Counted once per call id, stamped with when the step arrived, not when the advisor ended.
227 */
228async function noteAdvisor(
229 $: EngineInterface,
230 started: number,
231 arrived: number,
232 uses: readonly TurnStepServerToolUse[] | undefined,
233) {
234 const prev = await read($, advisor)
235 const known = new Set(prev.calls.map(c => c.id))
236 const fresh = (uses ?? []).filter(u => u.name === 'advisor' && !known.has(u.id))
237 if (fresh.length === 0 && prev.firstAt !== null) return
238 await update($, advisor, a => {
239 const seen = new Set(a.calls.map(c => c.id))
240 const add = fresh.filter(u => !seen.has(u.id))
241 return {
242 firstAt: a.firstAt ?? started,
243 count: a.count + add.length,
244 calls: [
245 ...a.calls,
246 ...add.map((u, i) => ({
247 id: u.id,
248 n: a.count + i + 1,
249 at: arrived,
250 ms: u.endedAt === undefined ? null : Math.max(0, u.endedAt - u.startedAt),
251 })),
252 ].slice(-ADVISOR_KEEP),
253 }
254 })
255}
256
257// A main-thread request refreshes the cache and starts its TTL over.
258async function noteMain($: EngineInterface, sentAt: number, u: TurnUsage) {
259 const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
260 if (prompt === 0) return
261 const prev = await read($, warm)
262 // Learn the TTL from a request after a gap only a 1h cache survives.
263 if (prev !== null && prev.model === u.model && prev.tokens > 2000) {
264 const gap = sentAt - prev.at
265 if (gap > 6 * MIN && gap < 55 * MIN) {
266 const served = u.cache_read_input_tokens / prev.tokens
267 if (served >= 0.5) await update($, ttlAtom, () => '1h')
268 else if (served < 0.1) await update($, ttlAtom, () => '5m')
269 }
270 }
271 await update($, warm, () => ({
272 at: sentAt,
273 tokens: prompt + u.output_tokens,
274 hitPct: Math.round((100 * u.cache_read_input_tokens) / prompt),
275 model: u.model,
276 }))
277}
278
279async function resetAll($: EngineInterface) {
280 await update($, steps, () => [])
281 await update($, totals, () => ZERO)
282 await update($, turn, () => null)
283 await update($, warm, () => null)
284 await update($, advisor, () => NO_ADVISOR)
285}
286
287// ---------- settings ----------
288
289const ON_WORDS = ['on', 'true', 'yes', '1', 'show']
290const OFF_WORDS = ['off', 'false', 'no', '0', 'hide']
291
292/** The switches of the Settings section, in the order and under the headings it draws them. */
293export const SWITCHES = {
294 band: [
295 ['showContext', 'Context'],
296 ['show5h', '5h'],
297 ['showWeek', 'Week'],
298 ['showAdvisor', 'Advisor'],
299 ['showCache', 'Cache'],
300 ['showCost', 'Cost'],
301 ['showRate', 'Live rate'],
302 ['showAlerts', 'Warnings'],
303 ],
304 details: [
305 ['detailsNow', 'Right now'],
306 ['detailsLimits', 'Limits'],
307 ['detailsCache', 'Cache'],
308 ['detailsSession', 'This session'],
309 ],
310} as const
311type SwitchKey = (typeof SWITCHES)['band' | 'details'][number][0]
312
313/** The seconds the advisor note cycles through when its chip is pressed. */
314export const FLASH_STEPS = [0, 5, 12, 30, 60]
315
316/** What the store held, kept only where it has the right type; the rest takes the default. */
317export const readPrefs = (raw: unknown): Prefs => {
318 const from = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
319 const out: Record<string, boolean | number> = { ...DEFAULT_PREFS }
320 for (const [k, d] of Object.entries(DEFAULT_PREFS)) {
321 const v = from[k]
322 if (typeof d === 'boolean' && typeof v === 'boolean') out[k] = v
323 if (typeof d === 'number' && typeof v === 'number' && Number.isFinite(v) && v >= 0) out[k] = v
324 }
325 return out as Prefs
326}
327
328const fmtPref = (v: boolean | number): string => (typeof v === 'boolean' ? (v ? 'on' : 'off') : String(v))
329
330/** `/band set <option> <value>` applied to `current`: the new prefs when it fits, and the reply. */
331export const applySet = (current: Prefs, name: string, valueText: string): { next?: Prefs; text: string } => {
332 const key = (Object.keys(DEFAULT_PREFS) as (keyof Prefs)[]).find(k => k.toLowerCase() === name.toLowerCase())
333 if (!key) return { text: `No setting called "${name}". List them with /${COMMAND} set` }
334 const x = valueText.trim().toLowerCase()
335 const isSwitch = typeof DEFAULT_PREFS[key] === 'boolean'
336 const value = isSwitch
337 ? ON_WORDS.includes(x)
338 ? true
339 : OFF_WORDS.includes(x)
340 ? false
341 : undefined
342 : x !== '' && Number.isFinite(Number(x)) && Number(x) >= 0
343 ? Number(x)
344 : undefined
345 if (value === undefined)
346 return { text: `"${valueText}" doesn't fit ${key}; it takes ${isSwitch ? 'on or off' : 'a number of seconds, 0 or more'}.` }
347 return { next: { ...current, [key]: value }, text: `${key} is now ${fmtPref(value)}.` }
348}
349
350export const listPrefs = (p: Prefs): string =>
351 [
352 `Status band settings. Change one with /${COMMAND} set <option> <value>, or click them under ⚙ Settings in /${COMMAND}.`,
353 ...Object.entries(p).map(([k, v]) => ` ${k}: ${fmtPref(v as boolean | number)}`),
354 ].join('\n')
355
356/** Kept in the mod's own store, so it holds across sessions, and mirrored to the atom the band draws from. */
357async function savePrefs($: EngineInterface, next: Prefs) {
358 await update($, prefs, () => next)
359 await $.store.set('prefs', next)
360}
361
362// ---------- the band ----------
363
364export const register: Register = on => {
365 on('session.start', async ($, e, next) => {
366 await $.command.register({
367 name: COMMAND,
368 description: 'Status band: open or close details; settings, set, reset, hide, show',
369 argumentHint: 'settings | set <option> <value> | reset | hide | show',
370 })
371 const stored = readPrefs(await $.store.get('prefs'))
372 await update($, prefs, () => stored)
373 await update($, now, () => 0)
374 $.clock.every(SEC, async () => {
375 if (await read($, hidden)) return
376 const t = await $.clock.now()
377 await update($, now, () => t)
378 })
379 const result = await next(e)
380 await refreshUsage($)
381 return result
382 })
383
384 on('session.measure', async ($, e, next) => {
385 await noteLimits($, e.context, e.rateLimits, e.cost?.usd)
386 return next(e)
387 })
388
389 on('prompt.submit', async ($, e, next) => {
390 const text = String((e as { text?: unknown }).text ?? '')
391 if (!text.startsWith(`/${COMMAND}`)) {
392 const startedAt = await $.clock.now()
393 const l = await read($, limits)
394 await update($, turn, () => ({ startedAt, endedAt: null, tokens: 0, usdAtStart: l.usd }))
395 }
396 return next(e)
397 })
398
399 on('turn.step', async function* ($, e, next) {
400 const at = await $.clock.now()
401 const result = yield* next(e)
402 if (result.usage !== null) {
403 await noteStep($, at, result.usage)
404 if (e.agentId === undefined) await noteMain($, at, result.usage)
405 }
406 await noteAdvisor($, at, await $.clock.now(), result.serverToolUses)
407 return result
408 })
409
410 on('turn.complete', async ($, e, next) => {
411 const result = await next(e)
412 if (e.agentId === undefined) {
413 const t = await $.clock.now()
414 await update($, turn, x => (x === null ? x : { ...x, endedAt: t }))
415 await refreshUsage($)
416 }
417 return result
418 })
419
420 on('classic.SessionStart', async ($, e, next) => {
421 if (e.source === 'clear') await resetAll($)
422 else if (e.source === 'compact') await update($, warm, () => null)
423 else if (e.seconds_since_last_response !== undefined && e.context_tokens) {
424 const at = (await $.clock.now()) - e.seconds_since_last_response * SEC
425 await update($, warm, () => ({ at, tokens: e.context_tokens ?? 0, hitPct: null, model: e.model ?? '' }))
426 }
427 return next(e)
428 })
429
430 on('classic.PostCompact', async ($, e, next) => {
431 await update($, warm, () => null)
432 return next(e)
433 })
434
435 on('classic.PostModelSwitch', async ($, e, next) => {
436 await update($, ttlAtom, () => e.cache_ttl)
437 await update($, warm, () => null)
438 return next(e)
439 })
440
441 on('command.run', { command: COMMAND }, async ($, e) => {
442 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
443 const arg = sub.toLowerCase()
444 if (arg === 'settings') {
445 // Settings live inside the details: open both, or close the settings alone when they're already showing.
446 const showing = (await read($, open)) && (await read($, settingsOpen))
447 await update($, open, () => !showing)
448 await update($, settingsOpen, () => !showing)
449 return { text: showing ? 'Settings closed.' : 'Settings open. Click an item to switch it on or off.' }
450 }
451 if (arg === 'set') {
452 const [name = '', ...words] = rest
453 const current = await read($, prefs)
454 if (name === '' || words.length === 0) return { text: listPrefs(current) }
455 const { next, text } = applySet(current, name, words.join(' '))
456 if (next) await savePrefs($, next)
457 return { text }
458 }
459 if (arg === 'reset') {
460 await savePrefs($, DEFAULT_PREFS)
461 return { text: 'Settings back to the defaults.' }
462 }
463 if (arg === 'hide') {
464 await update($, hidden, () => true)
465 return { text: `Status band hidden. Bring it back with /${COMMAND} show` }
466 }
467 if (arg === 'show') {
468 await update($, hidden, () => false)
469 return { text: 'Status band shown.' }
470 }
471 await update($, open, v => !v)
472 const isOpen = await read($, open)
473 return { text: isOpen ? 'Details open.' : 'Details closed.' }
474 })
475
476 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
477 if (e.props.hasSurvey || (await read($, hidden))) return next(e)
478 const below = await next(e)
479
480 const kit = $.ui.resolve(e)
481 const { Box, Text, Button } = kit
482 const Svg = 'Svg' in kit ? kit.Svg : undefined
483 const desktop = e.surface === 'desktop'
484 const cols = Math.max(40, e.props.bodyColumns)
485 const narrow = !desktop && cols < 80
486
487 const t = Math.max(await read($, now), await $.clock.now())
488 const l = await read($, limits)
489 const list = await read($, steps)
490 const tot = await read($, totals)
491 const tr = await read($, turn)
492 const w = await read($, warm)
493 const ttl = await read($, ttlAtom)
494 const hist = await read($, history)
495 const adv = await read($, advisor)
496 const isOpen = await read($, open)
497 const p = await read($, prefs)
498 const setOpen = await read($, settingsOpen)
499 const working = e.props.isWorking
500
501 const opt = {
502 context: p.showContext,
503 fiveHour: p.show5h,
504 week: p.showWeek,
505 advisor: p.showAdvisor,
506 cache: p.showCache,
507 cost: p.showCost,
508 rate: p.showRate,
509 alerts: p.showAlerts,
510 detNow: p.detailsNow,
511 detLimits: p.detailsLimits,
512 detCache: p.detailsCache,
513 detSession: p.detailsSession,
514 }
515 // 0 turns the "Advisor consulted" note off.
516 const flashMs = p.advisorFlashSeconds * SEC
517
518 const five = findLimit(l, 'five_hour')
519 const week = findLimit(l, 'seven_day')
520 const ctxPct = l.context?.percent
521 const rate = rateAt(list, t)
522 const alert = opt.alerts ? alertFor(l, hist, w, ttl, t, working) : null
523 const cacheLeft = w === null ? null : w.at + TTL_MS[ttl] - t
524
525 const pct = (p: number | undefined) => (p === undefined ? '–' : `${Math.round(p)}%`)
526 const gauge = (key: string, label: string, short: string, p: number | undefined) => (
527 <Text key={key} wrap="truncate">
528 <Text color={C[level(p)]}>● </Text>
529 <Text dimColor>{(narrow ? short : label) + ' '}</Text>
530 <Text bold>{pct(p)}</Text>
531 </Text>
532 )
533 const sep = (key: string) => (
534 <Text key={key} dimColor>
535 {' │ '}
536 </Text>
537 )
538
539 const lastAdv = adv.calls[adv.calls.length - 1]
540 const advFresh = opt.advisor && lastAdv !== undefined && t - lastAdv.at < flashMs ? lastAdv : undefined
541
542 let right
543 if (alert) {
544 right = (
545 <Text color={C[alert.level]} bold wrap="truncate">
546 {(alert.level === 'bad' ? '▲ ' : '△ ') + alert.text}
547 </Text>
548 )
549 } else if (advFresh) {
550 // After the fact: the call is known once its step comes back, not while the advisor runs.
551 right = (
552 <Text wrap="truncate">
553 <Text color={C.pro} bold>
554 {'✦ Advisor consulted'}
555 </Text>
556 <Text dimColor>
557 {` · #${advFresh.n}${advFresh.ms !== null ? ` · ${fmtDur(advFresh.ms)}` : ''}${e.surface === 'terminal' && !narrow ? ' · Ctrl+O to read' : ''}`}
558 </Text>
559 </Text>
560 )
561 } else if (working && opt.rate) {
562 // With the rate off, a working band falls through to the cache countdown.
563 right = (
564 <Text wrap="truncate">
565 <Text color={C.live}>● </Text>
566 <Text bold>{fmtTok(rate)}</Text>
567 <Text dimColor>{narrow ? '/min ' : ' new tok/min '}</Text>
568 <Text color={C.live}>{spark(buckets(list, t, 8))}</Text>
569 {tr && tr.endedAt === null ? <Text dimColor>{` ⏱ ${fmtDur(t - tr.startedAt)}`}</Text> : null}
570 </Text>
571 )
572 } else if (opt.cache && w !== null && cacheLeft !== null) {
573 right = (
574 <Text wrap="truncate">
575 <Text color={cacheLeft > 0 ? C.ok : undefined} dimColor={cacheLeft <= 0}>
576 {cacheLeft > 0 ? '◆ ' : '◇ '}
577 </Text>
578 <Text dimColor>{cacheLeft > 0 ? 'Cache warm ' : 'Cache cold · next step re-sends '}</Text>
579 <Text bold>{cacheLeft > 0 ? clockText(cacheLeft) : fmtTok(w.tokens)}</Text>
580 {opt.cost && l.usd !== null && !narrow ? <Text dimColor>{` · ≈ ${usd(l.usd)}`}</Text> : null}
581 </Text>
582 )
583 } else if (opt.cost && l.usd !== null) {
584 right = <Text dimColor>{`Session ≈ ${usd(l.usd)}`}</Text>
585 }
586
587 // Only the visible items, with a separator between neighbours and never at an end.
588 const left: unknown[] = []
589 const add = (node: unknown) => {
590 if (left.length) left.push(sep(`s${left.length}`))
591 left.push(node)
592 }
593 if (opt.context) add(gauge('g-ctx', 'Context', 'Ctx', ctxPct))
594 if (opt.fiveHour) add(gauge('g-5h', '5h', '5h', five?.percentUsed))
595 if (opt.week) add(gauge('g-wk', 'Week', 'Wk', week?.percentUsed))
596 if (opt.advisor && adv.count > 0)
597 add(
598 <Text key="g-adv" wrap="truncate">
599 <Text color={C.pro}>✦ </Text>
600 <Text dimColor>{narrow ? '' : 'Advisor '}</Text>
601 <Text bold>{String(adv.count)}</Text>
602 </Text>,
603 )
604
605 // Nothing left to say on the band itself: draw no row (the details can still open).
606 const band =
607 left.length === 0 && right === undefined ? null : (
608 <Box key="sb-band" flexDirection="row" alignItems="center">
609 {left as never}
610 <Box flexGrow={1} minWidth={2} />
611 {right ?? null}
612 <Text> </Text>
613 <Button
614 key="sb-toggle"
615 label={isOpen ? '▴' : '▾'}
616 plain
617 dimColor
618 onPress={() => update($, open, v => !v)}
619 />
620 </Box>
621 )
622
623 if (!isOpen) {
624 if (band === null) return below
625 return (
626 <Box flexDirection="column">
627 {below}
628 {band}
629 </Box>
630 )
631 }
632
633 // ---------- details ----------
634
635 const labelW = desktop ? '18%' : 16
636 const rightW = desktop ? '30%' : Math.min(30, Math.max(20, Math.floor(cols * 0.28)))
637 const barCols = Math.max(10, cols - 16 - (typeof rightW === 'number' ? rightW : 0) - 4)
638
639 const bar = (key: string, p: number | undefined, color: string) => {
640 const f = Math.max(0, Math.min(100, p ?? 0)) / 100
641 if (desktop && Svg) {
642 const W = 220
643 const fill = Math.max(f > 0 ? 4 : 0, Math.round(W * f))
644 return (
645 <Svg
646 key={key}
647 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="8" viewBox="0 0 ${W} 8"><rect width="${W}" height="8" rx="4" fill="${C.track}"/><rect width="${fill}" height="8" rx="4" fill="${color}"/></svg>`}
648 alt={`${Math.round(f * 100)}%`}
649 width={W}
650 height={8}
651 />
652 )
653 }
654 const filled = Math.round(barCols * f)
655 return (
656 <Text key={key}>
657 <Text color={color}>{'█'.repeat(filled)}</Text>
658 <Text dimColor>{'░'.repeat(barCols - filled)}</Text>
659 </Text>
660 )
661 }
662
663 const row = (key: string, label: string, middle: unknown, side: string) => (
664 <Box key={key} flexDirection="row" columnGap={1}>
665 <Box width={labelW} flexShrink={0}>
666 <Text wrap="truncate">{label}</Text>
667 </Box>
668 <Box flexGrow={1} flexShrink={1}>
669 {typeof middle === 'string' ? <Text dimColor wrap="truncate">{middle}</Text> : (middle as never)}
670 </Box>
671 <Box width={rightW} flexShrink={0} justifyContent="flex-end">
672 <Text dimColor wrap="truncate">{side}</Text>
673 </Box>
674 </Box>
675 )
676 const section = (key: string, title: string) => (
677 <Box key={key} marginTop={1}>
678 <Text dimColor bold>{title}</Text>
679 </Box>
680 )
681
682 const rows = []
683
684 if (opt.detNow && (tr !== null || list.length)) {
685 const b15 = buckets(list, t, 15)
686 const peak = Math.max(0, ...b15)
687 const sparkW = desktop ? 30 : Math.max(15, Math.min(60, barCols))
688 const per = Math.max(1, Math.floor(sparkW / 15))
689 const wide = spark(b15).split('').map(ch => ch.repeat(per)).join('')
690 rows.push(section('h-now', 'Right now'))
691 rows.push(
692 row(
693 'r-rate',
694 'Live rate',
695 <Text>
696 <Text bold>{fmtTok(rate)}</Text>
697 <Text dimColor> new tokens / min</Text>
698 </Text>,
699 `peak ${fmtTok(peak)}/min`,
700 ),
701 )
702 rows.push(
703 row(
704 'r-spark',
705 'Last 15 min',
706 <Text color={C.live}>{wide}</Text>,
707 tr === null ? '' : tr.endedAt === null ? `running ${fmtDur(t - tr.startedAt)}` : `finished ${fmtDur(t - tr.endedAt)} ago`,
708 ),
709 )
710 if (tr !== null) {
711 const spent = tr.usdAtStart !== null && l.usd !== null ? l.usd - tr.usdAtStart : null
712 rows.push(
713 row(
714 'r-turn',
715 'This turn',
716 <Text>
717 <Text bold>{fmtTok(tr.tokens)}</Text>
718 <Text dimColor> new tokens</Text>
719 </Text>,
720 opt.cost && spent !== null && spent > 0 ? `≈ ${usd(spent)}` : '',
721 ),
722 )
723 }
724 }
725
726 if (opt.detLimits) {
727 rows.push(section('h-lim', 'Limits'))
728 rows.push(
729 row(
730 'r-ctx',
731 'Context',
732 bar('b-ctx', ctxPct, C[level(ctxPct)]),
733 l.context ? `${l.context.tokens !== undefined ? fmtTok(l.context.tokens) : '–'} / ${fmtTok(l.context.window)}` : 'no reading yet',
734 ),
735 )
736 for (const [k, label, lim] of [
737 ['five', '5-hour', five],
738 ['week', 'Weekly', week],
739 ] as const) {
740 const reset = resetIn(lim, t)
741 rows.push(
742 row(
743 `r-${k}`,
744 label,
745 bar(`b-${k}`, lim?.percentUsed, C[level(lim?.percentUsed)]),
746 lim === undefined ? 'no reading yet' : reset !== null ? `resets in ${fmtDur(reset)}` : `${Math.round(lim.percentUsed)}% used`,
747 ),
748 )
749 }
750 }
751
752 if (opt.detCache) {
753 rows.push(section('h-cache', 'Cache'))
754 if (w === null || cacheLeft === null) {
755 rows.push(row('r-cache', 'Status', 'Not warm yet — fills on the next step', `TTL ${ttl}`))
756 } else {
757 const isWarm = cacheLeft > 0
758 rows.push(
759 row(
760 'r-cache',
761 'Status',
762 <Text wrap="truncate">
763 <Text color={isWarm ? C.ok : undefined} bold>{isWarm ? 'Warm' : 'Cold'}</Text>
764 <Text dimColor>
765 {` · ${fmtTok(w.tokens)} ${isWarm ? 'kept' : 'to re-send'}${w.hitPct !== null ? ` · ${w.hitPct}% hit` : ''}`}
766 </Text>
767 </Text>,
768 `TTL ${ttl}`,
769 ),
770 )
771 const leftPct = (100 * Math.max(0, cacheLeft)) / TTL_MS[ttl]
772 const leftLevel: Level = !isWarm || cacheLeft <= (ttl === '1h' ? 5 * MIN : MIN) ? 'warn' : 'ok'
773 rows.push(
774 row(
775 'r-left',
776 'Time left',
777 bar('b-left', leftPct, C[leftLevel]),
778 isWarm ? `${fmtDur(cacheLeft)} · resets each step` : `cold for ${fmtDur(-cacheLeft)}`,
779 ),
780 )
781 rows.push(
782 row(
783 'r-expire',
784 isWarm ? 'If it expires' : 'Next step',
785 `Re-sends ${fmtTok(w.tokens)} at full price`,
786 `~${Math.round(WRITE_WEIGHT[ttl] / 0.1)}× a cached read`,
787 ),
788 )
789 }
790 }
791
792 if (opt.detSession) {
793 rows.push(section('h-sess', 'This session'))
794 rows.push(
795 row(
796 'r-new',
797 'New tokens',
798 <Text wrap="truncate">
799 <Text bold>{fmtTok(tot.sent)}</Text>
800 <Text dimColor> you sent + </Text>
801 <Text bold>{fmtTok(tot.wrote)}</Text>
802 <Text dimColor> Claude wrote</Text>
803 </Text>,
804 'real work',
805 ),
806 )
807 rows.push(row('r-reread', 'Re-reads', `${fmtTok(tot.reread)} · conversation re-sent each step`, 'cheap, from cache'))
808 if (opt.cost && l.usd !== null) {
809 // Share of cost from re-reads, by relative price per token.
810 const weight = tot.sent + 0.1 * tot.reread + 5 * tot.wrote
811 const share = weight > 0 ? (0.1 * tot.reread) / weight : 0
812 rows.push(
813 row(
814 'r-cost',
815 'Cost',
816 <Text>
817 <Text dimColor>≈ </Text>
818 <Text bold>{usd(l.usd)}</Text>
819 <Text dimColor> total</Text>
820 </Text>,
821 share > 0.05 ? `re-reads ≈ ${usd(l.usd * share)}` : '',
822 ),
823 )
824 }
825 }
826
827 if (opt.advisor && adv.count > 0 && lastAdv !== undefined) {
828 const timed = adv.calls.filter(c => c.ms !== null).map(c => c.ms as number)
829 const waited = timed.reduce((a, b) => a + b, 0)
830 const W = desktop ? 30 : Math.max(15, Math.min(40, barCols))
831 const hit = marks(adv.calls.map(c => c.at), t, W, ADVISOR_SPAN)
832 rows.push(section('h-adv', 'Advisor'))
833 rows.push(
834 row(
835 'r-adv-calls',
836 'Calls',
837 <Text wrap="truncate">
838 <Text bold color={C.pro}>
839 {String(adv.count)}
840 </Text>
841 <Text dimColor>{` this session · last ${fmtDur(t - lastAdv.at)} ago`}</Text>
842 </Text>,
843 timed.length ? `avg ${fmtDur(waited / timed.length)}` : '',
844 ),
845 )
846 if (timed.length)
847 rows.push(
848 row(
849 'r-adv-time',
850 'Time waiting',
851 <Text wrap="truncate">
852 <Text bold>{fmtDur(waited)}</Text>
853 <Text dimColor> total in the advisor</Text>
854 </Text>,
855 adv.firstAt === null ? '' : advisorShare(waited, t - adv.firstAt),
856 ),
857 )
858 rows.push(
859 row(
860 'r-adv-track',
861 'Last 60 min',
862 <Text>
863 {hit.map((called, i) =>
864 called ? (
865 <Text key={i} color={C.pro}>
866 ✦
867 </Text>
868 ) : (
869 <Text key={i} dimColor>
870 ▁
871 </Text>
872 ),
873 )}
874 </Text>,
875 `first ${fmtDur(t - (adv.calls[0]?.at ?? t))} ago`,
876 ),
877 )
878 adv.calls
879 .slice(-3)
880 .reverse()
881 .forEach((c, i) =>
882 rows.push(
883 row(
884 `r-adv-${c.id}`,
885 `#${c.n}`,
886 `${fmtDur(t - c.at)} ago${c.ms !== null ? ` · ran ${fmtDur(c.ms)}` : ''}`,
887 i === 0 && e.surface === 'terminal' ? 'Ctrl+O to read' : '',
888 ),
889 ),
890 )
891 rows.push(
892 <Box key="r-adv-note">
893 <Text dimColor>
894 {opt.cost
895 ? "The advisor's cost is in the ≈ $ total; its tokens aren't broken out."
896 : "The advisor's tokens aren't broken out."}
897 </Text>
898 </Box>,
899 )
900 }
901
902 // ---------- settings ----------
903
904 // The details always end with the Settings button, so the box is never empty and the way back is always there.
905 rows.push(
906 <Box key="r-settings" marginTop={1}>
907 <Button
908 key="sb-settings"
909 label={setOpen ? '⚙ Settings ▴' : '⚙ Settings ▾'}
910 plain
911 dimColor
912 onPress={() => update($, settingsOpen, v => !v)}
913 />
914 </Box>,
915 )
916 if (setOpen) {
917 const toggle = async (k: SwitchKey) => {
918 const cur = await read($, prefs)
919 await savePrefs($, { ...cur, [k]: !cur[k] })
920 }
921 const chip = (k: SwitchKey, label: string) => (
922 <Button key={`pref-${k}`} plain onPress={() => toggle(k)}>
923 <Text color={p[k] ? C.ok : undefined} dimColor={!p[k]}>
924 {p[k] ? '● ' : '○ '}
925 </Text>
926 <Text dimColor={!p[k]}>{label}</Text>
927 </Button>
928 )
929 const chips = (key: string, list: readonly (readonly [SwitchKey, string])[]) => (
930 <Box key={key} flexDirection="row" flexWrap="wrap" columnGap={2}>
931 {list.map(([k, label]) => chip(k, label))}
932 </Box>
933 )
934 const flash = p.advisorFlashSeconds
935 const cycleFlash = async () => {
936 const cur = await read($, prefs)
937 const i = FLASH_STEPS.indexOf(cur.advisorFlashSeconds)
938 await savePrefs($, { ...cur, advisorFlashSeconds: FLASH_STEPS[(i + 1) % FLASH_STEPS.length] ?? 0 })
939 }
940 rows.push(
941 <Text key="set-band-h" dimColor>
942 Band
943 </Text>,
944 chips('set-band', SWITCHES.band),
945 <Box key="set-details-h" marginTop={1}>
946 <Text dimColor>Details</Text>
947 </Box>,
948 chips('set-details', SWITCHES.details),
949 <Box key="set-flash-h" marginTop={1}>
950 <Text dimColor>Advisor note</Text>
951 </Box>,
952 <Box key="set-flash" flexDirection="row" columnGap={2}>
953 <Button key="pref-flash" plain onPress={cycleFlash}>
954 <Text color={flash > 0 ? C.ok : undefined} dimColor={flash === 0}>
955 {flash > 0 ? '● ' : '○ '}
956 </Text>
957 <Text dimColor={flash === 0}>{flash > 0 ? `${flash}s ▸` : 'off ▸'}</Text>
958 </Button>
959 <Button key="pref-reset" label="Reset to defaults" plain dimColor onPress={() => savePrefs($, DEFAULT_PREFS)} />
960 </Box>,
961 )
962 }
963
964 return (
965 <Box flexDirection="column">
966 {below}
967 {band}
968 <Box
969 key="sb-detail"
970 flexDirection="column"
971 borderStyle="round"
972 borderDimColor
973 paddingX={1}
974 >
975 {rows}
976 </Box>
977 </Box>
978 )
979 })
980}
981types/index.d.ts 102 lines1export type Ttl = '5m' | '1h'
2
3/** New tokens one model request added (sent uncached + written to cache + output). */
4export type Step = { at: number; n: number }
5
6/** Session totals across the main thread and subagents. */
7export type Totals = {
8 /** Uncached input plus cache writes: what was newly sent. */
9 sent: number
10 /** Output tokens. */
11 wrote: number
12 /** Cache reads: the conversation re-sent each step. */
13 reread: number
14}
15
16/** The current or last prompt's turn. */
17export type Turn = {
18 startedAt: number
19 endedAt: number | null
20 /** New tokens this turn. */
21 tokens: number
22 /** Session cost when the turn began; null when the host keeps no ledger. */
23 usdAtStart: number | null
24}
25
26/** The main thread's prompt cache as of its last model request. */
27export type Warm = {
28 at: number
29 /** Prompt tokens the next request re-sends. */
30 tokens: number
31 /** Share of the last prompt served from cache, 0-100; null when unknown. */
32 hitPct: number | null
33 model: string
34}
35
36/** One advisor consultation, noted when the step that carried it came back. */
37export type AdvisorCall = {
38 /** The API's id for the call, kept to count it once. */
39 id: string
40 /** 1-based position in the session. */
41 n: number
42 /** When the step carrying it arrived. */
43 at: number
44 /** How long the advisor ran; null when its result never arrived. */
45 ms: number | null
46}
47
48/** Advisor consultations this session (the newest 50 are kept). */
49export type Advisor = {
50 calls: AdvisorCall[]
51 /** All calls so far, including ones no longer in `calls`. */
52 count: number
53 /** When the first model step of the session arrived. */
54 firstAt: number | null
55}
56
57export type Ctx ={ tokens?: number; window: number; percent?: number }
58export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
59export type Limits = { context: Ctx | null; rateLimits: Limit[]; usd: number | null }
60
61/** What the band shows; saved in the mod's own store, edited from the Settings section of the details. */
62export type Prefs = {
63 showContext: boolean
64 show5h: boolean
65 showWeek: boolean
66 showAdvisor: boolean
67 showCache: boolean
68 showCost: boolean
69 /** Live new-token rate while Claude works; off shows the cache countdown instead. */
70 showRate: boolean
71 showAlerts: boolean
72 detailsNow: boolean
73 detailsLimits: boolean
74 detailsCache: boolean
75 detailsSession: boolean
76 /** Seconds the band says "Advisor consulted" after a call; 0 turns the note off. */
77 advisorFlashSeconds: number
78}
79
80/** A rate-limit reading, kept to estimate when a window runs out. */
81export type Sample = { t: number; p: number }
82
83declare module 'claude-code' {
84 interface PluginState {
85 'status-band': {
86 open: boolean
87 hidden: boolean
88 now: number
89 limits: Limits
90 steps: Step[]
91 totals: Totals
92 turn: Turn | null
93 warm: Warm | null
94 ttl: Ttl
95 history: Record<string, Sample[]>
96 advisor: Advisor
97 prefs: Prefs
98 settingsOpen: boolean
99 }
100 }
101}
102