Status band above the Claude Code prompt: prompt-cache countdown, today's cost and plan limits, open PRs and the coord-bus seat

A status band above the Claude Code prompt, drawn as one row when it fits and stacked when it does not:
████████████░░░░░░░░ 52:10 until prompt cache expires (1h detected) │ $4.12 today · 5h 63% 7d 21% │ ⎇ 2 open PRs │ ● worker-2 · #groundwork · 3 unread
| Segment | Shows |
|---|---|
| Cache countdown | Time left before the prompt cache expires (when the next prompt costs more). Green, yellow, then red as it drains; a red "Cache expired" line afterwards. The TTL (5m or 1h) is detected from the cache's behaviour and labelled assumed, detected or set. |
| Cost and limits | Today's cost across all your sessions, and your plan-limit windows (5h, 7d) as percentages, coloured by how full they are. |
| Open PRs | Open PRs in the current repo (gh pr list), with drafts counted. |
| Coord bus | This session's seat on an agent-coord bus: id, rooms, unread count. Hidden when there is no bus. |
When the terminal is narrow the segments shorten ($4.12 · 7d 22%, ⎇ 2 PRs), then stack.
/plugin marketplace add davidbalzan/status-band
/plugin install status-band@status-band
From a local clone, use its path instead of davidbalzan/status-band.
Set under /plugin configure status-band@status-band.
| Option | Default | |
|---|---|---|
ttl | auto | auto detects the cache lifetime; 5m or 1h forces it. |
showUsage | on | Cost and plan limits. |
showPrs | on | Open PRs. Needs the gh CLI, signed in. |
showBus | on | Coord-bus row. |
whoami tool (agent-coord-mcp 0.26.46 and later; older servers fall back to status and list_rooms). With no bus on the machine it backs off to every 10 minutes.gh pr list call every 5 minutes, sooner after this session runs gh pr or git push. A failing call backs off.assumed.claude plugin validate .
claude plugin test .
Logic lives in small modules under hooks/ (cache, usage, prs, bus, pieces for layout, schedule), with register.tsx wiring them to the engine. Tests are beside them.
hooks/register.tsx 368 lines1import type { Register, Timer } from 'claude-code'
2
3import { busSegment, findJoin, parseJsonBlock, type Bus, type WhoamiView } from './bus'
4import { cacheSegment, describeCheck, isTtl, judgeTtl, TTL_MS, type CacheState, type Ttl } from './cache'
5import { layoutRows } from './pieces'
6import { parsePrs, prSegment, type PrCount } from './prs'
7import { backoff } from './schedule'
8import { costDelta, localDay, usageSegment, type PlanLimit } from './usage'
9
10const STORE_TTL_KEY = 'detected-ttl'
11const STORE_COST_PREFIX = 'usage-cost:'
12const COST_KEEP_DAYS = 14
13
14// One display tick (while a countdown is on screen) and one slow poll; each source below keeps its own due time.
15const POLL_MS = 15_000
16const BUS_MS = 15_000
17const BUS_IDLE_MS = 60_000
18const USAGE_MS = 30_000
19const OTHER_SESSIONS_MS = 120_000
20const PR_REFRESH_MS = 5 * 60_000
21const PR_SETTLE_MS = 10_000
22const BACKOFF_MAX_MS = 10 * 60_000
23
24export const register: Register = (on, options) => {
25 const configured = isTtl(options.ttl) ? options.ttl : null
26 const showBus = options.showBus !== false
27 const showPrs = options.showPrs !== false
28 const showUsage = options.showUsage !== false
29
30 // Cache: every API response refreshes it, so the clock restarts at each one.
31 let lastResponseAt: number | null = null
32 let previousTokens = 0
33 let gapBeforeTurn: number | null = null
34 let detected: Ttl | null = null
35 let isDetectedLoaded = false
36 let lastCheck: string | null = null
37
38 // Redraw: the 1s tick only draws while a countdown is showing; the poll draws only when a value changed.
39 let tick: Timer | undefined
40 let poll: Timer | undefined
41 let isTicking = false
42 let hasPolledOnce = false
43
44 // Bus
45 let bus: Bus | null = null
46 let isBusChecked = false
47 let hasWhoami: boolean | null = null
48 let busDueAt = 0
49 let busDelayMs = BUS_MS
50
51 // Usage: today's cost is every session's spend since midnight, one store key per session so concurrent
52 // sessions never overwrite each other. Spend before the mod loaded in a session is not counted.
53 let todayUsd: number | null = null
54 let limits: readonly PlanLimit[] = []
55 let usageDueAt = 0
56 let lastSessionUsd: number | null = null
57 let ownDay = ''
58 let ownUsd = 0
59 let othersUsd = 0
60 let othersDay = ''
61 let othersDueAt = 0
62 let isUsagePruned = false
63
64 // Open PRs
65 let prs: PrCount | null = null
66 let prsDueAt = 0
67 let prsDelayMs = PR_REFRESH_MS
68
69 on('classic.SessionStart', async ($, e, next) => {
70 if (e.seconds_since_last_response !== undefined) {
71 lastResponseAt = (await $.clock.now()) - e.seconds_since_last_response * 1000
72 previousTokens = e.context_tokens ?? 0
73 }
74
75 return next(e)
76 })
77
78 on('session.end', ($, e, next) => {
79 tick?.cancel()
80 tick = undefined
81 poll?.cancel()
82 poll = undefined
83 bus = null
84
85 return next(e)
86 })
87
88 on('classic.PostModelSwitch', async ($, e, next) => {
89 detected = e.cache_ttl
90 await $.store.set(STORE_TTL_KEY, detected)
91
92 return next(e)
93 })
94
95 // `e.agentId` on a tool.call is the subagent loop's id, not the tool's own `agentId` argument, so the
96 // identity is never read off the event: a join only says to look again, soon.
97 on('tool.call', { tool: 'mcp__agent-coord__join' }, async ($, e, next) => {
98 const ran = await next(e)
99
100 if (ran.deny === undefined && ran.isError !== true) {
101 bus = null
102 isBusChecked = false
103 busDueAt = 0
104 busDelayMs = BUS_MS
105 }
106
107 return ran
108 })
109
110 on('tool.call', { tool: 'mcp__agent-coord__unregister' }, ($, e, next) => {
111 bus = null
112
113 return next(e)
114 })
115
116 // A PR changes when this agent pushes or runs gh, so that is when the count is worth refetching early.
117 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
118 if (/\bgh\s+pr\b|\bgit\s+push\b/.test(e.command)) {
119 prsDueAt = Math.min(prsDueAt, (await $.clock.now()) + PR_SETTLE_MS)
120 }
121
122 return next(e)
123 })
124
125 on('prompt.submit', async ($, e, next) => {
126 gapBeforeTurn = lastResponseAt === null ? null : (await $.clock.now()) - lastResponseAt
127
128 return next(e)
129 })
130
131 on('turn.step', async function* ($, e, next) {
132 for await (const chunk of next(e)) {
133 if (chunk.kind === 'stop' && chunk.usage) {
134 const { usage } = chunk
135
136 if (gapBeforeTurn !== null) {
137 const verdict = judgeTtl(gapBeforeTurn, previousTokens, usage)
138 lastCheck = describeCheck(gapBeforeTurn, previousTokens, usage)
139 gapBeforeTurn = null
140
141 if (verdict && verdict !== detected) {
142 detected = verdict
143 await $.store.set(STORE_TTL_KEY, verdict)
144 }
145 }
146
147 previousTokens = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
148 lastResponseAt = await $.clock.now()
149 $.ui.invalidate('ui.render')
150 }
151
152 yield chunk
153 }
154 })
155
156 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
157 if (e.props.hasSurvey) {
158 return next(e)
159 }
160
161 // Drawing must not depend on the timers, so a failure to start them is not a failure to draw.
162 try {
163 tick ??= $.clock.every(1000, () => {
164 if (isTicking) {
165 $.ui.invalidate('ui.render')
166 }
167 })
168
169 const runPoll = async () => {
170 const now = await $.clock.now()
171 let isChanged = false
172
173 if (showBus && now >= busDueAt) {
174 const seen = JSON.stringify(bus)
175 let outcome: 'ok' | 'none' | 'failed' = 'failed'
176
177 // `whoami` answers in one read-only call; a server without it falls back to the id in this session's
178 // own `join` call (read once from the transcript) plus `status` and `list_rooms`.
179 if (hasWhoami !== false) {
180 const call = await $.mcp.call('agent-coord', 'whoami').catch(() => null)
181 const me = call && parseJsonBlock<WhoamiView>(call.content, call.isError)
182
183 if (me) {
184 hasWhoami = true
185 bus = me.bound && me.agentId ? { agentId: me.agentId, rooms: me.rooms ?? [], unread: me.inboxUnread ?? null } : null
186 outcome = bus ? 'ok' : 'none'
187 } else if (call?.isError) {
188 hasWhoami = false
189 }
190 }
191
192 if (hasWhoami === false) {
193 if (!bus && !isBusChecked) {
194 isBusChecked = true
195 const joined = findJoin(await $.session.messages({ as: 'api' }))
196 bus = joined ? { agentId: joined.agentId, rooms: [], unread: null } : null
197 }
198
199 const seat = bus
200
201 if (!seat) {
202 outcome = 'none'
203 } else {
204 const [statusCall, roomsCall] = await Promise.all([
205 $.mcp.call('agent-coord', 'status', { agentId: seat.agentId }).catch(() => null),
206 $.mcp.call('agent-coord', 'list_rooms').catch(() => null),
207 ])
208 const status = statusCall && parseJsonBlock<{ registered?: boolean; inboxUnread?: number }>(statusCall.content, statusCall.isError)
209 const listing = roomsCall && parseJsonBlock<{ rooms?: { room: string; members?: string[] }[] }>(roomsCall.content, roomsCall.isError)
210
211 if (status?.registered === false) {
212 bus = null
213 outcome = 'none'
214 } else if (status || listing) {
215 bus = {
216 ...seat,
217 unread: status?.inboxUnread ?? seat.unread,
218 rooms: listing?.rooms?.filter(r => r.members?.includes(seat.agentId)).map(r => r.room) ?? seat.rooms,
219 }
220 outcome = 'ok'
221 }
222 }
223 }
224
225 // No bus on this machine backs off instead of asking every 15 seconds forever; a `join` resets it.
226 busDelayMs = outcome === 'ok' ? BUS_MS : outcome === 'none' ? BUS_IDLE_MS : backoff(busDelayMs, BUS_IDLE_MS, BACKOFF_MAX_MS)
227 busDueAt = now + busDelayMs
228 isChanged ||= JSON.stringify(bus) !== seen
229 }
230
231 if (showUsage && now >= usageDueAt) {
232 usageDueAt = now + USAGE_MS
233 const usage = await $.session.usage().catch(() => null)
234
235 if (usage) {
236 const seen = JSON.stringify([todayUsd, limits])
237 const day = localDay(now)
238 const keyPrefix = `${STORE_COST_PREFIX}${day}:`
239 const sessionUsd = usage.cost?.usd
240 limits = usage.rateLimits
241
242 if (sessionUsd === undefined) {
243 todayUsd = null
244 } else {
245 const ownKey = `${keyPrefix}${await $.session.id()}`
246 const delta = lastSessionUsd === null ? 0 : costDelta(lastSessionUsd, sessionUsd)
247 lastSessionUsd = sessionUsd
248 ownUsd = ownDay === day ? ownUsd + delta : delta
249 ownDay = day
250
251 if (delta > 0) {
252 await $.store.set(ownKey, ownUsd)
253 }
254
255 // The other sessions' spend changes slowly, so it is re-read every couple of minutes, not every poll.
256 if (othersDay !== day || now >= othersDueAt) {
257 othersDay = day
258 othersDueAt = now + OTHER_SESSIONS_MS
259 const keys = await $.store.keys()
260
261 if (!isUsagePruned) {
262 isUsagePruned = true
263 const cutoff = localDay(now - COST_KEEP_DAYS * 24 * 60 * 60_000)
264 const dateAt = STORE_COST_PREFIX.length
265
266 await Promise.all(
267 keys
268 .filter(key => key.startsWith(STORE_COST_PREFIX) && key.slice(dateAt, dateAt + 10) < cutoff)
269 .map(key => $.store.delete(key)),
270 )
271 }
272
273 const amounts = await Promise.all(
274 keys.filter(key => key.startsWith(keyPrefix) && key !== ownKey).map(key => $.store.get(key)),
275 )
276 othersUsd = amounts.reduce<number>((sum, value) => sum + (typeof value === 'number' ? value : 0), 0)
277 }
278
279 todayUsd = othersUsd + ownUsd
280 }
281
282 isChanged ||= seen !== JSON.stringify([todayUsd, limits])
283 }
284 }
285
286 if (showPrs && now >= prsDueAt) {
287 const repo = await $.session.repo()
288
289 if (!repo?.remote) {
290 isChanged ||= prs !== null
291 prs = null
292 prsDelayMs = PR_REFRESH_MS
293 } else {
294 const ran = await $.process
295 .run(['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,isDraft'], {
296 cwd: repo.root,
297 timeoutMs: 15_000,
298 })
299 .catch(() => null)
300 const counted = ran?.exitCode === 0 ? parsePrs(ran.stdout) : null
301
302 prsDelayMs = counted ? PR_REFRESH_MS : backoff(prsDelayMs, PR_REFRESH_MS, BACKOFF_MAX_MS)
303 isChanged ||= counted !== null && (counted.open !== prs?.open || counted.drafts !== prs?.drafts)
304 prs = counted ?? prs
305 }
306
307 prsDueAt = now + prsDelayMs
308 }
309
310 if (isChanged) {
311 $.ui.invalidate('ui.render')
312 }
313 }
314
315 poll ??= $.clock.every(POLL_MS, runPoll)
316
317 if (!hasPolledOnce) {
318 hasPolledOnce = true
319 $.clock.after(500, runPoll)
320 }
321 } catch {
322 // see above
323 }
324
325 if (!isDetectedLoaded) {
326 isDetectedLoaded = true
327 const stored = await $.store.get(STORE_TTL_KEY).catch(() => undefined)
328 detected ??= isTtl(stored) ? stored : null
329 }
330
331 const { Box, Text } = $.ui.resolve(e)
332 const ttl = configured ?? detected ?? '5m'
333 const remainingMs = lastResponseAt === null ? 0 : TTL_MS[ttl] - ((await $.clock.now()) - lastResponseAt)
334 const cacheState: CacheState =
335 lastResponseAt === null
336 ? { kind: 'waiting' }
337 : e.props.isWorking
338 ? { kind: 'working' }
339 : remainingMs <= 0
340 ? { kind: 'expired' }
341 : { kind: 'ticking', remainingMs, ttl, source: configured ? 'set' : detected ? 'detected' : 'assumed', lastCheck }
342 isTicking = cacheState.kind === 'ticking'
343
344 const usage = showUsage ? usageSegment(todayUsd, limits) : null
345 const width = Math.max(20, (e.props.bodyColumns || e.viewport?.columns || 100) - 2)
346 const rows = layoutRows(width, [
347 cacheSegment(cacheState),
348 ...(usage ? [usage] : []),
349 ...(showPrs && prs ? [prSegment(prs)] : []),
350 ...(showBus && bus ? [busSegment(bus)] : []),
351 ])
352
353 return (
354 <Box flexDirection="column">
355 {rows.map(row => (
356 <Box>
357 {row.map(piece => (
358 <Text color={piece.color} dimColor={piece.dim} bold={piece.bold}>
359 {piece.text}
360 </Text>
361 ))}
362 </Box>
363 ))}
364 </Box>
365 )
366 })
367}
368hooks/bus.ts 78 lines1import type { Segment } from './pieces'
2
3export type Bus = {
4 agentId: string
5 rooms: readonly string[]
6 unread: number | null
7}
8
9export type WhoamiView = { bound?: boolean; agentId?: string; rooms?: string[]; inboxUnread?: number }
10
11export type McpBlock = { type: string; text?: string }
12
13export const parseJsonBlock = <T,>(content: readonly McpBlock[], isError: boolean) => {
14 const block = content[0]
15
16 return isError || block?.type !== 'text' || !block.text ? null : (JSON.parse(block.text) as T)
17}
18
19const roleLabel = (role: unknown) => {
20 if (typeof role === 'string') {
21 return role
22 }
23
24 if (role && typeof role === 'object') {
25 const { displayName, roleId } = role as { displayName?: unknown; roleId?: unknown }
26
27 return typeof displayName === 'string' ? displayName : typeof roleId === 'string' ? roleId : undefined
28 }
29
30 return undefined
31}
32
33type JoinCall = { agentId: string; role?: string }
34
35/** The seat this transcript last joined the bus as, or null if it never did or has since unregistered. */
36export const findJoin = (messages: readonly { content?: unknown }[]): JoinCall | null => {
37 let seat: JoinCall | null = null
38
39 for (const message of messages) {
40 if (!Array.isArray(message.content)) {
41 continue
42 }
43
44 for (const block of message.content as { type?: string; name?: string; input?: Record<string, unknown> }[]) {
45 if (block?.type !== 'tool_use') {
46 continue
47 }
48
49 if (block.name === 'mcp__agent-coord__join' && typeof block.input?.agentId === 'string') {
50 seat = { agentId: block.input.agentId, role: roleLabel(block.input.role) }
51 } else if (block.name === 'mcp__agent-coord__unregister') {
52 seat = null
53 }
54 }
55 }
56
57 return seat
58}
59
60export const busLine = (bus: Bus) =>
61 [
62 bus.agentId,
63 bus.rooms.map(room => `#${room}`).join(' ') || 'no room',
64 bus.unread === null ? null : bus.unread === 0 ? 'inbox clear' : `${bus.unread} unread`,
65 ]
66 .filter(Boolean)
67 .join(' · ')
68
69export const busSegment = (bus: Bus): Segment => {
70 const rooms = bus.rooms.map(room => `#${room}`).join(' ')
71 const unread = bus.unread ? ` ✉${bus.unread}` : ''
72
73 return {
74 long: [{ text: '●', color: 'success' }, { text: ` bus: ${busLine(bus)}`, dim: true }],
75 short: [{ text: '●', color: 'success' }, { text: ` ${bus.agentId}${rooms ? ` ${rooms}` : ''}${unread}`, dim: true }],
76 }
77}
78hooks/cache.ts 104 lines1import type { Piece, Segment } from './pieces'
2
3export const BAR_CELLS = 20
4export const SHORT_BAR_CELLS = 10
5export const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
6const MARGIN_MS = 30_000
7
8export type Ttl = keyof typeof TTL_MS
9
10export type RequestUsage = {
11 input_tokens: number
12 cache_read_input_tokens: number
13 cache_creation_input_tokens: number
14}
15
16export const isTtl = (value: unknown): value is Ttl => value === '5m' || value === '1h'
17
18export const formatRemaining = (ms: number) => {
19 const total = Math.max(0, Math.ceil(ms / 1000))
20 const minutes = Math.floor(total / 60)
21 const seconds = total % 60
22
23 return `${minutes}:${String(seconds).padStart(2, '0')}`
24}
25
26export const barColor = (fraction: number) => (fraction > 0.5 ? 'success' : fraction > 0.2 ? 'warning' : 'error')
27
28/**
29 * Reads the TTL off the first request after an idle gap. If the previous context was still served from
30 * cache after more than 5 minutes the cache lives an hour; if a good part of it was written afresh within
31 * the hour it lives 5 minutes (a shared system-prompt prefix can stay cached either way, so the rebuilt
32 * share is what counts, not the cached one). Anything else (short gap, nothing to compare, an hour or
33 * more, a mixed result) says nothing.
34 */
35export const judgeTtl = (gapMs: number, previousTokens: number, usage: RequestUsage): Ttl | null => {
36 if (previousTokens <= 0 || gapMs <= TTL_MS['5m'] + MARGIN_MS) {
37 return null
38 }
39
40 if (usage.cache_read_input_tokens / previousTokens >= 0.8) {
41 return '1h'
42 }
43
44 const isRebuilt = usage.cache_creation_input_tokens / previousTokens >= 0.3
45
46 return isRebuilt && gapMs < TTL_MS['1h'] - MARGIN_MS ? '5m' : null
47}
48
49export const describeCheck = (gapMs: number, previousTokens: number, usage: RequestUsage) => {
50 const percent = (tokens: number) => (previousTokens > 0 ? Math.round((tokens / previousTokens) * 100) : 0)
51
52 return `last check: idle ${formatRemaining(gapMs)}, ${percent(usage.cache_read_input_tokens)}% read, ${percent(usage.cache_creation_input_tokens)}% written`
53}
54
55const barPieces = (fraction: number, cells: number, color: string): Piece[] => {
56 const filled = Math.max(1, Math.ceil(fraction * cells))
57
58 return [{ text: '█'.repeat(filled), color }, { text: '░'.repeat(cells - filled), dim: true }]
59}
60
61export type CacheState =
62 | { kind: 'waiting' }
63 | { kind: 'working' }
64 | { kind: 'expired' }
65 | { kind: 'ticking'; remainingMs: number; ttl: Ttl; source: string; lastCheck: string | null }
66
67export const cacheSegment = (state: CacheState): Segment => {
68 switch (state.kind) {
69 case 'waiting':
70 return {
71 long: [{ text: 'Cache timer: starts after the next response', dim: true }],
72 short: [{ text: 'cache: waiting', dim: true }],
73 }
74 case 'working':
75 return {
76 long: [{ text: 'Cache: refreshing while the model works', dim: true }],
77 short: [{ text: 'cache: refreshing', dim: true }],
78 }
79 case 'expired':
80 return {
81 long: [
82 { text: '░'.repeat(BAR_CELLS), color: 'error' },
83 { text: ' Cache expired: next prompt re-caches the context at a higher cost', color: 'error', bold: true },
84 ],
85 short: [{ text: 'Cache expired: next prompt costs more', color: 'error', bold: true }],
86 }
87 case 'ticking': {
88 const fraction = state.remainingMs / TTL_MS[state.ttl]
89 const color = barColor(fraction)
90 const time: Piece = { text: ` ${formatRemaining(state.remainingMs)}`, color }
91 const check = state.lastCheck && state.source === 'assumed' ? `; ${state.lastCheck}` : ''
92
93 return {
94 long: [
95 ...barPieces(fraction, BAR_CELLS, color),
96 time,
97 { text: ` until prompt cache expires (${state.ttl} ${state.source}${check})`, dim: true },
98 ],
99 short: [...barPieces(fraction, SHORT_BAR_CELLS, color), time, { text: ' cache', dim: true }],
100 }
101 }
102 }
103}
104hooks/pieces.ts 47 lines1export type Piece = { text: string; color?: string; dim?: boolean; bold?: boolean }
2
3/** One status item in a long and a short form; the layout picks the form that fits. */
4export type Segment = { long: readonly Piece[]; short: readonly Piece[] }
5
6type Form = keyof Segment
7
8const SEPARATOR: Piece = { text: ' │ ', dim: true }
9
10export const widthOf = (pieces: readonly Piece[]) => pieces.reduce((total, piece) => total + [...piece.text].length, 0)
11
12const joinRow = (pieces: readonly (readonly Piece[])[]) =>
13 pieces.flatMap((row, index) => (index === 0 ? [...row] : [SEPARATOR, ...row]))
14
15/**
16 * Fits the segments (cache first, then the optional ones) into as few rows as the width allows,
17 * shortening the extras before the cache. Falls back to one row each, shortened only if it must.
18 */
19export const layoutRows = (width: number, segments: readonly Segment[]): Piece[][] => {
20 const fits = (rows: readonly (readonly Piece[])[]) => rows.every(row => widthOf(row) <= width)
21 const oneRow = (forms: readonly Form[]) => [joinRow(segments.map((segment, i) => segment[forms[i] ?? 'short']))]
22 const [cache, ...extras] = segments
23 const all = (form: Form) => segments.map(() => form)
24 const plans: (() => Piece[][])[] = [
25 () => oneRow(all('long')),
26 () => oneRow(segments.map((_, i) => (i === 0 ? 'long' : 'short'))),
27 () => oneRow(all('short')),
28 ]
29
30 if (cache && extras.length > 0) {
31 plans.push(
32 () => [[...cache.long], joinRow(extras.map(segment => segment.long))],
33 () => [[...cache.long], joinRow(extras.map(segment => segment.short))],
34 )
35 }
36
37 for (const plan of plans) {
38 const rows = plan()
39
40 if (fits(rows)) {
41 return rows
42 }
43 }
44
45 return segments.map(segment => (widthOf(segment.long) <= width ? [...segment.long] : [...segment.short]))
46}
47hooks/prs.ts 28 lines1import type { Segment } from './pieces'
2
3export type PrCount = { open: number; drafts: number }
4
5export const parsePrs = (text: string): PrCount | null => {
6 try {
7 const prs: unknown = JSON.parse(text)
8
9 return Array.isArray(prs)
10 ? { open: prs.length, drafts: prs.filter(pr => pr?.isDraft === true).length }
11 : null
12 } catch {
13 return null
14 }
15}
16
17export const prLine = ({ open, drafts }: PrCount) =>
18 `${open} open PR${open === 1 ? '' : 's'}${drafts > 0 ? ` (${drafts} draft)` : ''}`
19
20export const prSegment = (prs: PrCount): Segment => {
21 const color = prs.open > 0 ? 'warning' : 'success'
22
23 return {
24 long: [{ text: '⎇', color }, { text: ` ${prLine(prs)}`, dim: true }],
25 short: [{ text: '⎇', color }, { text: ` ${prs.open} PR${prs.open === 1 ? '' : 's'}`, dim: true }],
26 }
27}
28hooks/schedule.ts 4 lines1/** The next wait after a failure: double the last one, never below `baseMs` and never above `maxMs`. */
2export const backoff = (previousMs: number, baseMs: number, maxMs: number) =>
3 Math.min(maxMs, Math.max(baseMs, previousMs * 2))
4hooks/usage.ts 60 lines1import type { Piece, Segment } from './pieces'
2
3export type PlanLimit = { kind: string; percentUsed: number }
4
5/** What a session spent since the last reading; a counter that went backwards (a clear) restarts from zero. */
6export const costDelta = (previousUsd: number, currentUsd: number) =>
7 currentUsd >= previousUsd ? currentUsd - previousUsd : currentUsd
8
9export const localDay = (ms: number) => {
10 const date = new Date(ms)
11
12 return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
13}
14
15export const formatUsd = (usd: number) => (usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
16
17const LIMIT_LABELS: Readonly<Record<string, string>> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
18
19export const limitLabel = (kind: string) => LIMIT_LABELS[kind] ?? kind
20
21export const limitColor = (percentUsed: number) =>
22 percentUsed < 60 ? 'success' : percentUsed < 85 ? 'warning' : 'error'
23
24const percentText = (percentUsed: number) => `${Math.round(percentUsed)}%`
25
26const gap = (pieces: readonly Piece[]): Piece[] => (pieces.length > 0 ? [{ text: ' · ', dim: true }] : [])
27
28export const usageSegment = (todayUsd: number | null, limits: readonly PlanLimit[]): Segment | null => {
29 if (todayUsd === null && limits.length === 0) {
30 return null
31 }
32
33 const cost: Piece[] = todayUsd === null ? [] : [{ text: formatUsd(todayUsd) }, { text: ' today', dim: true }]
34 const busiest = limits.reduce<PlanLimit | null>(
35 (worst, limit) => (!worst || limit.percentUsed > worst.percentUsed ? limit : worst),
36 null,
37 )
38
39 return {
40 long: [
41 ...cost,
42 ...limits.flatMap((limit, index) => [
43 ...(index === 0 ? gap(cost) : [{ text: ' ', dim: true }]),
44 { text: `${limitLabel(limit.kind)} `, dim: true },
45 { text: percentText(limit.percentUsed), color: limitColor(limit.percentUsed) },
46 ]),
47 ],
48 short: [
49 ...(todayUsd === null ? [] : [{ text: formatUsd(todayUsd) }]),
50 ...(busiest
51 ? [
52 ...gap(todayUsd === null ? [] : cost),
53 { text: `${limitLabel(busiest.kind)} `, dim: true },
54 { text: percentText(busiest.percentUsed), color: limitColor(busiest.percentUsed) },
55 ]
56 : []),
57 ],
58 }
59}
60