SLOPSHOPPER

status-band

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

newbandguardpromptprocesstimer
★ 1v0.2.0no licenseupdated 2026-10-06davidbalzan/status-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Cache timer: starts after the next response ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Cache timer: starts after the next response
README

status-band

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
SegmentShows
Cache countdownTime 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 limitsToday's cost across all your sessions, and your plan-limit windows (5h, 7d) as percentages, coloured by how full they are.
Open PRsOpen PRs in the current repo (gh pr list), with drafts counted.
Coord busThis 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.

Install

/plugin marketplace add davidbalzan/status-band
/plugin install status-band@status-band

From a local clone, use its path instead of davidbalzan/status-band.

Options

Set under /plugin configure status-band@status-band.

OptionDefault
ttlautoauto detects the cache lifetime; 5m or 1h forces it.
showUsageonCost and plan limits.
showPrsonOpen PRs. Needs the gh CLI, signed in.
showBusonCoord-bus row.

How it works, and what it costs

  • A one-second redraw runs only while the countdown is on screen. One 15-second poll serves the other segments, each on its own schedule, and redraws only when a value changed.
  • The bus row asks the bus server's read-only 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.
  • Open PRs: one gh pr list call every 5 minutes, sooner after this session runs gh pr or git push. A failing call backs off.
  • Today's cost: each session adds what it spent to its own store key and the total sums today's keys, so concurrent sessions never overwrite each other. Spend from before the mod loaded in a session is not counted, and sessions without the mod are not counted.
  • None of it goes through the model.

Limits

  • Terminal and desktop surfaces only (the band is not drawn on others).
  • Plan-limit windows appear only when the API reports them (subscription accounts).
  • TTL detection needs one prompt sent after more than five and a half minutes idle; until then the label says assumed.

Development

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.

Source 7 files
hooks/register.tsx 368 lines
1import 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}
368
hooks/bus.ts 78 lines
1import 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}
78
hooks/cache.ts 104 lines
1import 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}
104
hooks/pieces.ts 47 lines
1export 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}
47
hooks/prs.ts 28 lines
1import 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}
28
hooks/schedule.ts 4 lines
1/** 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))
4
hooks/usage.ts 60 lines
1import 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