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

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.
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.
| Hook | What it does |
|---|---|
session.measure | Reads context size and the usage-window percentage that Claude Code already reports. Nothing is changed. |
turn.step | Reads the effort level of the main loop and of subagents. The step passes through unchanged. |
agent.spawn | Notes a subagent's model for the model carousel. The call passes through unchanged. |
prompt.submit | Brings 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).
hooks/register.tsx 537 lines1import { 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}
537types/index.d.ts 25 lines1export 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