SLOPSHOPPER

context-watcher

Band above the prompt showing the model, context fill and the 5-hour usage window while a prompt runs; hides itself 10 min after.

newbandprompttimeragents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-watcher
› 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 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context · Opus 5.5 97k / 200k · 49% │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ Usage ──────────────────────────────────────────────────────── 31% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context · Opus 5.5 97k / 200k · 49% │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ Usage ──────────────────────────────────────────────────────── 31% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

context-watcher

Part of Watcher. A card above the prompt shows the model and its effort, a context bar (it shimmers as context grows and runs backwards on compaction), and the 5-hour usage window with pace. The card hides 10 minutes after a turn ends.

What it touches

Watcher draws on screen only. It sends nothing anywhere, starts no programs, reads no files, holds no credentials, and adds nothing to the model's context.

HookWhat it does
session.measureReads context size and the usage-window percentage that Claude Code already reports. Nothing is changed.
turn.stepReads the effort level of the main loop and of subagents. The step passes through unchanged.
agent.spawnNotes a subagent's model for the model carousel. The call passes through unchanged.
prompt.submitBrings the card up and lifts the pause key (h). The prompt passes through unchanged.
session.start, turn.complete, ui.*Restore and draw the card.

Settings: lingerMinutes (10) and showUsagePace (on).

Source 2 files
hooks/register.tsx 537 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Meter, Shown } from '../types'
5
6// How long the band lingers after a prompt's turn ends.
7// Set from the plugin's settings when it loads (see plugin.json userConfig).
8let LINGER_MS = 10 * 60 * 1000
9let SHOW_PACE = true
10// A changed figure glides to its new value: one step per frame, a quarter of the gap each time.
11const GLIDE_MS = 40
12const CAROUSEL_MS = 2500
13const CLAY = '#D97757'
14const CONTEXT = '#5B8DEF'
15const WARN = '#E0A526'
16const HOT = '#D0453F'
17// Each family keeps its own tint in the carousel.
18const FAMILY_TINT: Record<string, string> = { Opus: '#D97757', Sonnet: '#5B8DEF', Haiku: '#3B9C5F', Fable: '#8F8CF4' }
19
20const meter = atom({ plugin: 'context-watcher', key: 'meter' } as const, {
21  model: '',
22  agentModels: {},
23  contextTokens: null,
24  contextWindow: 0,
25  contextPercent: null,
26  usagePercent: null,
27  usageResetsAt: null,
28  isActive: false,
29  isVisible: false,
30} as Meter)
31const tick = atom({ plugin: 'context-watcher', key: 'tick' } as const, 0)
32const isPaused = atom({ plugin: 'context-watcher', key: 'isPaused' } as const, false)
33// What the bars show right now, easing toward the meter's figures.
34const shown = atom({ plugin: 'context-watcher', key: 'shown' } as const, { context: 0, usage: 0, carousel: 0, carouselAt: 0, breath: 0, contextGlowAt: 0, usageGlowAt: 0 } as Shown)
35
36// A bar that grows starts bright and fades back to its color over this long.
37const GLOW_MS = 1000
38
39// Mix a hex color toward white by t (0 = the color, 1 = white).
40const brighten = (hex: string, t: number) => {
41  const n = parseInt(hex.slice(1), 16)
42  const ch = (shift: number) => {
43    const c = (n >> shift) & 255
44    return Math.round(c + (255 - c) * t).toString(16).padStart(2, '0')
45  }
46  return '#' + ch(16) + ch(8) + ch(0)
47}
48
49const glowColor = (base: string, glowAt: number, now: number) => {
50  const age = now - glowAt
51  return age >= 0 && age < GLOW_MS ? brighten(base, 0.6 * (1 - age / GLOW_MS)) : base
52}
53
54// When context grows, the big bar shimmers once: an ultracode-style band of shifting colors
55// runs across the fill and fades out. Native text, so nothing flickers; the glide timer drives it.
56const SHIMMER_MS = 1600
57const ULTRA = ['#D97757', '#E0A526', '#D65DB1', '#8F8CF4', '#5B8DEF']
58const HEAD = 6 // half-width of the shimmering band, in cells
59
60// The bar's design: blue blocks, then the dotted ░ rest; the figures above end where it ends.
61// Measured on desktop, in layout cells (with a little margin): a █ draws about 1.62 wide, a ░
62// about 1.26, a ━ or ─ about 1.55.
63const BLOCK_W = 1.53
64const DOT_W = 1.17 // measured from screenshots at 9% and 47%
65const LINE_W = 1.6
66
67// The filled cells as runs of one color each: the base color, or the shimmer near its head.
68// `isReverse`: the band runs right to left (context compacted) instead of left to right (it grew).
69const shimmerRuns = (fill: number, base: string, age: number, isReverse = false) => {
70  const runs: { color: string; text: string }[] = []
71  const isOn = age >= 0 && age < SHIMMER_MS
72  const travel = (age / SHIMMER_MS) * (fill + HEAD * 4)
73  const head = isReverse ? fill + HEAD * 2 - travel : travel - HEAD * 2
74  const fade = 1 - age / SHIMMER_MS
75  for (let i = 0; i < fill; i++) {
76    let color = base
77    const d = Math.abs(i - head)
78    if (isOn && d < HEAD) {
79      const hue = ULTRA[Math.floor((i + age / 120) / 2) % ULTRA.length] ?? base
80      // Quantized so neighbouring cells share a color and the row stays a few runs.
81      color = brighten(hue, Math.round((1 - d / HEAD) * fade * 3) / 6)
82    }
83    const last = runs[runs.length - 1]
84    if (last && last.color === color) {
85      last.text += '█'
86    } else {
87      runs.push({ color, text: '█' })
88    }
89  }
90  return runs
91}
92
93// While the main model works its name breathes in its family color; idle, it rests in plain white.
94const BREATHE_MS = 300
95const BREATHE_STEPS = 8
96const breathe = (tint: string, frame: number) => {
97  const phase = (frame % BREATHE_STEPS) / BREATHE_STEPS
98  return brighten(tint, 0.35 * (0.5 + 0.5 * Math.cos(phase * 2 * Math.PI)))
99}
100
101// A new name in the carousel rises out of grey over FADE_MS (the breathing timer paints the frames).
102const FADE_MS = 900
103const fadeIn = (hex: string, age: number) => {
104  if (age < 0 || age >= FADE_MS) {
105    return hex
106  }
107  const t = 1 - age / FADE_MS
108  const n = parseInt(hex.slice(1), 16)
109  const ch = (shift: number) => Math.round(((n >> shift) & 255) * (1 - t) + 0x55 * t).toString(16).padStart(2, '0')
110  return '#' + ch(16) + ch(8) + ch(0)
111}
112
113const step = (from: number, to: number) => (Math.abs(to - from) < 0.3 ? to : from + (to - from) * 0.25)
114
115// A thin savvy-style track in text: heavy line for the fill, light line for the rest.
116const track = (percent: number, width: number) => {
117  const exact = (width * Math.max(0, Math.min(100, percent))) / 100
118  const full = Math.round(exact)
119  return { fill: '━'.repeat(full), rest: '─'.repeat(width - full) }
120}
121
122// "claude-opus-5-5" → "Opus 5.5"; anything unrecognised shows as it came.
123const modelName = (id: string) => {
124  const m = /(opus|sonnet|haiku|fable)[-_ ]?(\d+)(?:[-_.](\d+))?/i.exec(id)
125  if (!m || !m[1] || !m[2]) {
126    return id.replace(/^claude-/, '') || 'Claude'
127  }
128  const family = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase()
129  return `${family} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
130}
131
132const effortText = (v: unknown) => (typeof v === 'string' ? v : typeof v === 'number' ? `${v}` : '')
133
134const tintOf = (name: string) => FAMILY_TINT[name.split(' ')[0] ?? ''] ?? CLAY
135
136// The main model first, then each other model a running agent uses, once each.
137const modelsInUse = (m: Meter) => [...new Set([m.model, ...Object.values(m.agentModels)].filter(Boolean).map(modelName))]
138
139const formatCount = (n: number) =>
140  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
141
142const resetsIn = (iso: string | null, now: number) => {
143  if (!iso) {
144    return ''
145  }
146  const ms = Date.parse(iso) - now
147  if (!Number.isFinite(ms) || ms <= 0) {
148    return ''
149  }
150  const m = Math.round(ms / 60000)
151  return m >= 60 ? `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`
152}
153
154// Usage pace: how fast this 5-hour window is being spent since its first reading. Quiet until
155// there is enough to go on (10 minutes and 2 points), then the time to 100% at this rate.
156const PACE_MIN_MS = 10 * 60 * 1000
157const pace = (m: Meter, now: number) => {
158  const from = m.paceFrom
159  const pctNow = m.usagePercent
160  if (!from || pctNow === null || pctNow >= 100 || now - from.at < PACE_MIN_MS || pctNow - from.percent < 2) {
161    return null
162  }
163  const msPerPoint = (now - from.at) / (pctNow - from.percent)
164  const toFull = (100 - pctNow) * msPerPoint
165  const resetMs = m.usageResetsAt ? Date.parse(m.usageResetsAt) - now : Infinity
166  const isShort = Number.isFinite(resetMs) && toFull < resetMs
167  const m2 = Math.round(toFull / 60000)
168  const label = m2 >= 60 ? `${Math.floor(m2 / 60)}h ${String(m2 % 60).padStart(2, '0')}m` : `${m2}m`
169  return isShort ? { text: `full in ~${label}`, isShort } : { text: 'on pace', isShort }
170}
171
172// Calm until it matters: the base color, amber past 70%, red past 90%.
173const levelColor = (percent: number, base: string) => (percent >= 90 ? HOT : percent >= 70 ? WARN : base)
174
175
176export const register: Register = (on, options) => {
177  LINGER_MS = Math.max(1, Number(options.lingerMinutes ?? 10)) * 60 * 1000
178  SHOW_PACE = options.showUsagePace !== false
179  let hide: { cancel: () => void } | null = null
180  let pulse: { cancel: () => void } | null = null
181  let glide: { cancel: () => void } | null = null
182  let spin: { cancel: () => void } | null = null
183  let breathing: { cancel: () => void } | null = null
184
185  on('session.start', async ($, e, next) => {
186    const result = await next(e)
187    // A reload keeps the meter but drops timers: let a shown band still hide on time.
188    try {
189      const m = await read($, meter)
190      if (m.isVisible && !m.isActive) {
191        hide = $.clock.after(LINGER_MS, () => {
192          hide = null
193          void update($, meter, cur => ({ ...cur, isVisible: false })).catch(() => {})
194        })
195      }
196    } catch {}
197    return result
198  })
199
200  // The pause key (h, in any Watcher band) hides every band until the person's next prompt.
201  on('ui.press', { element: 'watcher-pause' }, async ($, e, next) => {
202    await update($, isPaused, () => true).catch(() => {})
203    return next(e)
204  }).catch(($, e, next) => next(e))
205
206  // A prompt from the user is the only thing that brings the band up.
207  on('prompt.submit', async ($, e, next) => {
208    try {
209      if (e.origin?.kind !== 'task-notification') {
210        await update($, isPaused, () => false)
211      }
212      hide?.cancel()
213      hide = null
214      const model = await $.session.model().catch(() => '')
215      const usage = await $.session.usage().catch(() => null)
216      const submittedAt = await $.clock.now()
217      // Entry: when the card comes up after being hidden, its bars start empty and grow to the
218      // figures while the colorful shimmer runs across them once.
219      if (!(await read($, meter)).isVisible) {
220        await update($, shown, v => ({ ...v, context: 0, usage: 0, contextGlowAt: submittedAt, usageGlowAt: submittedAt }))
221      }
222      await update($, meter, m => ({
223        ...m,
224        model: model || m.model,
225        contextTokens: usage?.context.tokens ?? m.contextTokens,
226        contextWindow: usage?.context.window ?? m.contextWindow,
227        contextPercent: usage?.context.percent ?? m.contextPercent,
228        ...usageOf(usage?.rateLimits ?? [], m, submittedAt),
229        isActive: true,
230        isVisible: true,
231      }))
232      // Refresh the reset countdown once a minute while shown.
233      if (!pulse) {
234        pulse = $.clock.every(60_000, () => {
235          void update($, tick, t => t + 1).catch(() => {})
236        })
237      }
238      // The name breathes while the model works (slow on purpose: every redraw rebuilds the whole band).
239      if (!breathing) {
240        breathing = $.clock.every(BREATHE_MS, () => {
241          void update($, shown, v => ({ ...v, breath: v.breath + 1 })).catch(() => {})
242        })
243      }
244      // Several models in use take turns in the name slot.
245      if (!spin) {
246        spin = $.clock.every(CAROUSEL_MS, () => {
247          void (async () => {
248            if (modelsInUse(await read($, meter)).length > 1) {
249              const at = await $.clock.now()
250              await update($, shown, v => ({ ...v, carousel: v.carousel + 1, carouselAt: at }))
251            }
252          })().catch(() => {})
253        })
254      }
255      // Glide the bars to the new figures and keep the carousel turning while shown.
256      if (!glide) {
257        glide = $.clock.every(GLIDE_MS, () => {
258          void (async () => {
259            const m = await read($, meter)
260            const cur = await read($, shown)
261            const context = step(cur.context, m.contextPercent ?? 0)
262            const usage = step(cur.usage, m.usagePercent ?? 0)
263            const now = await $.clock.now()
264            const isGlowing = now - cur.contextGlowAt < SHIMMER_MS || now - cur.usageGlowAt < GLOW_MS
265            if (context === cur.context && usage === cur.usage && !isGlowing) {
266              glide?.cancel()
267              glide = null
268              return
269            }
270            await update($, shown, v => ({ ...v, context, usage }))
271          })().catch(() => {})
272        })
273      }
274    } catch {}
275    return next(e)
276  }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
277
278  // Context and usage move: take the new figures, and mark a rise so it animates.
279  on('session.measure', async ($, e, next) => {
280    try {
281      const before = await read($, meter)
282      const after = { ...before, ...usageOf(e.rateLimits, before, await $.clock.now()) }
283      const contextTokens = e.context.tokens ?? before.contextTokens
284      // Nothing visible moved (same whole percent and same thousand tokens): skip the redraw.
285      const isSame =
286        Math.round(e.context.percent ?? -1) === Math.round(before.contextPercent ?? -1) &&
287        Math.round((contextTokens ?? 0) / 1000) === Math.round((before.contextTokens ?? 0) / 1000) &&
288        after.usagePercent === before.usagePercent
289      if (isSame) {
290        return next(e)
291      }
292      const glowNow = await $.clock.now()
293      const isContextUp = (e.context.percent ?? 0) > (before.contextPercent ?? 0)
294      // A sharp drop is a compaction: the shimmer runs back, right to left, as the bar shrinks.
295      const isCompacted = (before.contextPercent ?? 0) - (e.context.percent ?? 0) >= 5
296      const isUsageUp = (after.usagePercent ?? 0) > (before.usagePercent ?? 0)
297      if (isContextUp || isUsageUp || isCompacted) {
298        await update($, shown, v => ({
299          ...v,
300          contextGlowAt: isContextUp || isCompacted ? glowNow : v.contextGlowAt,
301          compactAt: isCompacted ? glowNow : v.compactAt,
302          usageGlowAt: isUsageUp ? glowNow : v.usageGlowAt,
303        }))
304      }
305      await update($, meter, m => ({
306        ...m,
307        contextTokens,
308        contextWindow: e.context.window || m.contextWindow,
309        contextPercent: e.context.percent ?? m.contextPercent,
310        usagePercent: after.usagePercent,
311        usageResetsAt: after.usageResetsAt,
312        paceFrom: after.paceFrom,
313      }))
314      // Glide the bars to the new figures and keep the carousel turning while shown.
315      if (!glide) {
316        glide = $.clock.every(GLIDE_MS, () => {
317          void (async () => {
318            const m = await read($, meter)
319            const cur = await read($, shown)
320            const context = step(cur.context, m.contextPercent ?? 0)
321            const usage = step(cur.usage, m.usagePercent ?? 0)
322            const now = await $.clock.now()
323            const isGlowing = now - cur.contextGlowAt < SHIMMER_MS || now - cur.usageGlowAt < GLOW_MS
324            if (context === cur.context && usage === cur.usage && !isGlowing) {
325              glide?.cancel()
326              glide = null
327              return
328            }
329            await update($, shown, v => ({ ...v, context, usage }))
330          })().catch(() => {})
331        })
332      }
333    } catch {}
334    return next(e)
335  })
336
337  // Agents join the carousel with their model while they run.
338  on('agent.spawn', async ($, e, next) => {
339    const result = await next(e)
340    try {
341      const id = result.agentId
342      const model = result.model
343      if (id && model) {
344        await update($, meter, m => ({ ...m, agentModels: { ...m.agentModels, [id]: model } }))
345      }
346    } catch {}
347    return result
348  }).catch(($, e, next) => next(e)) // display only: a failure never blocks Claude Code
349
350  // Each model request: note the effort it asks for (the main loop's, or an agent's by id).
351  on('turn.step', async function* ($, e, next) {
352    // Streams: forward every chunk untouched, then note the step once it is answered.
353    const result = yield* next(e)
354    try {
355      const effort = effortText(e.effort)
356      const id = e.agentId
357      if (id) {
358        await update($, meter, m => ({ ...m, agentEfforts: { ...(m.agentEfforts ?? {}), [id]: effort } }))
359      } else if (effort) {
360        await update($, meter, m => ({ ...m, effort }))
361      }
362    } catch {}
363    return result
364  }).catch(async function* ($, e, next) {
365    // display only: a failure never blocks Claude Code
366    return yield* next(e)
367  })
368
369  on('turn.complete', async ($, e, next) => {
370    const result = await next(e)
371    try {
372      if (e.agentId) {
373        const id = e.agentId
374        await update($, meter, m => {
375          const rest = { ...m.agentModels }
376          const efforts = { ...(m.agentEfforts ?? {}) }
377          delete efforts[id]
378          delete rest[id]
379          return { ...m, agentModels: rest, agentEfforts: efforts }
380        })
381        return result
382      }
383      // The main turn is done and the figures are final: settle, then hide after the linger.
384      const model = result.usage?.model ?? e.usage?.model
385      breathing?.cancel()
386      breathing = null
387      await update($, meter, m => ({ ...m, model: model || m.model, isActive: false }))
388      hide?.cancel()
389      hide = $.clock.after(LINGER_MS, () => {
390        hide = null
391        pulse?.cancel()
392        pulse = null
393        spin?.cancel()
394        spin = null
395        void update($, meter, m => ({ ...m, isVisible: false })).catch(() => {})
396      })
397    } catch {}
398    return result
399  })
400
401  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
402    const m = await read($, meter)
403    if (!m.isVisible || e.props.hasSurvey || (await read($, isPaused))) {
404      return next(e)
405    }
406    // Other bands (agent-watcher) draw beneath this row; the engine's own placeholder is never nested.
407    const drawn = await Promise.resolve(next(e)).catch(() => null)
408    const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
409
410    await read($, tick)
411    const now = await $.clock.now()
412    const s = await read($, shown)
413    const { Box, Button, Text } = $.ui.resolve(e)
414    const names = modelsInUse(m)
415    const name = names.length ? (names[s.carousel % names.length] ?? 'Claude') : 'Claude'
416    const reset = resetsIn(m.usageResetsAt, now)
417    // The effort of whichever loop runs the model shown: the main loop's, or an agent's on it.
418    const agentOnName = Object.keys(m.agentModels).find(id => modelName(m.agentModels[id] ?? '') === name && id in (m.agentEfforts ?? {}))
419    const nameEffort = name === modelName(m.model) ? (m.effort ?? '') : agentOnName ? (m.agentEfforts?.[agentOnName] ?? '') : ''
420    const ctxDetail = m.contextTokens !== null && m.contextWindow ? `${formatCount(m.contextTokens)} / ${formatCount(m.contextWindow)}` : ''
421    // Native text, not an image: it updates in place, so nothing flashes when a figure moves.
422    // A card as wide as the prompt: the model and the context figures on top, the big context
423    // bar across the whole width, then usage on its own thin line underneath.
424    const cols = e.props.bodyColumns || 100
425    const inner = Math.max(10, cols - 4) // the border and padding take 4 cells
426    const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v)}%`)
427    const usePace = SHOW_PACE && m.usagePercent !== null ? pace(m, now) : null
428    // The usage track takes the room its line has left (label, %, reset time and pace need ~40 cells).
429    const useW = Math.max(6, Math.floor((inner - 40) / (e.surface === "terminal" ? 1 : LINE_W)))
430    const use = track(s.usage, useW)
431    // The pause key shows only when another Watcher band is up too (alone, the card needs none),
432    // and only once: the first band that finds none beneath it draws it.
433    const showPause = below !== null && !hasKey(below, 'watcher-pause')
434
435    // The big bar: blue blocks, then the dotted ░ rest, running to one cell short of the card's edge;
436    // the figures above (tokens · percentage) end at the same place. Desktop draws █ and ░ at different widths (both
437    // wider than a layout cell), so fill and rest are counted separately from those widths and the
438    // bar keeps one length at any percentage.
439    const base = levelColor(s.context, CONTEXT)
440    const age = now - (s.contextGlowAt ?? 0)
441    const share = Math.max(0, Math.min(100, s.context)) / 100
442    const span = Math.max(8, inner)
443    const isTerminal = e.surface === 'terminal'
444    const blockW = isTerminal ? 1 : BLOCK_W
445    const dotW = isTerminal ? 1 : DOT_W
446    const fill = Math.round((span * share) / blockW)
447    const restDots = Math.max(0, Math.floor((span - fill * blockW) / dotW))
448    const runs = shimmerRuns(fill, base, age, s.compactAt !== undefined && s.compactAt === s.contextGlowAt)
449    const bar = (
450      <Box key="bar" flexDirection="row" flexShrink={0}>
451        <Text>
452          {runs.map((r, i) => (
453            <Text key={`run-${i}`} color={r.color}>{r.text}</Text>
454          ))}
455          <Text dimColor>{'░'.repeat(restDots)}</Text>
456        </Text>
457      </Box>
458    )
459
460    // Layout: three rows that never change height. Left groups hold labels and figures, right
461    // groups hold details; only small changing numbers get a fixed width.
462    return (
463      <Box flexDirection="column">
464        {below}
465        <Box key="context-watcher" flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
466          <Box key="head" flexDirection="row" justifyContent="space-between">
467            <Box key="title" flexDirection="row" gap={1} flexShrink={0}>
468              <Text dimColor>{'Context ·'}</Text>
469              <Text bold color={m.isActive ? fadeIn(breathe(tintOf(name), s.breath), now - (s.carouselAt ?? 0)) : undefined}>{name}</Text>
470              {nameEffort ? <Text dimColor>{nameEffort}</Text> : null}
471            </Box>
472            {/* Ends where the dotted bar below ends, one cell in from the card's edge. */}
473            <Box key="figures" flexDirection="row" flexShrink={1} marginRight={1}>
474              <Text dimColor wrap="truncate-end">{ctxDetail ? `${ctxDetail} · ` : ''}</Text>
475              <Text bold>{pct(m.contextPercent)}</Text>
476            </Box>
477          </Box>
478          {bar}
479          <Box key="usage" flexDirection="row" gap={1}>
480            <Text dimColor>{'Usage'}</Text>
481            {m.usagePercent === null ? null : (
482              <Box key="use-track" flexShrink={1}>
483                <Text wrap="truncate-end" color={glowColor(levelColor(s.usage, CLAY), s.usageGlowAt, now)}>{use.fill}</Text>
484                <Text dimColor>{use.rest}</Text>
485              </Box>
486            )}
487            <Box key="use-pct" width={4} flexShrink={0} justifyContent="flex-end">
488              <Text bold={m.usagePercent !== null} dimColor={m.usagePercent === null}>{pct(m.usagePercent)}</Text>
489            </Box>
490            <Box key="use-detail" flexShrink={1}>
491              <Text dimColor wrap="truncate-end">{reset ? `resets in ${reset}` : ''}</Text>
492            </Box>
493            {usePace ? (
494              <Box key="use-pace" flexShrink={0}>
495                <Text color={usePace.isShort ? WARN : undefined} dimColor={!usePace.isShort}>{`· ${usePace.text}`}</Text>
496              </Box>
497            ) : null}
498            {showPause ? (
499              <Box key="use-pause" flexGrow={1} justifyContent="flex-end">
500                <Button key="watcher-pause" label="–" hotkey="h" plain onPress={() => {}} />
501              </Box>
502            ) : null}
503          </Box>
504        </Box>
505      </Box>
506    )
507  })
508}
509
510// Whether a drawn tree already holds an element with this key (another band's pause button).
511function hasKey(node: unknown, key: string): boolean {
512  if (!node || typeof node !== 'object') {
513    return false
514  }
515  if (Array.isArray(node)) {
516    return node.some(n => hasKey(n, key))
517  }
518  const el = node as { key?: unknown; props?: { key?: unknown; children?: unknown }; children?: unknown }
519  return el.key === key || el.props?.key === key || hasKey(el.props?.children, key) || hasKey(el.children, key)
520}
521
522// Usage here is the 5-hour window; the weekly one is left out on purpose.
523function usageOf(limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[], m: Meter, now: number) {
524  const five = limits.find(l => l.kind === 'five_hour')
525  if (!five) {
526    return { usagePercent: m.usagePercent, usageResetsAt: m.usageResetsAt, paceFrom: m.paceFrom }
527  }
528  const resetsAt = five.resetsAt ?? m.usageResetsAt
529  // A new window (another reset time, or usage fell) starts measuring the pace again.
530  const isNewWindow = !m.paceFrom || m.paceFrom.resetsAt !== resetsAt || five.percentUsed < m.paceFrom.percent
531  return {
532    usagePercent: five.percentUsed,
533    usageResetsAt: resetsAt,
534    paceFrom: isNewWindow ? { at: now, percent: five.percentUsed, resetsAt } : m.paceFrom,
535  }
536}
537
types/index.d.ts 25 lines
1export type Meter = {
2  model: string
3  agentModels: Record<string, string>
4  contextTokens: number | null
5  contextWindow: number
6  contextPercent: number | null
7  usagePercent: number | null
8  usageResetsAt: string | null
9  isActive: boolean
10  isVisible: boolean
11  // Where the pace is measured from: the first reading in this 5-hour window.
12  paceFrom?: { at: number; percent: number; resetsAt: string | null }
13  // The effort each loop asks for: the main loop's, and each running agent's by id.
14  effort?: string
15  agentEfforts?: Record<string, string>
16}
17
18export type Shown = { context: number; usage: number; carousel: number; carouselAt: number; breath: number; contextGlowAt: number; usageGlowAt: number; compactAt?: number }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'context-watcher': { meter: Meter; tick: number; shown: Shown; isPaused: boolean }
23  }
24}
25