SLOPSHOPPER

status-band

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

bandcommandprompttimer
v0.4.0no licenseupdated 2026-10-16sertacdincel-byte/claude-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 › /band ⎿ status-band: Details open. ⟨Claude Code's own drawing⟩ ● Context 49% │ ● 5h 31% │ ● Week –Session ≈ $0.42 ▴ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ Right now │ │ Live rate 0 new tokens / min peak 0/min │ │ Last 15 min ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ finished 0s ago │ │ This turn 0 new tokens │ │ │ │ Limits │ │ Context █████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 97k / 200k │ │ 5-hour ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ resets in NaNd NaNh │ │ Weekly ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ no reading yet │ │ │ │ Cache │ │ Status Not warm yet — fills on the next step TTL 1h │ │ │ │ This session │ │ New tokens 0 you sent + 0 Claude wrote real work │ │ Re-reads 0 · conversation re-sent each step cheap, from cache │ │ Cost ≈ $0.42 total │ │ │ │ ⚙ Settings ▾ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ● Context 49% │ ● 5h 31% │ ● Week –Session ≈ $0.42 ▴ ╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ │ Right now │ Live rate 0 new tokens / min peak 0/min │ Last 15 min ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ finished 0s ago │ This turn 0 new tokens │ │ Limits │ Context █████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 97k / 200k │ 5-hour ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ resets in NaNd NaNh │ Weekly ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ no reading yet │ │ Cache │ Status Not warm yet — fills on the next step TTL 1h │ │ This session │ New tokens 0 you sent + 0 Claude wrote real work │ Re-reads 0 · conversation re-sent each step cheap, from cache │ Cost ≈ $0.42 total │ │ ⚙ Settings ▾ ╰───────────────────────────────────────────────────────────────────────────────────────────────────
README

status-band

A Claude Code mod that puts one plain status line above the prompt:

● Context 12% │ ● 5h 4% │ ● Week 1% │ ✦ Advisor 3        ◆ Cache warm 57:57 · ≈ $0.46  ▾

Preview

The band, idle and right after an advisor call:

The status band above the prompt, idle and right after an advisor call

Opened with /band:

The opened details panel with cache, session and advisor sections

Choose what shows by clicking the chips under ⚙ Settings (here Cost is switched off, so the band shows no dollar figure):

The Settings section: a chip for every switch, with Cost switched off

These are illustrations of the band, drawn with sample numbers, not screenshots.

What it shows

  • Gauges: context window, 5-hour limit and weekly limit, colored green / amber / red.
  • Right side, one thing at a time: a warning when something needs attention, otherwise the advisor note, the live new-token rate while Claude works, or the prompt-cache countdown and session cost.
  • Advisor (✦ Advisor N): shows once the advisor has been consulted. For a few seconds after a call it reads ✦ Advisor consulted · #3 · 6s.
  • Details: /band (or the ▾ button) opens limits, cache, session token totals and an Advisor section with call count, time spent, a 60-minute trail and the last calls. At the bottom, ⚙ Settings opens a chip for every switch; see Settings.
  • /band hide and /band show turn the band off and on.

Settings

Everything on the band can be switched on or off; all of it is on by default.

SettingWhat it controls
showContextContext gauge
show5h5-hour limit gauge
showWeekWeekly limit gauge
showAdvisorAdvisor count, the "Advisor consulted" note and the Advisor section in the details
showCacheCache countdown on the right
showCostEvery ≈ $ figure, on the band and in the details
showRateLive new-token rate while Claude works (off: the cache countdown shows instead)
showAlertsWarnings: context nearly full, a limit running out, the cache about to expire
detailsNowDetails section "Right now"
detailsLimitsDetails section "Limits"
detailsCacheDetails section "Cache"
detailsSessionDetails section "This session"
advisorFlashSecondsSeconds the band says "Advisor consulted" after a call (default 12, 0 turns the note off)

Click them. Open the details (/band, or the ▾ button) and press ⚙ Settings. Every switch is a chip: ● is on, ○ is off. Click one and it takes effect at once; the advisor-note chip steps through 0 (off), 5, 12, 30 and 60 seconds, and Reset to defaults puts everything back. /band settings opens the details with the settings showing.

Or type them, in any session (this works in the desktop app too):

/band set                       list every setting and its value
/band set showCost off
/band set advisorFlashSeconds 30
/band reset                     back to the defaults

Values are on / off for switches and a number of seconds for advisorFlashSeconds. Your choices are saved in the mod's own store, so they are still there in the next session.

If every item on the band is off, the band draws no row at all; /band still opens the details, and /band hide hides everything.

Install

You need a Claude Code build that supports mods (written and tested on 2.1.299). In a terminal session:

/plugin install status-band --marketplace sertacdincel-byte/claude-status-band

Answer y to add the marketplace, then pick a scope. From a shell:

claude plugin install status-band --marketplace sertacdincel-byte/claude-status-band

This repository is private, so GitHub access must be set up on your computer (for example gh auth login, or an SSH key on an account that has been invited to the repository). Restart Claude Code after installing.

Install from a folder instead

Clone the repository, then either run once with

claude --plugin-dir <path-to-clone>

or load it every time by adding the folder to ~/.claude/settings.json (several folders are separated by ;):

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:/path/to/claude-status-band"
  }
}

What it can and can't show about the advisor

The API reports an advisor call's name, start and end, so the band shows the call count, how long each call ran and when. It does not report the advisor's tokens or whether it "reviewed" or "declined". The advisor's cost is in the ≈ $ total; its tokens aren't broken out.

The band updates when the step that carried the call comes back, not while the advisor is running, so "Advisor consulted" is after the fact. The Ctrl+O to read hint appears on the terminal surface only.

Develop

claude plugin validate .
claude plugin test .

hooks/register.tsx is the whole mod, hooks/register.test.ts its tests, types/index.d.ts the contract for the values it keeps.

Türkçe özet

Prompt'un üstünde tek satırlık durum bandı: context, 5 saatlik ve haftalık limit, cache geri sayımı ve advisor çağrı sayısı. Kurulum için yukarıdaki /plugin install satırını kullanın; depo private olduğu için GitHub erişiminiz olmalı. Kurduktan sonra Claude Code'u yeniden başlatın. Detaylar için /band.

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