One tidy bar above the prompt: context against your auto-compact window, 5-hour and 7-day limits against the clock, prompt cache time left, session cost and…

A Claude Code mod that puts one tidy bar above the prompt:
◔ 281k / 400k +9.8k │ 5h ━━──── 28% 2h 00m │ 7d ─────── 7% 4d 4h │ cache 59m │ $191 │ 5 running / 38
In the desktop app each segment is drawn with a small ring, bar or icon and opens a hover card with the detail.
| Segment | What it shows |
|---|---|
| Context | Tokens in the context against the auto-compact window (for example autoCompactWindow: 400000 in settings), not the model's full window. The ring fills as you approach it. The hover card says where the window came from, the model's own window and the token count where auto-compaction runs. |
| 5h / 7d | Share of your plan's limits used. The bar's tick marks how much of the window has passed, so a fill past the tick means you are ahead of pace. Then the time to reset. A 7-day projection (out Fri) appears when the week's pace would use it up before the reset. |
| Cache | Time left before the prompt cache lapses (1 hour on a plan, 5 minutes on an API key), cold once it has. The hover card shows the share of the last message read from the cache. |
| Cost | Session cost at API prices, as /cost totals it. The hover card adds the last prompt's cost. |
| Agents | Subagents running now, out of all the session has run, with what each is doing in the hover card. |
Colour only carries state: blue while things are fine, amber when ahead of pace or running short, red at the edge.
On a machine that runs heavy jobs (builds, test runs, dev servers) through a queue script, the band can show that queue on a row above it:
◇ Heavy Mac 0/2 · box-a 1/3 · box-b 0/8 · Queue 0 · Mem 79% free Load 2.7
Click the row (or run the slash command named after the script, /heavy for heavy.sh) to open a side pane with each slot, the waiting jobs in order and each remote machine, in sections that open on click. Amber means jobs are waiting or the load has passed the core count; red means memory pressure is critical, the load has reached the script's own limit, or a remote machine is off.
Set the option to the script's path in /config or in settings.json:
"pluginConfigs": { "usage-band@usage-band": { "options": { "queueScript": "~/tools/heavy.sh" } } }
Left empty, nothing runs and no row shows. The script is run as <path> status every few seconds; when several sessions are open, one of them runs it and the rest read its result from the plugin's local store. The output it reads, one line each:
slot 1: free
slot 2: web/fix-x, run pid 123, 3 min, in /path/to/worktree
remote slot 1 (box-a): api/tests, run pid 456, 0 min, in /path/to/worktree
queue 1: api/orders, run pid 789, waiting 4 min, in /path/to/worktree
memory free: 79%
memory pressure: normal
load: 2.67 2.35 2.13
chats working (last 2 min): 2
gate: off (load limit 40, ...)
remote (box-a, 3 slots): on, last check: ok (1 min ago)
box-a: memory 3 of 9 GB used, ..., load 1.61 0.44 0.15, ...
The engine's /context breakdown ($.session.usage({ breakdown: "summary" })) reports the window the session compacts against as rawMaxTokens, along with its source and the auto-compact threshold. The band reads it at start, after a compaction and at most every five minutes. Without a breakdown it falls back to CLAUDE_CODE_AUTO_COMPACT_WINDOW, then the model's window.
At the prompt of a terminal Claude Code session:
/plugin install usage-band --marketplace iamshadmantaqi/claude-usage-band
Answer y to add the marketplace, then pick the user scope. A mod installed at the user scope also draws in the desktop app's Code tab.
claude plugin validate .
claude plugin test .
No network requests and nothing sent anywhere. With the job queue option set it also runs that script locally. The band reads the usage figures Claude Code already has (context, limits, cost, each request's cache token counts, the list of agents) and keeps the latest limits reading in the plugin's local store so other sessions can show it.
MIT
hooks/register.tsx 850 lines1// Usage Band: one bar above the prompt.
2//
3// Desktop: ◔ 281k / 400k +9.8k 5h ▬▬▭▭ 28% 8m 7d ▬▭▭▭ 7% 4d 4h ⚡ 59m $191 ● 5 running / 38
4// Terminal: ◔ 281k/400k +9.8k │ 5h ━━──── 28% 8m │ 7d ━───── 7% 4d 4h │ cache 59m │ $191 │ 5 running / 38
5//
6// Colour only carries state: blue while things are fine, amber when ahead of pace or running
7// short, red at the edge. Every segment has a hover card in the app with the detail.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, On, Register, RenderChildren, RenderElement, RenderInput, SessionRateLimit } from 'claude-code'
11
12import type { CacheHit, Context, QueueReading, QueueSlot, QueueWaiter, RunningAgent, View } from '../types'
13import { clock as queueClock, hostSummary, isOpen, loadTone, memoryTone, namesOf, oneDecimal, parseStatus, summary, used } from './queue'
14
15type $ = EngineInterface
16
17const EMPTY: View = { context: null, limits: { at: 0, list: [] }, cost: null, lastPromptCost: null, cache: null, running: [], seen: [] }
18const view = atom({ plugin: 'usage-band', key: 'view' } as const, EMPTY)
19
20const MIN = 60_000
21const HOUR = 60 * MIN
22const DAY = 24 * HOUR
23const SPANS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * DAY }
24const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
25const ORDER = ['five_hour', 'seven_day', 'spend_limit']
26// The account's limits are shared by every session: the newest reading lives in the store.
27const LIMITS_KEY = 'limits'
28// The compaction window rarely changes; the breakdown that reports it is read this often at most.
29const WINDOW_TTL = 5 * MIN
30const RUNNING = new Set(['pending', 'running', 'waiting'])
31
32type Tone = 'ok' | 'warn' | 'alert'
33// SVG fills, readable on light and dark backgrounds.
34const FILL: Record<Tone, string> = { ok: '#3f8cff', warn: '#e09b2d', alert: '#e5534b' }
35// Text colours, as theme keys where the theme has one.
36const INK: Record<Tone, string | undefined> = { ok: undefined, warn: 'warning', alert: 'error' }
37const TRACK = 'rgba(127,127,127,0.28)'
38const MARK = 'rgba(127,127,127,0.95)'
39const MUTED_ICON = '#8b95a5'
40
41// Not drawn from, so module variables: they start over on a reload, as session.start does.
42let windowReadAt = 0
43let costAtSubmit: number | null = null
44let tokensAtSubmit: number | null = null
45let cachingOff = false
46let timer: { cancel: () => void } | null = null
47
48export const register: Register = (on, options) => {
49 const queueScript = String(options.queueScript ?? '').trim()
50 if (queueScript) registerQueue(on, queueScript)
51
52 on('session.start', async ($, e, next) => {
53 timer?.cancel()
54 if (queueScript) await startQueue($, queueScript)
55 windowReadAt = 0
56 cachingOff = Boolean(await $.env.get('DISABLE_PROMPT_CACHING'))
57 const usage = await $.session.usage()
58 const context = await contextOf($, usage.context, null)
59 const limits = await newestLimits($, usage.rateLimits)
60 await update($, view, (v) => ({ ...(v ?? EMPTY), context: context ?? v?.context ?? null, limits, cost: usage.cost?.usd ?? v?.cost ?? null }))
61 await refreshAgents($)
62 // Time moves on (cache countdown, reset clocks) and other sessions read newer limits.
63 timer = $.clock.every(15_000, async () => {
64 await adoptSharedLimits($)
65 await refreshAgents($)
66 $.ui.invalidate('ui.render')
67 })
68 return next(e)
69 })
70
71 on('session.end', async ($, e, next) => {
72 if (e.reason === 'prompt_input_exit' || e.reason === 'other') {
73 timer?.cancel()
74 stopQueue()
75 }
76 return next(e)
77 })
78
79 on('prompt.submit', async ($, e, next) => {
80 const v = await read($, view)
81 costAtSubmit = v?.cost ?? null
82 tokensAtSubmit = v?.context?.tokens ?? null
83 return next(e)
84 })
85
86 // Each main-loop request: how much of its prompt the cache served. The cache's lifetime runs
87 // from the request's start.
88 on('turn.step', async function* ($, e, next) {
89 if (e.agentId) return yield* next(e)
90 const at = await $.clock.now()
91 const result = yield* next(e)
92 const u = result?.usage
93 if (u) {
94 const cache: CacheHit = { at, read: u.cache_read_input_tokens ?? 0, write: u.cache_creation_input_tokens ?? 0, fresh: u.input_tokens ?? 0 }
95 await update($, view, (v) => ({ ...(v ?? EMPTY), cache }))
96 }
97 return result
98 })
99
100 on('turn.complete', async ($, e, next) => {
101 const result = await next(e)
102 if (e.agentId) {
103 await refreshAgents($)
104 return result
105 }
106 try {
107 const usage = await $.session.usage()
108 const prev = await read($, view)
109 const context = await contextOf($, usage.context, prev?.context ?? null)
110 const cost = usage.cost?.usd ?? prev?.cost ?? null
111 const lastPromptCost = cost !== null && costAtSubmit !== null && cost >= costAtSubmit ? cost - costAtSubmit : prev?.lastPromptCost ?? null
112 if (context && tokensAtSubmit !== null) context.delta = context.tokens - tokensAtSubmit
113 const limits = usage.rateLimits.length > 0 ? await shareLimits($, usage.rateLimits) : prev?.limits ?? EMPTY.limits
114 await update($, view, (v) => ({ ...(v ?? EMPTY), context: context ?? v?.context ?? null, cost, lastPromptCost, limits }))
115 await refreshAgents($)
116 } catch {
117 // No reading this turn: the bar keeps the last one.
118 }
119 return result
120 })
121
122 // A compaction drops the context at once; the next request writes a new cache.
123 on('session.compact', async ($, e, next) => {
124 const result = await next(e)
125 if (e.agentId || e.trigger === 'precompute' || !result || 'skip' in result) return result
126 try {
127 windowReadAt = 0
128 const prev = await read($, view)
129 const usage = await $.session.usage()
130 const context = await contextOf($, usage.context, prev?.context ?? null)
131 const after = (result as { tokensAfter?: number }).tokensAfter
132 if (context && typeof after === 'number' && after > 0) context.tokens = after
133 if (context) context.delta = null
134 await update($, view, (v) => ({ ...(v ?? EMPTY), context: context ?? v?.context ?? null, cache: null }))
135 } catch {
136 // The bar catches up at the end of the next prompt.
137 }
138 return result
139 })
140
141 on('session.measure', async ($, e, next) => {
142 const limits = e.changed.includes('rateLimits') && e.rateLimits.length > 0 ? await shareLimits($, e.rateLimits) : null
143 await update($, view, (v) => {
144 const base = v ?? EMPTY
145 const out: View = { ...base }
146 if (limits) out.limits = limits
147 if (e.cost) out.cost = e.cost.usd
148 if (e.changed.includes('context') && base.context && typeof e.context.tokens === 'number') {
149 out.context = { ...base.context, tokens: e.context.tokens, modelWindow: e.context.window || base.context.modelWindow }
150 }
151 return out
152 })
153 return next(e)
154 })
155
156 on('agent.spawn', async ($, e, next) => {
157 const result = await next(e)
158 try {
159 await refreshAgents($)
160 } catch {}
161 return result
162 })
163
164 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
165 if (e.props.hasSurvey) return next(e)
166 const els = $.ui.resolve(e)
167 const { Box } = els
168 // The job queue row, when the option is set, sits a row of air above the band.
169 const row = queueScript ? await queueRow($, e) : null
170 const v = (await read($, view)) ?? EMPTY
171 const hasBand = Boolean(v.context || v.limits.list.length > 0)
172 const now = await $.clock.now()
173 const mine = hasBand ? (e.surface === 'terminal' || !('Svg' in els) ? drawTerminal(els, segmentsOf(v, now), e.props.bodyColumns ?? 80) : drawDesktop(els, segmentsOf(v, now))) : null
174 const below = await next(e)
175 const band = mine && below ? (
176 <Box flexDirection="column">
177 {mine}
178 {below}
179 </Box>
180 ) : (mine ?? below)
181 if (!row) return band
182 if (!band) return row
183 return (
184 <Box flexDirection="column" rowGap={1}>
185 {row}
186 {band}
187 </Box>
188 )
189 })
190}
191
192// ---------- Readings ----------
193
194// The context against the auto-compact window. The engine's /context breakdown names that window
195// (`rawMaxTokens`): the model's own limit, or the smaller one settings, the environment or the
196// account set. Read at most every few minutes; between reads the last one stands.
197async function contextOf($: $, live: { tokens?: number; window: number }, prev: Context | null): Promise<Context | null> {
198 if (!live || !(live.window > 0)) return prev ? { ...prev } : null
199 const tokens = typeof live.tokens === 'number' ? live.tokens : prev?.tokens ?? 0
200 let win = prev && prev.modelWindow === live.window ? { window: prev.window, source: prev.source, threshold: prev.threshold } : null
201 const now = await $.clock.now()
202 if (!win || now - windowReadAt > WINDOW_TTL) win = (await compactWindow($, live.window, now)) ?? win
203 if (!win) win = { window: live.window, source: 'model', threshold: null }
204 return { tokens, modelWindow: live.window, window: win.window, source: win.source, threshold: win.threshold, delta: prev?.delta ?? null }
205}
206
207async function compactWindow($: $, modelWindow: number, now: number) {
208 try {
209 const usage = await $.session.usage({ breakdown: 'summary' })
210 const b = usage.context.breakdown
211 if (b && b.rawMaxTokens > 0) {
212 windowReadAt = now
213 const threshold = b.isAutoCompactEnabled && typeof b.autoCompactThreshold === 'number' ? b.autoCompactThreshold : null
214 return { window: b.rawMaxTokens, source: String(b.autocompactSource ?? 'auto'), threshold }
215 }
216 } catch {
217 // No session bound yet, or the breakdown failed: the variable below, then the model's window.
218 }
219 const fromEnv = Number(await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW'))
220 if (fromEnv > 0) return { window: Math.min(fromEnv, modelWindow), source: 'env', threshold: null }
221 return null
222}
223
224function sortLimits(list: readonly SessionRateLimit[]) {
225 const rank = (k: string) => (ORDER.includes(k) ? ORDER.indexOf(k) : ORDER.length)
226 return [...list].sort((a, b) => rank(a.kind) - rank(b.kind))
227}
228
229// This session's reading, published when it is the newest the store knows.
230async function shareLimits($: $, list: readonly SessionRateLimit[]) {
231 const limits = { at: await $.clock.now(), list: sortLimits(list) }
232 try {
233 const stored = (await $.store.get(LIMITS_KEY)) as View['limits'] | undefined
234 if (!stored || !(stored.at > limits.at)) await $.store.set(LIMITS_KEY, limits)
235 } catch {}
236 return limits
237}
238
239// At start: the newest of this session's reading and the store's.
240async function newestLimits($: $, list: readonly SessionRateLimit[]) {
241 let stored: View['limits'] | undefined
242 try {
243 stored = (await $.store.get(LIMITS_KEY)) as View['limits'] | undefined
244 } catch {}
245 if (stored && Array.isArray(stored.list) && stored.list.length > 0) return { at: stored.at, list: sortLimits(stored.list) }
246 return list.length > 0 ? shareLimits($, list) : EMPTY.limits
247}
248
249async function adoptSharedLimits($: $) {
250 try {
251 const stored = (await $.store.get(LIMITS_KEY)) as View['limits'] | undefined
252 const v = await read($, view)
253 if (stored && Array.isArray(stored.list) && stored.at > (v?.limits.at ?? 0)) {
254 await update($, view, (x) => ({ ...(x ?? EMPTY), limits: { at: stored.at, list: sortLimits(stored.list) } }))
255 }
256 } catch {}
257}
258
259async function refreshAgents($: $) {
260 let list: Awaited<ReturnType<$['agent']['list']>>
261 try {
262 list = await $.agent.list()
263 } catch {
264 return
265 }
266 const running: RunningAgent[] = list.filter((a) => RUNNING.has(a.status)).map((a) => ({ id: a.id, type: a.type, description: a.description }))
267 const v = (await read($, view)) ?? EMPTY
268 const seen = [...new Set([...v.seen, ...list.map((a) => a.id)])]
269 const same = seen.length === v.seen.length && running.length === v.running.length && running.every((r, i) => r.id === v.running[i]?.id)
270 if (!same) await update($, view, (x) => ({ ...(x ?? EMPTY), running, seen }))
271}
272
273// ---------- What each segment says ----------
274
275type Part = { text: string; tone?: Tone; strong?: boolean; dim?: boolean; detail?: boolean }
276type Segment = {
277 key: string
278 icon?: { svg: string; alt: string; glyph: string; tone?: Tone }
279 bar?: { used: number; mark: number | null; tone: Tone }
280 parts: Part[]
281 card: string[]
282}
283
284function segmentsOf(v: View, now: number): Segment[] {
285 const out: Segment[] = []
286 if (v.context) out.push(contextSegment(v.context))
287 for (const l of v.limits.list) out.push(limitSegment(l, now))
288 const cache = cacheSegment(v, now)
289 if (cache) out.push(cache)
290 if (v.cost !== null && v.cost >= 0.005) out.push(costSegment(v))
291 out.push(agentsSegment(v))
292 return out
293}
294
295function contextSegment(c: Context): Segment {
296 const pct = c.window > 0 ? (c.tokens / c.window) * 100 : 0
297 const tone: Tone = pct >= 85 ? 'alert' : pct >= 60 ? 'warn' : 'ok'
298 const parts: Part[] = [{ text: tokens(c.tokens), strong: true, tone: tone === 'ok' ? undefined : tone }, { text: `/ ${tokens(c.window)}`, dim: true }]
299 if (c.delta !== null && Math.abs(c.delta) >= 1000) parts.push({ text: c.delta > 0 ? `+${tokens(c.delta)}` : `−${tokens(-c.delta)}`, dim: true, detail: true })
300 const card = [`Context ${tokens(c.tokens)} of ${tokens(c.window)} (${Math.round(pct)}%)`]
301 if (c.window < c.modelWindow) card.push(` Auto-compact window ${tokens(c.window)}, ${SOURCES[c.source] ?? c.source}; model allows ${tokens(c.modelWindow)}`)
302 else card.push(` The model's full window`)
303 if (c.threshold !== null) card.push(`Auto-compacts at about ${tokens(c.threshold)}`, ` ${tokens(Math.max(0, c.threshold - c.tokens))} to go`)
304 if (c.delta !== null) card.push(`Last prompt ${c.delta >= 0 ? '+' : '−'}${tokens(Math.abs(c.delta))}`)
305 return { key: 'context', icon: { svg: ringSvg(pct, FILL[tone]), alt: `Context ${Math.round(pct)}% of the auto-compact window`, glyph: ringGlyph(pct), tone }, parts, card }
306}
307
308const SOURCES: Record<string, string> = {
309 settings: 'from your settings',
310 env: 'from CLAUDE_CODE_AUTO_COMPACT_WINDOW',
311 clientdata: 'set by your account',
312 experiment: 'set by an experiment',
313 'model-default': "the model's default",
314 'unknown-model': 'a default for this model',
315}
316
317function limitSegment(l: SessionRateLimit, now: number): Segment {
318 const label = LABELS[l.kind] ?? l.kind
319 const reset = Date.parse(l.resetsAt ?? '')
320 const left = Number.isFinite(reset) ? reset - now : null
321 const span = SPANS[l.kind]
322 // A window that already reset: its reading says nothing until the next response.
323 if (left !== null && left <= 0) {
324 return { key: 'limit-' + l.kind, parts: [{ text: label, dim: true }, { text: '–', dim: true }], card: [`${NAMES[l.kind] ?? label} reset at ${clock(reset)}`, ' The next message brings a new reading'] }
325 }
326 const used = clamp(l.percentUsed)
327 const elapsed = span && left !== null ? clamp(((span - left) / span) * 100) : null
328 const ahead = elapsed === null ? 0 : used - elapsed
329 const tone: Tone = used >= 90 || ahead > 20 ? 'alert' : ahead > 5 ? 'warn' : 'ok'
330 const parts: Part[] = [{ text: label, dim: true }, { text: `${Math.round(used)}%`, strong: true, tone: tone === 'ok' ? undefined : tone }]
331 if (left !== null) parts.push({ text: duration(left), dim: true, detail: true })
332 const card = [`${NAMES[l.kind] ?? label}: ${Math.round(used)}% used`]
333 if (elapsed !== null) card.push(` ${Math.round(elapsed)}% of the window has passed${ahead > 0 ? `, ${Math.round(ahead)} points ahead of pace` : ''}`)
334 if (left !== null) card.push(`Resets ${l.kind === 'five_hour' ? 'at ' + clock(reset) : dayTime(reset)}`, ` in ${duration(left)}`)
335 // The week's average pace so far, projected: shown after a day and from 15% used.
336 if (l.kind === 'seven_day' && span && left !== null && span - left >= DAY && used >= 15 && used < 100) {
337 const out = now + ((100 - used) * (span - left)) / used
338 if (out < reset) {
339 parts.push({ text: `out ${weekday(out)}`, tone: out - now < DAY ? 'alert' : 'warn', strong: true })
340 card.push(`At this week's pace it runs out ${dayTime(out)}`, ` before the reset; an estimate`)
341 }
342 }
343 return { key: 'limit-' + l.kind, bar: { used, mark: elapsed, tone }, parts, card }
344}
345
346const NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: '7-day limit', spend_limit: 'Spend limit' }
347
348// The prompt cache keeps the conversation's start for an hour on a subscription, five minutes on
349// an API key; each request that reads it starts the time again.
350function cacheSegment(v: View, now: number): Segment | null {
351 if (cachingOff || !v.cache) return null
352 const ttl = v.limits.list.length > 0 ? HOUR : 5 * MIN
353 const left = ttl - (now - v.cache.at)
354 const total = v.cache.read + v.cache.write + v.cache.fresh
355 const hit = total > 0 ? Math.round((v.cache.read / total) * 100) : 0
356 const card: string[] = []
357 let parts: Part[]
358 let tone: Tone
359 if (left > 0) {
360 tone = left < ttl / 5 ? 'warn' : 'ok'
361 parts = [{ text: duration(left), strong: tone !== 'ok', tone: tone === 'ok' ? undefined : tone }]
362 card.push(`Prompt cache warm until ${clock(v.cache.at + ttl)}`, ` ${duration(left)} left, ${ttl === HOUR ? '1 hour' : '5 minute'} lifetime`)
363 } else {
364 tone = 'alert'
365 parts = [{ text: 'cold', strong: true, tone }]
366 card.push(`Prompt cache expired ${duration(-left)} ago`)
367 if (v.context) card.push(` The next message writes about ${tokens(v.context.tokens)} again`)
368 if (v.context && v.context.tokens >= 100_000) card.push(`/compact first makes that rewrite smaller`)
369 }
370 if (total > 0) card.push(`Last message: ${hit}% read from the cache`, ` ${tokens(v.cache.read)} read, ${tokens(v.cache.write)} written`)
371 return { key: 'cache', icon: { svg: boltSvg(tone === 'ok' ? MUTED_ICON : FILL[tone]), alt: 'Prompt cache', glyph: 'cache', tone }, parts, card }
372}
373
374function costSegment(v: View): Segment {
375 const cost = v.cost ?? 0
376 const card = [`Session ${money(cost, true)} at API prices`]
377 if (v.limits.list.length > 0) card.push(' On a plan this counts toward its limits, not a bill')
378 if (v.lastPromptCost !== null && v.lastPromptCost >= 0.005) card.push(`Last prompt +${money(v.lastPromptCost, true)}`)
379 return { key: 'cost', parts: [{ text: money(cost, false) }], card }
380}
381
382// Always drawn: a session that has run no agent shows a muted 0.
383function agentsSegment(v: View): Segment {
384 const n = v.running.length
385 const total = v.seen.length
386 const card = [`${total} agent${total === 1 ? '' : 's'} this session, ${n} running`]
387 for (const a of v.running.slice(0, 8)) card.push(` ${a.type}: ${fit(a.description || 'working', 48)}`)
388 if (n > 8) card.push(` and ${n - 8} more`)
389 const parts: Part[] = n > 0 ? [{ text: `${n} running`, strong: true }, { text: `/ ${total}`, dim: true }] : [{ text: `${total} agent${total === 1 ? '' : 's'}`, dim: true }]
390 return { key: 'agents', icon: n > 0 ? { svg: dotSvg(FILL.ok), alt: 'Agents running', glyph: '●', tone: 'ok' } : undefined, parts, card }
391}
392
393// ---------- Drawing ----------
394
395type Els = ReturnType<$['ui']['resolve']>
396
397function drawDesktop(els: Els, segments: Segment[]) {
398 const { Box, Text } = els
399 const Svg = (els as { Svg?: any }).Svg
400 const half = Math.ceil(segments.length / 2)
401 return (
402 <Box flexDirection="row" flexWrap="wrap" justifyContent="center" alignItems="center" columnGap={1} rowGap={0} paddingX={1}>
403 {segments.map((s, i) => (
404 <Box key={s.key} flexDirection="row" alignItems="center" columnGap={1} flexShrink={0} paddingX={1} hover={{ backgroundColor: 'rgba(127,127,127,0.14)' }}>
405 {s.icon ? <Svg key="icon" source={s.icon.svg} alt={s.icon.alt} width={14} height={14} /> : null}
406 {partsOf(Text, s.parts.slice(0, s.bar ? 1 : s.parts.length))}
407 {s.bar ? <Svg key="bar" source={barSvg(s.bar.used, s.bar.mark, FILL[s.bar.tone])} alt={`${Math.round(s.bar.used)}% used`} width={BAR.w} height={BAR.h} /> : null}
408 {s.bar ? partsOf(Text, s.parts.slice(1), 'p') : null}
409 {card(els, s.card, i >= half)}
410 </Box>
411 ))}
412 </Box>
413 )
414}
415
416function partsOf(Text: Els['Text'], parts: Part[], prefix = 't') {
417 return parts.map((p, i) => (
418 <Text key={prefix + i} bold={p.strong} dimColor={p.dim && !p.tone} color={p.tone ? INK[p.tone] : undefined}>
419 {p.text}
420 </Text>
421 ))
422}
423
424// Hidden until the pointer is over its segment; drawn above the bar, anchored to the segment's
425// left edge, or its right edge for segments in the bar's right half.
426function card(els: Els, lines: string[], fromRight: boolean) {
427 const { Box, Text } = els
428 if (lines.length === 0) return null
429 return (
430 <Box position="absolute" bottom={1} {...(fromRight ? { right: 0 } : { left: 0 })} display="none" hover={{ display: 'flex' }} flexDirection="column" paddingX={1} borderStyle="round" borderColor="subtle" backgroundColor="background">
431 {lines.map((line, i) =>
432 line.startsWith(' ') ? (
433 <Text key={'c' + i} dimColor>
434 {line.trim()}
435 </Text>
436 ) : (
437 <Text key={'c' + i} bold={i === 0}>
438 {line}
439 </Text>
440 ),
441 )}
442 </Box>
443 )
444}
445
446const TEXT_BAR = 6
447
448function drawTerminal(els: Els, segments: Segment[], columns: number) {
449 const { Box, Text } = els
450 // Too wide for the terminal: the details go first, then the bars.
451 const width = (withDetail: boolean, withBar: boolean) =>
452 segments.reduce((n, s) => n + segmentWidth(s, withDetail, withBar), 0) + 3 * Math.max(0, segments.length - 1) + 2
453 const withDetail = width(true, true) <= columns
454 const withBar = withDetail || width(false, true) <= columns
455 const children: RenderChildren[] = []
456 segments.forEach((s, i) => {
457 if (i > 0) children.push(<Text key={'sep' + i} dimColor>{' │ '}</Text>)
458 const parts = s.parts.filter((p) => withDetail || !p.detail)
459 const inner: RenderChildren[] = []
460 if (s.icon) inner.push(<Text key="icon" color={glyphInk(s)} dimColor={!glyphInk(s)}>{s.icon.glyph + ' '}</Text>)
461 const head = s.bar ? parts.slice(0, 1) : parts
462 head.forEach((p, j) => inner.push(textPart(Text, p, 'h' + j, j > 0)))
463 if (s.bar && withBar) inner.push(textBar(Text, s.bar))
464 if (s.bar) parts.slice(1).forEach((p, j) => inner.push(textPart(Text, p, 'b' + j, true)))
465 children.push(
466 <Box key={s.key} flexDirection="row">
467 {inner}
468 </Box>,
469 )
470 })
471 return (
472 <Box flexDirection="row" justifyContent="center" paddingX={1}>
473 {children}
474 </Box>
475 )
476}
477
478// The terminal's icon colour: the state's when it is not fine; blue context, green agents.
479function glyphInk(s: Segment) {
480 const tone = s.icon?.tone
481 if (tone && tone !== 'ok') return INK[tone]
482 return s.key === 'context' ? 'blue' : s.key === 'agents' ? 'success' : undefined
483}
484
485function textPart(Text: Els['Text'], p: Part, key: string, space: boolean) {
486 return (
487 <Text key={key} bold={p.strong} dimColor={p.dim && !p.tone} color={p.tone ? INK[p.tone] ?? (p.tone === 'ok' ? 'blue' : undefined) : undefined}>
488 {(space ? ' ' : '') + p.text}
489 </Text>
490 )
491}
492
493function textBar(Text: Els['Text'], bar: NonNullable<Segment['bar']>) {
494 // Any share above zero shows at least one cell.
495 const filled = bar.used > 0 ? Math.max(1, Math.round((bar.used / 100) * TEXT_BAR)) : 0
496 return (
497 <Text key="bar">
498 {' '}
499 <Text color={bar.tone === 'ok' ? 'blue' : INK[bar.tone]}>{'━'.repeat(filled)}</Text>
500 <Text dimColor>{'─'.repeat(TEXT_BAR - filled)}</Text>
501 </Text>
502 )
503}
504
505function segmentWidth(s: Segment, withDetail: boolean, withBar: boolean) {
506 const parts = s.parts.filter((p) => withDetail || !p.detail)
507 return (s.icon ? [...s.icon.glyph].length + 1 : 0) + parts.reduce((n, p) => n + [...p.text].length + 1, 0) + (s.bar && withBar ? TEXT_BAR + 1 : 0)
508}
509
510// ---------- Pictures ----------
511
512const BAR = { w: 40, h: 10 }
513
514// A rounded bar filled to the share used, with a tick where the clock stands in the window.
515function barSvg(used: number, mark: number | null, color: string) {
516 const { w, h } = BAR
517 const y = 2
518 const th = h - 4
519 const fill = Math.max(used > 0 ? th : 0, (used / 100) * w)
520 let body = `<rect x="0" y="${y}" width="${w}" height="${th}" rx="${th / 2}" fill="${TRACK}"/>`
521 if (fill > 0) body += `<rect x="0" y="${y}" width="${fill.toFixed(1)}" height="${th}" rx="${th / 2}" fill="${color}"/>`
522 if (mark !== null) {
523 const x = Math.min(w - 1, Math.max(1, (mark / 100) * w))
524 body += `<rect x="${(x - 0.75).toFixed(2)}" y="0" width="1.5" height="${h}" rx="0.75" fill="${MARK}"/>`
525 }
526 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`
527}
528
529// A ring filled clockwise to the share of the window in use.
530function ringSvg(pct: number, color: string) {
531 const r = 5.5
532 const c = 2 * Math.PI * r
533 const len = (clamp(pct) / 100) * c
534 return (
535 `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">` +
536 `<circle cx="7" cy="7" r="${r}" fill="none" stroke="${TRACK}" stroke-width="2.4"/>` +
537 `<circle cx="7" cy="7" r="${r}" fill="none" stroke="${color}" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="${len.toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 7 7)"/></svg>`
538 )
539}
540
541function boltSvg(color: string) {
542 return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><path d="M8 1 2.5 8H7l-1 5 5.5-7H7z" fill="${color}" stroke="${color}" stroke-width="0.8" stroke-linejoin="round"/></svg>`
543}
544
545function dotSvg(color: string) {
546 return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="5.5" fill="${color}" fill-opacity="0.22"/><circle cx="7" cy="7" r="3" fill="${color}"/></svg>`
547}
548
549function ringGlyph(pct: number) {
550 return pct < 12.5 ? '○' : pct < 37.5 ? '◔' : pct < 62.5 ? '◑' : pct < 87.5 ? '◕' : '●'
551}
552
553// ---------- Formatting ----------
554
555function clamp(n: number) {
556 return Math.min(100, Math.max(0, Number.isFinite(n) ? n : 0))
557}
558
559// 1M, 1.2M, 281k, 9.8k, 950.
560export function tokens(n: number) {
561 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
562 if (n >= 100_000) return `${Math.round(n / 1000)}k`
563 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
564 return String(Math.round(n))
565}
566
567// 8m, 2h 05m, 4d 4h.
568export function duration(ms: number) {
569 const m = Math.max(0, Math.round(ms / MIN))
570 if (m < 60) return `${m}m`
571 if (m < 1440) return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
572 return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
573}
574
575// $4.32 in the bar, $191 from a hundred; the card keeps the cents.
576export function money(usd: number, exact: boolean) {
577 if (usd < 0.01) return '< $0.01'
578 if (!exact && usd >= 100) return `$${Math.round(usd)}`
579 return `$${usd.toFixed(2)}`
580}
581
582function fmt(ms: number, opts: Intl.DateTimeFormatOptions) {
583 try {
584 return new Intl.DateTimeFormat('en-GB', { hourCycle: 'h23', ...opts }).format(ms)
585 } catch {
586 return new Date(ms).toISOString().slice(11, 16) + ' UTC'
587 }
588}
589
590const clock = (ms: number) => fmt(ms, { hour: '2-digit', minute: '2-digit' })
591const weekday = (ms: number) => fmt(ms, { weekday: 'short' })
592const dayTime = (ms: number) => fmt(ms, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
593
594function fit(text: string, n: number) {
595 const c = [...text]
596 return c.length > n ? c.slice(0, n - 1).join('') + '…' : text
597}
598
599// ---------- Job queue (optional: the queueScript option) ----------
600
601const QUEUE_PANE = 'job-queue'
602const QUEUE_EVERY = 5_000
603// A reading another session took this recently is used as it is.
604const QUEUE_FRESH = 4_000
605const QUEUE_STORE_KEY = 'queue-reading'
606
607const queueReading = atom({ plugin: 'usage-band', key: 'queue' } as const, { at: 0, snapshot: null, error: null } as QueueReading)
608const queueSections = atom({ plugin: 'usage-band', key: 'queueSections' } as const, {} as Record<string, boolean>)
609
610// Not drawn from: they start over on a reload, as session.start does.
611let queuePath = ''
612let queueName = 'Queue'
613let queueCores = 8
614let queueTimer: { cancel: () => void } | null = null
615let queueBusy = false
616
617// Every session runs the same timer, so the newest reading lives in the store and only a session that finds it
618// stale runs the script: one run per few seconds for the whole machine, not one per session.
619// True when the stored reading was fresh enough to use.
620async function adoptQueueReading($: $) {
621 const now = await $.clock.now()
622 const shared = (await $.store.get(QUEUE_STORE_KEY)) as QueueReading | undefined
623 if (!shared || !shared.snapshot) return false
624 await update($, queueReading, (prev) => (prev && prev.at >= shared.at ? prev : shared))
625 return now - shared.at < QUEUE_FRESH
626}
627
628async function refreshQueue($: $, force = false) {
629 if (!force && (await adoptQueueReading($))) return
630 if (queueBusy) return
631 queueBusy = true
632 try {
633 const ran = await $.process.run([queuePath, 'status'], { timeoutMs: 20_000 })
634 const at = await $.clock.now()
635 const next: QueueReading =
636 ran.exitCode === 0
637 ? { at, snapshot: parseStatus(ran.stdout), error: null }
638 : { at, snapshot: null, error: (ran.stderr || ran.stdout).trim().split('\n')[0] || `exit ${ran.exitCode}` }
639 await update($, queueReading, (prev) => (next.snapshot || !prev?.snapshot ? next : { ...prev, error: next.error }))
640 if (next.snapshot) await $.store.set(QUEUE_STORE_KEY, next)
641 } catch (err) {
642 const at = await $.clock.now()
643 await update($, queueReading, (prev) => ({ at, snapshot: prev?.snapshot ?? null, error: String((err as Error)?.message ?? err) }))
644 } finally {
645 queueBusy = false
646 }
647}
648
649async function queuePaneOpen($: $) {
650 return (await $.ui.panes()).some((p) => p.id === QUEUE_PANE)
651}
652
653async function toggleQueuePane($: $) {
654 if (await queuePaneOpen($)) await $.ui.close({ id: QUEUE_PANE })
655 else await $.ui.open({ id: QUEUE_PANE, title: `${queueName} queue` })
656}
657
658// The row above the band, or null while there is no reading yet.
659export async function queueRow($: $, e: RenderInput<'AbovePrompt'>): Promise<RenderElement | null> {
660 const s = (await read($, queueReading))?.snapshot
661 if (!s) return null
662 const { Box, Text, Button } = $.ui.resolve(e)
663 const sum = summary(s)
664 const calm = sum.queue === 0 && !s.gateOn
665 return (
666 <Box key="queue-row" flexDirection="row" justifyContent="center" alignItems="center" columnGap={1} paddingX={1}>
667 <Button
668 key="queue-row-chip"
669 plain
670 dimColor={calm}
671 label={[`${calm ? '◇' : '◆'} ${queueName} Mac ${sum.mac}`, ...sum.hosts.filter((x) => x.isOn).map((x) => `${x.name} ${x.count}`), `Queue ${sum.queue}`].join(' · ')}
672 onPress={() => toggleQueuePane($)}
673 />
674 {sum.hosts
675 .filter((x) => !x.isOn)
676 .map((x) => (
677 <Text key={`off-${x.name}`} color="error">
678 {x.name} off
679 </Text>
680 ))}
681 {sum.queue > 0 ? <Text color="warning">{sum.queue} waiting</Text> : null}
682 {s.gateOn ? <Text color="warning">gate on</Text> : null}
683 <Text dimColor>·</Text>
684 <Text color={memoryTone(s.pressure)} dimColor={!memoryTone(s.pressure)}>
685 Mem {s.memoryFree || '?'} free
686 </Text>
687 <Text color={loadTone(s.load, queueCores, s.loadLimit)} dimColor={!loadTone(s.load, queueCores, s.loadLimit)}>
688 Load {oneDecimal(s.load)}
689 </Text>
690 </Box>
691 )
692}
693
694// Called from the band's own session.start, since a plugin hooks an event once without a matcher.
695export async function startQueue($: $, path: string) {
696 queueTimer?.cancel()
697 const home = (await $.env.get('HOME')) ?? ''
698 queuePath = path.startsWith('~/') ? home + path.slice(1) : path
699 try {
700 const ran = await $.process.run(['sysctl', '-n', 'hw.ncpu'], { timeoutMs: 5_000 })
701 queueCores = Number(ran.stdout.trim()) || queueCores
702 } catch {
703 // Not macOS: the default stands.
704 }
705 await $.command.register({ name: namesOf(path).command, description: `Show or hide the ${queueName.toLowerCase()} queue pane` })
706 // The last reading any session took draws at once; a stale one is replaced in the background.
707 await adoptQueueReading($)
708 void refreshQueue($)
709 queueTimer = $.clock.every(QUEUE_EVERY, () => refreshQueue($))
710}
711
712export function stopQueue() {
713 queueTimer?.cancel()
714}
715
716// The queue's own command and pane, both matched to it.
717function registerQueue(on: On, path: string) {
718 const names = namesOf(path)
719 queueName = names.name
720
721 on('command.run', { command: names.command }, async ($) => {
722 const wasOpen = await queuePaneOpen($)
723 await toggleQueuePane($)
724 if (!wasOpen) await refreshQueue($, true)
725 return { text: wasOpen ? `${queueName} queue pane closed.` : `${queueName} queue pane opened.` }
726 })
727
728 on('ui.render', { component: 'Pane', requestId: QUEUE_PANE }, async ($, e) => {
729 const { Box, Text, Button } = $.ui.resolve(e)
730 const r = await read($, queueReading)
731 const s = r?.snapshot
732 const open = (await read($, queueSections)) ?? {}
733
734 if (!s) {
735 return (
736 <Box flexDirection="column">
737 <Text dimColor>{r?.error ? `${queueName} status failed: ${r.error}` : `Reading ${queueName.toLowerCase()} status…`}</Text>
738 <Button key="refresh" label="Refresh" onPress={() => refreshQueue($, true)} />
739 </Box>
740 )
741 }
742
743 const toggle = (id: string) => () => update($, queueSections, (o) => ({ ...(o ?? {}), [id]: !isOpen(o ?? {}, id, s) }))
744
745 // A section header: the arrow and name are the button, its count sits beside it.
746 const header = (id: string, title: string, count: string, tone?: 'warning' | 'error') => (
747 <Box key={`head-${id}`} flexDirection="row" gap={1}>
748 <Button key={`sec-${id}`} plain label={`${isOpen(open, id, s) ? '▾' : '▸'} ${title}`} onPress={toggle(id)} />
749 <Text color={tone} dimColor={!tone}>
750 {count}
751 </Text>
752 </Box>
753 )
754
755 const slotRow = (slot: QueueSlot) =>
756 slot.label ? (
757 <Box key={`${slot.host ?? 'mac'}-${slot.n}`} flexDirection="row" gap={1}>
758 <Text color="success">●</Text>
759 <Text wrap="truncate-end">{slot.label}</Text>
760 <Text dimColor>
761 {slot.kind} · {slot.mins} min
762 </Text>
763 </Box>
764 ) : null
765
766 const freeRow = (key: string, free: number) =>
767 free > 0 ? (
768 <Text key={key} dimColor>
769 ○ {free} free
770 </Text>
771 ) : null
772
773 const hostsByName = new Map(s.hosts.map((x) => [x.name, x]))
774 const remoteHosts = [...new Set(s.remote.map((x) => x.host ?? ''))]
775 const queued = s.queue as QueueWaiter[]
776 const sum = summary(s)
777 const stale = r && r.error ? ` · last read failed: ${r.error}` : ''
778
779 return (
780 <Box flexDirection="column" gap={1}>
781 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
782 <Text bold>Mac {sum.mac}</Text>
783 {sum.hosts.map((x) => (
784 <Text key={`top-${x.name}`} bold color={x.isOn ? undefined : 'error'}>
785 {x.name} {x.isOn ? x.count : 'off'}
786 </Text>
787 ))}
788 <Text bold color={queued.length ? 'warning' : undefined}>
789 Queue {queued.length}
790 </Text>
791 </Box>
792
793 <Box flexDirection="column">
794 {header('queue', 'Queue', queued.length ? `${queued.length} waiting` : 'empty', queued.length ? 'warning' : undefined)}
795 {isOpen(open, 'queue', s)
796 ? queued.map((w) => (
797 <Box key={`q-${w.pos}`} flexDirection="row" gap={1}>
798 <Text color="warning">{w.pos}.</Text>
799 <Text wrap="truncate-end">{w.label}</Text>
800 <Text dimColor>
801 {w.kind} · waiting {w.mins} min
802 </Text>
803 </Box>
804 ))
805 : null}
806 </Box>
807
808 <Box flexDirection="column">
809 {header('mac', 'This Mac', sum.mac)}
810 {isOpen(open, 'mac', s) ? s.local.map(slotRow) : null}
811 {isOpen(open, 'mac', s) ? freeRow('mac-free', s.local.length - used(s.local)) : null}
812 </Box>
813
814 {remoteHosts.map((host) => {
815 const slots = s.remote.filter((x) => (x.host ?? '') === host)
816 const info = hostsByName.get(host)
817 const down = Boolean(info && info.state !== 'on')
818 const id = `host-${host}`
819 return (
820 <Box key={id} flexDirection="column">
821 {header(id, host, down ? `${info?.state}` : `${used(slots)}/${slots.length}`, down ? 'error' : undefined)}
822 {isOpen(open, id, s) ? (
823 <Box flexDirection="column">
824 {info?.detail ? <Text dimColor>{hostSummary(info.detail)}</Text> : null}
825 {down && info?.check ? <Text color="error">last check: {info.check}</Text> : null}
826 {slots.map(slotRow)}
827 {freeRow(`free-${host}`, slots.length - used(slots))}
828 </Box>
829 ) : null}
830 </Box>
831 )
832 })}
833
834 <Box flexDirection="column">
835 <Text dimColor>
836 Memory free {s.memoryFree || '?'} · pressure {s.pressure || '?'} · load {s.load || '?'} · chats {s.chats || '?'}
837 </Text>
838 <Text color={s.gateOn ? 'warning' : undefined} dimColor={!s.gateOn}>
839 Gate {s.gateOn ? `on: ${s.gate}` : 'off'}
840 </Text>
841 <Text dimColor>
842 Updated {queueClock(r!.at)}{stale}
843 </Text>
844 </Box>
845 <Button key="refresh" label="Refresh" onPress={() => refreshQueue($, true)} />
846 </Box>
847 )
848 })
849}
850hooks/queue.tsx 122 lines1// The job queue row: an optional line above the band, and a side pane, for a machine that runs heavy
2// jobs through a queue script. Set the `queueScript` option to the script's path; the row stays off
3// while it is empty. The script's `status` output is read as these lines:
4//
5// slot 1: free slot 2: <label>, run pid 123, 3 min, in <dir>
6// remote slot 1 (<host>): free remote slot 2 (<host>): <label>, run pid 9, 0 min, in <dir>
7// queue 1: <label>, run pid 77, waiting 4 min, in <dir>
8// memory free: 79% memory pressure: normal load: 2.67 2.35 2.13 chats working (last 2 min): 2
9// gate: off (load limit 40, ...) remote (<host>, 3 slots): on, last check: ok (1 min ago)
10// <host>: memory 3 of 9 GB used, ..., load 1.61 0.44 0.15, ...
11//
12// The row (drawn by register.tsx): ◇ Heavy Mac 0/2 · <host> 1/3 · Queue 0 · Mem 79% free Load 2.7 (click: the pane)
13// Its label and its slash command come from the script's file name (heavy.sh: "Heavy", /heavy).
14
15import type { QueueHost, QueueSnapshot, QueueSlot } from '../types'
16
17// heavy.sh -> { name: 'Heavy', command: 'heavy' }
18export function namesOf(path: string) {
19 const base = (path.split('/').pop() ?? '').replace(/\.[^.]*$/, '')
20 const command = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'queue'
21 return { name: command.charAt(0).toUpperCase() + command.slice(1), command }
22}
23
24const mins = (s: string) => Number(s) || 0
25
26export function parseStatus(text: string): QueueSnapshot {
27 const snap: QueueSnapshot = { local: [], remote: [], queue: [], memoryFree: '', pressure: '', load: '', loadLimit: null, chats: '', gate: '', gateOn: false, hosts: [] }
28 let host: QueueHost | null = null
29 for (const raw of text.split('\n')) {
30 const line = raw.trimEnd()
31 // Each line's groups as strings, '' where a group did not take part.
32 const match = (re: RegExp) => {
33 const m = line.match(re)
34 return m ? Array.from(m, (x) => x ?? '') : null
35 }
36 let m: string[] | null
37 if ((m = match(/^slot (\d+): free$/))) {
38 snap.local.push({ n: Number(m[1]), host: null, label: null, kind: '', mins: 0, cwd: '' })
39 } else if ((m = match(/^slot (\d+): (.+?), (hold until \S+|run pid \d+), (\d+) min, in (.*)$/))) {
40 const [, n = '', label = '', kind = '', min = '', cwd = ''] = m
41 snap.local.push({ n: Number(n), host: null, label, kind: kind.startsWith('hold') ? kind : 'run', mins: mins(min), cwd })
42 } else if ((m = match(/^remote slot (\d+) \(([^)]+)\): free$/))) {
43 snap.remote.push({ n: Number(m[1]), host: m[2] ?? '', label: null, kind: '', mins: 0, cwd: '' })
44 } else if ((m = match(/^remote slot (\d+) \(([^)]+)\): (.+?), run pid \d+, (\d+) min, in (.*)$/))) {
45 const [, n = '', where = '', label = '', min = '', cwd = ''] = m
46 snap.remote.push({ n: Number(n), host: where, label, kind: 'run', mins: mins(min), cwd })
47 } else if ((m = match(/^queue (\d+): (.+?), (\S+) pid \d+, waiting (\d+) min, in (.*)$/))) {
48 const [, pos = '', label = '', kind = '', min = '', cwd = ''] = m
49 snap.queue.push({ pos: Number(pos), label, kind, mins: mins(min), cwd })
50 } else if ((m = match(/^memory free: (.*)$/))) {
51 snap.memoryFree = (m[1] ?? '').trim()
52 } else if ((m = match(/^memory pressure: (.*)$/))) {
53 snap.pressure = (m[1] ?? '').trim()
54 } else if ((m = match(/^load: (.*)$/))) {
55 snap.load = (m[1] ?? '').trim().split(/\s+/)[0] ?? ''
56 } else if ((m = match(/^chats working \(last 2 min\): (.*)$/))) {
57 snap.chats = (m[1] ?? '').trim()
58 } else if ((m = match(/^gate: (on|off)(?:, | )?(.*)$/))) {
59 snap.gateOn = m[1] === 'on'
60 snap.gate = snap.gateOn ? (m[2] ?? '') : 'off'
61 const limit = (m[2] ?? '').match(/load limit (\d+(?:\.\d+)?)/)
62 if (limit) snap.loadLimit = Number(limit[1])
63 } else if ((m = match(/^remote \(([^,]+), (\d+) slots?\): (\w+)(?:, last check: (.*))?$/))) {
64 const [, name = '', slots = '', state = '', check = ''] = m
65 host = { name, slots: Number(slots), state, check, detail: '' }
66 snap.hosts.push(host)
67 } else if (host && (m = match(/^\s+([^:]+): (.*)$/)) && m[1] === host.name) {
68 host.detail = m[2] ?? ''
69 }
70 }
71 return snap
72}
73
74// "memory 3 of 9 GB used, agents account ..., load 1.61 0.44 0.15, 1 jobs, ..." to "mem 3/9 GB · load 1.61".
75export function hostSummary(detail: string): string {
76 const mem = detail.match(/memory (\d+) of (\d+) GB used/)
77 const load = detail.match(/load ([\d.]+)/)
78 return [mem ? `mem ${mem[1]}/${mem[2]} GB` : '', load ? `load ${load[1]}` : ''].filter(Boolean).join(' · ')
79}
80
81export const used = (slots: QueueSlot[]) => slots.filter((s) => s.label).length
82
83// One count per machine, in the order the script lists its slots. A host it reports as anything
84// but on is marked off: its slots take no jobs.
85export function summary(s: QueueSnapshot) {
86 const states = new Map(s.hosts.map((x) => [x.name, x.state]))
87 const names = [...new Set(s.remote.map((x) => x.host ?? ''))]
88 const hosts = names.map((name) => {
89 const slots = s.remote.filter((x) => (x.host ?? '') === name)
90 return { name, count: `${used(slots)}/${slots.length}`, isOn: (states.get(name) ?? 'on') === 'on' }
91 })
92 return { mac: `${used(s.local)}/${s.local.length}`, hosts, queue: s.queue.length }
93}
94
95// Memory reads by macOS memory pressure, not the free figure: free memory stays low while the cache is full.
96export function memoryTone(pressure: string): 'warning' | 'error' | undefined {
97 return pressure === 'critical' ? 'error' : pressure === 'warn' ? 'warning' : undefined
98}
99
100// Amber once the load passes the core count, red at the script's own load limit (four per core when
101// it does not say).
102export function loadTone(load: string, cores: number, limit: number | null): 'warning' | 'error' | undefined {
103 const n = Number(load)
104 if (load === '' || !Number.isFinite(n)) return undefined
105 return n >= (limit ?? cores * 4) ? 'error' : n >= cores ? 'warning' : undefined
106}
107
108export const oneDecimal = (load: string) => (Number.isFinite(Number(load)) && load !== '' ? Number(load).toFixed(1) : '?')
109
110// A section is open when the person opened it; until then only a non-empty queue starts open.
111export function isOpen(open: Record<string, boolean>, id: string, s: QueueSnapshot) {
112 return open[id] ?? (id === 'queue' && s.queue.length > 0)
113}
114
115export function clock(ms: number) {
116 try {
117 return new Intl.DateTimeFormat('en-GB', { hour: 'numeric', minute: '2-digit', second: '2-digit', hourCycle: 'h12' }).format(ms)
118 } catch {
119 return new Date(ms).toISOString().slice(11, 19) + ' UTC'
120 }
121}
122types/index.d.ts 66 lines1/** One rate-limit window, as the engine reports it. */
2export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
3
4/** The live context, measured against the window auto-compaction works to. */
5export type Context = {
6 /** Input tokens the last main-loop response was answered over. */
7 tokens: number
8 /** The model's own context window. */
9 modelWindow: number
10 /** The window the band measures against: the auto-compact window when one is set. */
11 window: number
12 /** Where `window` came from: the engine's breakdown source, `env`, or `model`. */
13 source: string
14 /** Token count at which auto-compaction runs; null when it is off or unknown. */
15 threshold: number | null
16 /** Tokens the last prompt added; null before one finished. */
17 delta: number | null
18}
19
20/** The last main-loop request's prompt, as the cache served it. */
21export type CacheHit = { at: number; read: number; write: number; fresh: number }
22
23export type RunningAgent = { id: string; type: string; description: string }
24
25export type View = {
26 context: Context | null
27 /** The newest rate-limit reading, from this or another session. */
28 limits: { at: number; list: RateLimit[] }
29 cost: number | null
30 lastPromptCost: number | null
31 cache: CacheHit | null
32 running: RunningAgent[]
33 /** Ids of every agent seen this session. */
34 seen: string[]
35}
36
37/** One job slot of the queue script, on this machine (`host` null) or a remote one. */
38export type QueueSlot = { n: number; host: string | null; label: string | null; kind: string; mins: number; cwd: string }
39/** A job waiting for a slot, in the order the queue serves them. */
40export type QueueWaiter = { pos: number; label: string; kind: string; mins: number; cwd: string }
41/** A remote machine the script sends jobs to: whether it is on, and its own memory and load line. */
42export type QueueHost = { name: string; slots: number; state: string; check: string; detail: string }
43/** One `status` reading of the queue script. */
44export type QueueSnapshot = {
45 local: QueueSlot[]
46 remote: QueueSlot[]
47 queue: QueueWaiter[]
48 memoryFree: string
49 pressure: string
50 /** The one-minute load average, as printed. */
51 load: string
52 /** The load at which the script runs one job at a time, when it says. */
53 loadLimit: number | null
54 chats: string
55 gate: string
56 gateOn: boolean
57 hosts: QueueHost[]
58}
59export type QueueReading = { at: number; snapshot: QueueSnapshot | null; error: string | null }
60
61declare module 'claude-code' {
62 interface PluginState {
63 'usage-band': { view: View; queue: QueueReading; queueSections: Record<string, boolean> }
64 }
65}
66