SLOPSHOPPER

Context Weather

One band above the prompt: context size with its weather, your 5-hour and 7-day limits against the clock, the prompt cache countdown, session cost, a…

newbandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-weather
› 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 ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◔ 97.4k 49% · 5h 31% ━━────── · $0.42 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◔ 97.4k 49% · 5h 31% ━━────── · $0.42 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

Context Weather

One band above the Claude Code prompt that says how the session is doing: how full the context is, how fast you are using your limits, whether the prompt cache is still warm, what the session has cost, and which subagents are running.

In the terminal it is one capsule around one line:

╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ◔ 312k 31% ▂▃▅▂█ +27.4k · 5h 48% ━━━┃──── 2h 54m · 7d 52% ━━━━┃─── 3d 2h · cache 41m · $18.42 +$2.31 · 3 agents │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

In the Desktop app it is one neutral capsule with the same blocks. On both, values are plain, labels and details are dim, and colour appears only where something needs attention: yellow for a warning, red for an alert.

What each block says

  • Context: a glyph for how full the context window is, the tokens it holds, one bar per recent turn as tall as what that turn added, and the last turn's change. The weather goes Clear, Cloudy (from 30% of the window), Rain (55%), Storm (75%, yellow) and Compact soon (90%, red). The Desktop app draws the weather, and shows the share of the window when you hover it. The terminal draws a disc that fills through the same five steps (○ ◔ ◑ ◕ ●) and writes the share next to the tokens.
  • 5h / 7d: the share of each account limit already used, and the time to its reset. The gauge marks where the clock stands in the window: a bar past the mark means you are using faster than time passes. Yellow when usage is more than 5 points ahead of the clock; red when it is 20 points ahead or 90% is used. In the Desktop app, hover the clock for the 5-hour reset time. These windows exist on a Claude subscription only. The newest reading is shared by every session on the machine, and a window that has already reset is hidden until its next reading.
  • Cache: the time before the main conversation's prompt cache lapses. Each request starts the countdown again. Yellow in the last fifth of the cache's life, and red expired once it has lapsed, with what the next message writes again on a large context. When a request had to write the prompt again, the block says why: model changed, expired or prefix changed.
  • Cost: what the session has cost, as /cost totals it, and what the last turn added. On a subscription this is the API-price equivalent, not a bill.
  • Heavy thread: shown from 300k tokens of context (red from 600k). Every request reads the whole context again, so a long thread pays for its length at each step. The figure is how many times a fresh thread's load you are carrying; a fresh thread's load is the lightest first turn of your last five new threads, and the figure is left out until one has been measured.
  • Agents: the number of subagents running, shown only while some run. In the Desktop app, hover the icon for what each is doing.

When the terminal is too narrow for the whole line, the bars and details drop out first, then everything but the figures and the warnings. Where the band has fewer than three rows to itself, the line is drawn without its border.

How the cache countdown is worked out

Claude Code gives mods each request's cache token counts but not the cache's lifetime, so the mod follows Claude Code's own rules: one hour on a subscription within its plan's usage, five minutes otherwise, unless FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting or ENABLE_PROMPT_CACHING_1H says otherwise. It then corrects itself from the traffic: a request the cache served after more than five minutes proves the hour, and a rewrite within the hour proves five minutes. DISABLE_PROMPT_CACHING hides the block.

Install

claude plugin marketplace add endless-fr/claude-mods
claude plugin install context-weather@endless-claude-mods

Run /reload-plugins in a session that is already open. Requires Claude Code v2.1.287 or later in the terminal, v2.1.286 or later in the Desktop app.

What it reads and keeps

The mod makes no network requests and sends nothing anywhere.

  • Reads: the usage figures Claude Code provides (context, limits, cost, each main-conversation request's cache token counts), the list of the session's subagents, the promptCacheTtl setting and the four prompt-cache environment variables named above plus DISABLE_PROMPT_CACHING.
  • Keeps, in the plugin's own store on your machine: the newest limits reading; per session, the last twelve turn readings, the last request's cache figures and the last turn's cost (deleted after seven idle days); and the first-turn load of your last five new threads.

To list this yourself before installing, run claude plugin validate mods/context-weather from a clone.

Develop

claude plugin validate mods/context-weather
claude plugin test mods/context-weather

The rules live in hooks/measure.ts, free of any drawing or engine call. hooks/terminal.tsx and hooks/desktop.tsx draw what it works out, and hooks/register.tsx wires both to the session.

Credits

Inspired by Eric Cologni's token-weather-usage, which showed how much one band can say. Context Weather is written from scratch and shares no code with it.

Source 4 files
hooks/register.tsx 216 lines
1import type { EngineInterface, Register, RenderElement, SessionContextUsage, SessionRateLimit, Timer } from 'claude-code'
2
3import { desktopBand } from './desktop'
4import { STARTS_KEPT, cleared, emptyThread, restored, savedOf, segments, sortLimits, withCost, withReading, withRequest } from './measure'
5import type { Limit, Reading, Thread } from './measure'
6import { terminalBand } from './terminal'
7
8const MINUTE = 60_000
9const DAY = 24 * 60 * MINUTE
10
11// The mod's store, shared by every session on the machine.
12// The account's limits: the newest reading any session made.
13const LIMITS_KEY = 'limits'
14// What the last fresh threads held after their first turn: what "heavy" is measured against.
15const STARTS_KEY = 'starts'
16// One entry per session, so its bars and its cache countdown come back with it.
17const THREAD_PREFIX = 'thread:'
18const THREADS_KEPT_FOR = 7 * DAY
19
20// Subagents start and finish between turns, where no event of ours fires.
21const AGENTS_EVERY = 15_000
22
23let thread = emptyThread()
24// When `thread.limits` was read, by whichever session read it.
25let limitsAt = 0
26let ticker: Timer | null = null
27let agentsTicker: Timer | null = null
28
29const readingOf = (context: SessionContextUsage): Reading | null =>
30  context.tokens === undefined
31    ? null
32    : { tokens: context.tokens, window: context.window, percent: context.percent ?? Math.round((context.tokens / context.window) * 100) }
33
34/** Keeps this session's reading of the limits and offers it to the others. */
35async function publishLimits($: EngineInterface, limits: readonly SessionRateLimit[]) {
36  if (limits.length === 0) return
37  limitsAt = await $.clock.now()
38  thread = { ...thread, limits: sortLimits(limits) }
39  await $.store.set(LIMITS_KEY, { at: limitsAt, list: thread.limits })
40}
41
42/** Takes another session's reading of the limits when it is newer than ours. */
43async function adoptLimits($: EngineInterface) {
44  const saved = (await $.store.get(LIMITS_KEY)) as { at?: number; list?: Limit[] } | undefined
45  if (saved && typeof saved.at === 'number' && Array.isArray(saved.list) && saved.at > limitsAt) {
46    limitsAt = saved.at
47    thread = { ...thread, limits: sortLimits(saved.list) }
48  }
49}
50
51/** Reads the subagents running now; true when the list changed. */
52async function refreshAgents($: EngineInterface): Promise<boolean> {
53  const running = (await $.agent.list()).filter(agent => agent.status === 'running')
54  const ids = (agents: readonly { id: string }[]) => agents.map(agent => agent.id).join()
55  if (ids(running) === ids(thread.agents)) return false
56  thread = { ...thread, agents: running.map(({ id, type, description }) => ({ id, type, description })) }
57
58  return true
59}
60
61/** A new thread's first turn says what a fresh thread holds here. */
62async function noteStart($: EngineInterface) {
63  const held = thread.context?.tokens ?? 0
64  if (!thread.isFresh || held <= 0) return
65  thread = { ...thread, isFresh: false, starts: [...thread.starts, held].slice(-STARTS_KEPT) }
66  await $.store.set(STARTS_KEY, thread.starts)
67}
68
69/** Saves what this session's thread should come back with. */
70async function saveThread($: EngineInterface) {
71  await $.store.set(THREAD_PREFIX + (await $.session.id()), { at: await $.clock.now(), ...savedOf(thread) })
72}
73
74/** Brings this session's thread back, and forgets the sessions nobody returned to. */
75async function restoreThread($: EngineInterface) {
76  const mine = THREAD_PREFIX + (await $.session.id())
77  const oldest = (await $.clock.now()) - THREADS_KEPT_FOR
78  for (const key of await $.store.keys()) {
79    if (!key.startsWith(THREAD_PREFIX)) continue
80    const saved = (await $.store.get(key)) as { at?: number } | undefined
81    if (key === mine) thread = restored(thread, saved)
82    else if (!(typeof saved?.at === 'number' && saved.at >= oldest)) await $.store.delete(key)
83  }
84}
85
86const numbers = (value: unknown): number[] => (Array.isArray(value) ? value.filter(one => typeof one === 'number' && one > 0) : [])
87
88const isOn = (value: string | undefined) => /^(1|true|yes|on)$/i.test(value?.trim() ?? '')
89const ttlIn = (value: unknown) => (value === '5m' || value === '1h' ? value : null)
90
91/**
92 * What the person's environment and settings say of the main conversation's
93 * cache, in the order Claude Code itself takes them.
94 */
95async function cacheRuleOf($: EngineInterface): Promise<Thread['cacheRule']> {
96  if (isOn(await $.env.get('DISABLE_PROMPT_CACHING'))) return 'off'
97  if (isOn(await $.env.get('FORCE_PROMPT_CACHING_5M'))) return '5m'
98  const settings: Record<string, unknown> = await $.settings.read()
99
100  return (
101    ttlIn(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')) ??
102    ttlIn(settings.promptCacheTtl) ??
103    (isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H')) ? '1h' : null)
104  )
105}
106
107/** True for a tree that shows nothing: no text, and no drawing of its own. */
108function isBlank(node: unknown): boolean {
109  if (node === null || node === undefined || node === false) return true
110  if (typeof node === 'string') return node.trim() === ''
111  if (Array.isArray(node)) return node.every(isBlank)
112  if (typeof node !== 'object') return false
113  const element = node as { type?: string; children?: unknown }
114
115  return (element.type === 'Box' || element.type === 'Text') && isBlank(element.children)
116}
117
118export const register: Register = on => {
119  on('session.start', async ($, e, next) => {
120    const usage = await $.session.usage()
121    const cost = usage.cost?.usd ?? null
122    thread = { ...emptyThread(), cacheRule: await cacheRuleOf($), starts: numbers(await $.store.get(STARTS_KEY)), cost, costAtTurn: cost }
123    await restoreThread($)
124    thread = withReading(thread, readingOf(usage.context), true)
125    // Nothing in the window yet: this thread is new, and its first turn will say what a start weighs.
126    thread = { ...thread, isFresh: thread.context === null || thread.context.tokens <= 0 }
127    await refreshAgents($)
128    limitsAt = 0
129    // A session that just started has at best an old reading of its own: what
130    // the last session to measure left in the store comes first.
131    await adoptLimits($)
132    if (limitsAt === 0) await publishLimits($, usage.rateLimits)
133
134    // Time moves the gauges and the cache countdown on, and another session
135    // may have a newer reading of the limits.
136    ticker?.cancel()
137    ticker = $.clock.every(MINUTE, async () => {
138      await adoptLimits($)
139      $.ui.invalidate('ui.render')
140    })
141    agentsTicker?.cancel()
142    agentsTicker = $.clock.every(AGENTS_EVERY, async () => {
143      if (await refreshAgents($)) $.ui.invalidate('ui.render')
144    })
145    $.ui.invalidate('ui.render')
146
147    return next(e)
148  })
149
150  on('session.end', ($, e, next) => {
151    if (e.reason === 'clear') {
152      // The process goes on with a new thread, and no session.start says so.
153      thread = cleared(thread)
154      $.ui.invalidate('ui.render')
155    } else {
156      ticker?.cancel()
157      agentsTicker?.cancel()
158    }
159
160    return next(e)
161  })
162
163  on('session.measure', async ($, e, next) => {
164    thread = withCost(withReading(thread, readingOf(e.context), false), e.cost?.usd, false)
165    if (e.changed.includes('rateLimits')) await publishLimits($, e.rateLimits)
166    $.ui.invalidate('ui.render')
167
168    return next(e)
169  })
170
171  // Each request of the main conversation: a subagent's has a cache of its own.
172  on('turn.step', async function* ($, e, next) {
173    const result = yield* next(e)
174    if (e.agentId === undefined && result.usage) {
175      thread = withRequest(thread, await $.clock.now(), result.usage)
176      // The request may have handed work to a subagent.
177      await refreshAgents($)
178      await saveThread($)
179      $.ui.invalidate('ui.render')
180    }
181
182    return result
183  })
184
185  on('turn.complete', async ($, e, next) => {
186    const result = await next(e)
187    if (e.agentId === undefined) {
188      const usage = await $.session.usage()
189      thread = withCost(withReading(thread, readingOf(usage.context), true), usage.cost?.usd, true)
190      await noteStart($)
191      await saveThread($)
192      $.ui.invalidate('ui.render')
193    } else if (await refreshAgents($)) {
194      // A subagent's turn ended: it may have been its last.
195      $.ui.invalidate('ui.render')
196    }
197
198    return result
199  })
200
201  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202    const shown = e.props.hasSurvey ? [] : segments(thread, await $.clock.now())
203    let band: RenderElement
204    if (shown.length > 0 && e.surface === 'desktop') band = desktopBand($.ui.resolve(e), shown)
205    else if (shown.length > 0 && e.surface === 'terminal') band = terminalBand($.ui.resolve(e), shown, e.props.bodyColumns, e.props.maxRows)
206    else return next(e)
207
208    // A mod placed after this one keeps its place in the band, under our line.
209    const beneath = await next(e)
210    if (isBlank(beneath)) return band
211    const { Box } = $.ui.resolve(e)
212
213    return <Box flexDirection="column">{[band, beneath]}</Box>
214  })
215}
216
hooks/desktop.tsx 167 lines
1import type { Elements, RenderElement } from 'claude-code'
2
3import type { AgentsSegment, CacheSegment, ContextSegment, HeavySegment, LimitSegment, Segment, Sky, Tone } from './measure'
4
5// One neutral grey that reads on a light and on a dark page; colour is kept
6// for what needs attention.
7const GREY = '#8E8E93'
8const INK: Record<Tone, string> = { calm: GREY, warn: '#E08600', alert: '#E5484D' }
9const EDGE = 'rgba(142,142,147,0.30)'
10const FILL = 'rgba(142,142,147,0.08)'
11const FAINT = 'rgba(142,142,147,0.45)'
12
13const ICON = 15
14const SPARK = { height: 12, bar: 3, gap: 2 }
15const GAUGE = { width: 40, height: 9, bar: 3 }
16const TRACK = 'rgba(142,142,147,0.28)'
17
18// An Svg that answers the pointer is drawn in a frame of its own, which a
19// browser paints white under a dark page unless the frame names both schemes.
20const FRAME = '<style>:root{color-scheme:light dark}</style>'
21
22const escape = (text: string) => text.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c] ?? c)
23
24const stroke = (ink: string, d: string) => `<path d="${d}" fill="none" stroke="${ink}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>`
25
26const CLOUD = 'M7.5 16.5h9a3.5 3.5 0 0 0 .5-6.96 5.5 5.5 0 0 0-10.6 1.1A3 3 0 0 0 7.5 16.5z'
27const SKIES: Record<Sky, (ink: string) => string> = {
28  clear: ink => `<circle cx="12" cy="12" r="3.6" fill="none" stroke="${ink}" stroke-width="1.7"/>${stroke(ink, 'M12 3.5v2M12 18.5v2M3.5 12h2M18.5 12h2M6 6l1.4 1.4M16.6 16.6 18 18M6 18l1.4-1.4M16.6 7.4 18 6')}`,
29  cloudy: ink => stroke(ink, CLOUD),
30  rain: ink => `<g transform="translate(0 -2.5)">${stroke(ink, CLOUD)}</g>${stroke(ink, 'M9 17.5l-1 3M12.5 17.5l-1 3M16 17.5l-1 3')}`,
31  storm: ink => `<g transform="translate(0 -2.5)">${stroke(ink, CLOUD)}</g>${stroke(ink, 'M12.5 15.5l-2 3h3l-2 3')}`,
32  full: ink => stroke(ink, 'M12 4.5 3.5 19.5h17zM12 10.5v4M12 17.2v.1'),
33}
34
35/** A small drawing with a tooltip: `tip` shows while the pointer rests on it. */
36function glyph(body: string, tip: string, width = ICON): string {
37  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ICON}" viewBox="0 0 ${(width * 24) / ICON} 24">${FRAME}<title>${escape(tip)}</title>${body}</svg>`
38}
39
40/** One bar per recent turn, as tall as what it added; the latest in ink. */
41function spark(bars: number[], ink: string): { source: string; width: number } {
42  const { height, bar, gap } = SPARK
43  const width = bars.length * bar + (bars.length - 1) * gap
44  const rects = bars.map((share, i) => {
45    const tall = Math.max(1.5, share * height)
46
47    return `<rect x="${i * (bar + gap)}" y="${(height - tall).toFixed(1)}" width="${bar}" height="${tall.toFixed(1)}" rx="1" fill="${i === bars.length - 1 ? ink : FAINT}"/>`
48  })
49
50  return { source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects.join('')}</svg>`, width }
51}
52
53const CLOCK = (ink: string) => `<circle cx="12" cy="12" r="8" fill="none" stroke="${ink}" stroke-width="1.7"/>${stroke(ink, 'M12 7.5V12l3 2')}`
54
55/** A thin bar for the share used and a taller tick where the clock stands. */
56function gauge(segment: LimitSegment): string {
57  const { width, height, bar } = GAUGE
58  const top = (height - bar) / 2
59  const used = (Math.min(100, segment.used) / 100) * width
60  const tick = segment.elapsed === null ? '' : `<rect x="${Math.min(width - 1.5, (segment.elapsed / 100) * width).toFixed(1)}" width="1.5" height="${height}" rx="0.75" fill="${GREY}"/>`
61
62  return (
63    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
64    `<rect y="${top}" width="${width}" height="${bar}" rx="${bar / 2}" fill="${TRACK}"/>` +
65    `<rect y="${top}" width="${used.toFixed(1)}" height="${bar}" rx="${bar / 2}" fill="${INK[segment.tone]}"/>${tick}</svg>`
66  )
67}
68
69function drawLimit({ Svg, Text }: Elements['desktop'], segment: LimitSegment): RenderElement[] {
70  const parts = [
71    <Text dimColor>{segment.label}</Text>,
72    <Svg source={gauge(segment)} alt={segment.alt} width={GAUGE.width} height={GAUGE.height} />,
73    <Text bold {...inked(segment.tone)}>
74      {segment.percent}
75    </Text>,
76  ]
77  if (segment.tip) parts.push(<Svg source={glyph(CLOCK(GREY), segment.tip)} alt={segment.tip} width={ICON} height={ICON} isInteractive />)
78  if (segment.left) parts.push(<Text dimColor>{segment.left}</Text>)
79
80  return parts
81}
82
83/** Colour only where the tone asks for attention. */
84const inked = (tone: Tone) => (tone === 'calm' ? {} : { color: INK[tone] })
85
86function drawCache({ Text }: Elements['desktop'], segment: CacheSegment): RenderElement[] {
87  return [
88    <Text dimColor>cache</Text>,
89    <Text bold {...inked(segment.tone)}>
90      {segment.value}
91    </Text>,
92    ...(segment.detail ? [<Text dimColor>{segment.detail}</Text>] : []),
93  ]
94}
95
96// A weight: a handle over a body.
97const WEIGHT = (ink: string) => stroke(ink, 'M9.2 8.5a2.8 2.8 0 1 1 5.6 0M7 8.5h10l2.2 10.5H4.8z')
98// Work handed out: one node over two.
99const BRANCH = (ink: string) =>
100  `${stroke(ink, 'M12 8.5v3.5M12 12H7.5v3M12 12h4.5v3')}<circle cx="12" cy="6" r="2.4" fill="none" stroke="${ink}" stroke-width="1.7"/><circle cx="7.5" cy="17.5" r="2.4" fill="none" stroke="${ink}" stroke-width="1.7"/><circle cx="16.5" cy="17.5" r="2.4" fill="none" stroke="${ink}" stroke-width="1.7"/>`
101
102function drawHeavy({ Svg, Text }: Elements['desktop'], segment: HeavySegment): RenderElement[] {
103  const icon = <Svg source={glyph(WEIGHT(INK[segment.tone]), segment.tip)} alt={segment.tip} width={ICON} height={ICON} isInteractive />
104
105  return segment.ratio
106    ? [icon, <Text dimColor>heavy</Text>, <Text bold color={INK[segment.tone]}>{segment.ratio}</Text>]
107    : [icon, <Text bold color={INK[segment.tone]}>heavy</Text>]
108}
109
110function drawAgents({ Svg, Text }: Elements['desktop'], segment: AgentsSegment): RenderElement[] {
111  return [
112    <Svg source={glyph(BRANCH(GREY), segment.tip)} alt={segment.alt} width={ICON} height={ICON} isInteractive />,
113    <Text bold>{segment.count}</Text>,
114    <Text dimColor>{segment.noun}</Text>,
115  ]
116}
117
118function draw(table: Elements['desktop'], segment: Segment): RenderElement[] {
119  if (segment.kind === 'limit') return drawLimit(table, segment)
120  if (segment.kind === 'cache') return drawCache(table, segment)
121  if (segment.kind === 'heavy') return drawHeavy(table, segment)
122  if (segment.kind === 'agents') return drawAgents(table, segment)
123  if (segment.kind === 'cost') {
124    return [<table.Text bold>{segment.value}</table.Text>, ...(segment.detail ? [<table.Text dimColor>{segment.detail}</table.Text>] : [])]
125  }
126
127  return drawContext(table, segment)
128}
129
130function drawContext({ Svg, Text }: Elements['desktop'], segment: ContextSegment): RenderElement[] {
131  const ink = INK[segment.tone]
132  const parts = [
133    <Svg source={glyph(SKIES[segment.sky](ink), segment.tip)} alt={segment.tip} width={ICON} height={ICON} isInteractive />,
134    <Text bold>{segment.tokens}</Text>,
135  ]
136  if (segment.bars.length > 0) {
137    const { source, width } = spark(segment.bars, ink)
138    parts.push(<Svg source={source} alt={`Tokens added by the last ${segment.bars.length} turns`} width={width} height={SPARK.height} />)
139  }
140  if (segment.delta) parts.push(<Text dimColor>{segment.delta}</Text>)
141
142  return parts
143}
144
145// A hairline between two blocks; its alt is what a reader hears there.
146const RULE = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="14" viewBox="0 0 1 14"><rect width="1" height="14" fill="${EDGE}"/></svg>`
147
148const keyOf = (segment: Segment) => (segment.kind === 'limit' ? segment.id : segment.kind)
149
150/** The band in the desktop app: one quiet capsule, the blocks split by hairlines. */
151export function desktopBand(table: Elements['desktop'], segments: Segment[]): RenderElement {
152  const { Box, Svg } = table
153
154  return (
155    <Box flexDirection="row" paddingX={1}>
156      <Box flexDirection="row" alignItems="center" columnGap={1} paddingX={1} paddingY={0} borderStyle="round" borderColor={EDGE} backgroundColor={FILL}>
157        {segments.flatMap((segment, i) => [
158          ...(i > 0 ? [<Svg source={RULE} alt="|" width={1} height={14} />] : []),
159          <Box key={keyOf(segment)} flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
160            {draw(table, segment)}
161          </Box>,
162        ])}
163      </Box>
164    </Box>
165  )
166}
167
hooks/measure.ts 369 lines
1// What the band says, worked out from the session's figures: no drawing and no
2// engine calls here, so every rule can be read (and tested) on its own.
3
4export type Tone = 'calm' | 'warn' | 'alert'
5export type Sky = 'clear' | 'cloudy' | 'rain' | 'storm' | 'full'
6
7export type Reading = { tokens: number; window: number; percent: number }
8/** One of the account's usage windows, as the session reports it. */
9export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
10
11export type Ttl = '5m' | '1h'
12export type MissCause = 'model changed' | 'expired' | 'prefix changed'
13/** The token counts of one request, as the API reports them. */
14export type RequestUsage = { model: string; input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
15/** The main conversation's last request: when it was answered and what the cache served of it. */
16export type Request = { at: number; model: string; read: number; written: number; plain: number; miss?: MissCause }
17
18/** A subagent at work. */
19export type Agent = { id: string; type: string; description: string }
20
21/** Everything the band draws from. */
22export type Thread = {
23  /** The context window as last reported. */
24  context: Reading | null
25  /** Context tokens after each completed turn, oldest first. */
26  turns: number[]
27  /** The account's usage windows, newest reading known on this machine. */
28  limits: Limit[]
29  request: Request | null
30  /** What the person's settings say of the cache: off, a lifetime, or nothing. */
31  cacheRule: 'off' | Ttl | null
32  /** The lifetime the traffic showed, which settles it. */
33  seenTtl: Ttl | null
34  /** Dollars the session has cost, as /cost totals them; null where nothing keeps count. */
35  cost: number | null
36  /** What the cost stood at when the last turn ended, and what that turn added. */
37  costAtTurn: number | null
38  lastTurnCost: number | null
39  /** What the last fresh threads on this machine held after their first turn. */
40  starts: number[]
41  /** True until a new thread's first turn has been measured. */
42  isFresh: boolean
43  agents: Agent[]
44}
45
46export type ContextSegment = {
47  kind: 'context'
48  sky: Sky
49  tone: Tone
50  tokens: string
51  /** Share of the window held, as `21%`. */
52  share: string
53  /** What each recent turn added, 0 to 1 against the largest of them. */
54  bars: number[]
55  delta: string
56  tip: string
57}
58
59export type LimitSegment = {
60  kind: 'limit'
61  id: string
62  label: string
63  /** Share of the window used, 0 to 100. */
64  used: number
65  /** Share of the window's time gone, 0 to 100; null when its length is unknown. */
66  elapsed: number | null
67  tone: Tone
68  percent: string
69  /** Time to the reset, empty when unknown. */
70  left: string
71  /** What the gauge says in words. */
72  alt: string
73  /** When the window resets, empty when not worth a tooltip. */
74  tip: string
75}
76
77export type CacheSegment = { kind: 'cache'; tone: Tone; value: string; detail: string }
78
79export type CostSegment = { kind: 'cost'; value: string; detail: string }
80/** `ratio` is empty when nothing says what a fresh thread holds. */
81export type HeavySegment = { kind: 'heavy'; tone: Tone; ratio: string; tip: string }
82export type AgentsSegment = { kind: 'agents'; count: string; noun: string; alt: string; tip: string }
83
84export type Segment = ContextSegment | LimitSegment | CacheSegment | CostSegment | HeavySegment | AgentsSegment
85
86export const TURNS_KEPT = 12
87const BARS_SHOWN = 6
88
89const SKIES: readonly { below: number; sky: Sky; name: string; tone: Tone }[] = [
90  { below: 30, sky: 'clear', name: 'Clear', tone: 'calm' },
91  { below: 55, sky: 'cloudy', name: 'Cloudy', tone: 'calm' },
92  { below: 75, sky: 'rain', name: 'Rain', tone: 'calm' },
93  { below: 90, sky: 'storm', name: 'Storm', tone: 'warn' },
94  { below: Infinity, sky: 'full', name: 'Compact soon', tone: 'alert' },
95]
96
97const MINUTE = 60_000
98const HOUR = 60 * MINUTE
99const DAY = 24 * HOUR
100
101// The windows whose length is known, in the order they are drawn.
102const WINDOWS: Record<string, { label: string; span: number }> = {
103  five_hour: { label: '5h', span: 5 * HOUR },
104  seven_day: { label: '7d', span: 7 * DAY },
105}
106const WINDOW_ORDER = Object.keys(WINDOWS)
107
108// Pace is the share used less the share of time gone, in points.
109const AHEAD_WARN = 5
110const AHEAD_ALERT = 20
111const NEARLY_SPENT = 90
112
113const TTL: Record<Ttl, number> = { '5m': 5 * MINUTE, '1h': HOUR }
114// The countdown turns yellow in the last fifth of the cache's life.
115const LAPSING = 0.2
116// A request that wrote this much, and more than it read, wrote the prompt again.
117const REWRITE_FLOOR = 2_000
118// An expired cache on a context this large is worth a number.
119const WORTH_SAYING = 100_000
120
121// Every request reads the whole context again, so a long thread pays for its
122// length at each step: worth a word from here, and a red one from there.
123const HEAVY = 300_000
124const VERY_HEAVY = 600_000
125export const STARTS_KEPT = 5
126
127export const emptyThread = (): Thread => ({
128  context: null,
129  turns: [],
130  limits: [],
131  request: null,
132  cacheRule: null,
133  seenTtl: null,
134  cost: null,
135  costAtTurn: null,
136  lastTurnCost: null,
137  starts: [],
138  isFresh: false,
139  agents: [],
140})
141
142/** What a session keeps between two runs of the same thread. */
143export type Saved = Pick<Thread, 'turns' | 'request' | 'seenTtl' | 'lastTurnCost'>
144
145export const savedOf = ({ turns, request, seenTtl, lastTurnCost }: Thread): Saved => ({ turns, request, seenTtl, lastTurnCost })
146
147/** Lays what a session saved over a thread, taking only what still has the right shape. */
148export function restored(thread: Thread, saved: unknown): Thread {
149  if (typeof saved !== 'object' || saved === null) return thread
150  const { turns, request, seenTtl, lastTurnCost } = saved as Partial<Saved>
151
152  return {
153    ...thread,
154    turns: Array.isArray(turns) ? turns.filter(one => typeof one === 'number' && one > 0).slice(-TURNS_KEPT) : thread.turns,
155    request: request && typeof request.at === 'number' && typeof request.read === 'number' ? request : thread.request,
156    seenTtl: seenTtl === '5m' || seenTtl === '1h' ? seenTtl : thread.seenTtl,
157    lastTurnCost: typeof lastTurnCost === 'number' ? lastTurnCost : thread.lastTurnCost,
158  }
159}
160
161/** The thread a /clear leaves: what belongs to the account and the machine stays. */
162export function cleared(thread: Thread): Thread {
163  const { limits, cacheRule, cost, starts, agents } = thread
164
165  return { ...emptyThread(), limits, cacheRule, cost, costAtTurn: cost, starts, agents, isFresh: true }
166}
167
168/** Records the session's cost; at a turn's end, what that turn added to it. */
169export function withCost(thread: Thread, cost: number | undefined, isTurnEnd: boolean): Thread {
170  if (cost === undefined) return thread
171  if (!isTurnEnd) return { ...thread, cost }
172  const added = thread.costAtTurn === null ? null : cost - thread.costAtTurn
173
174  return { ...thread, cost, costAtTurn: cost, lastTurnCost: added !== null && added >= 0 ? added : null }
175}
176
177const dollars = (usd: number) => (usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
178
179function costSegment(thread: Thread): CostSegment | null {
180  if (thread.cost === null || thread.cost < 0.005) return null
181  const added = thread.lastTurnCost ?? 0
182
183  return { kind: 'cost', value: dollars(thread.cost), detail: added >= 0.005 ? `+${dollars(added)}` : '' }
184}
185
186function heavySegment(thread: Thread): HeavySegment | null {
187  const size = thread.context?.tokens ?? 0
188  if (size < HEAVY) return null
189  // A fresh thread's load: the lightest of the recent starts, this thread's own among them if it began here.
190  const start = thread.starts.length > 0 ? Math.min(...thread.starts) : null
191  const times = start === null ? null : size / start
192  const ratio = times === null ? '' : times >= 10 ? String(Math.round(times)) : trim(times)
193  const versus = start === null ? '' : `, ${ratio} times a fresh thread (${tokens(start)})`
194
195  return {
196    kind: 'heavy',
197    tone: size >= VERY_HEAVY ? 'alert' : 'warn',
198    ratio: ratio && `×${ratio}`,
199    tip: `Heavy thread: ${tokens(size)} tokens of context${versus}. Every request reads all of it again, so a new thread costs less.`,
200  }
201}
202
203function agentsSegment(thread: Thread): AgentsSegment | null {
204  const count = thread.agents.length
205  if (count === 0) return null
206  const noun = count === 1 ? 'agent' : 'agents'
207
208  return {
209    kind: 'agents',
210    count: String(count),
211    noun,
212    alt: `${count} ${noun} running`,
213    tip: thread.agents.map(agent => `${agent.type} · ${agent.description}`).join('\n'),
214  }
215}
216
217/** <1m, 41m, 1h, 2h 54m, 3d 2h. */
218export function duration(ms: number): string {
219  const minutes = Math.floor(ms / MINUTE)
220  if (minutes < 1) return '<1m'
221  if (minutes < 60) return `${minutes}m`
222  const [big, small] = minutes < 24 * 60 ? [`${Math.floor(minutes / 60)}h`, `${minutes % 60}m`] : [`${Math.floor(minutes / (24 * 60))}d`, `${Math.floor((minutes % (24 * 60)) / 60)}h`]
223
224  return small.startsWith('0') ? big : `${big} ${small}`
225}
226
227/** 20:34, in the machine's time zone. */
228function clockTime(ms: number): string {
229  const date = new Date(ms)
230
231  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
232}
233
234/** The windows in drawing order, each kind once. */
235export function sortLimits(limits: readonly Limit[]): Limit[] {
236  const rank = (kind: string) => (WINDOW_ORDER.includes(kind) ? WINDOW_ORDER.indexOf(kind) : WINDOW_ORDER.length)
237
238  return [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
239}
240
241/** 950, 27.4k, 312k, 1.2M: one decimal only where it still says something. */
242export function tokens(count: number): string {
243  if (count >= 1_000_000) return `${trim(count / 1_000_000)}M`
244  if (count >= 100_000) return `${Math.round(count / 1_000)}k`
245  if (count >= 1_000) return `${trim(count / 1_000)}k`
246
247  return String(Math.round(count))
248}
249
250const trim = (value: number) => String(Number(value.toFixed(1)))
251
252/** Records the window's fill; a repeat of the last turn's reading adds no turn. */
253export function withReading(thread: Thread, reading: Reading | null, isTurnEnd: boolean): Thread {
254  if (reading === null) return thread
255  const isNew = reading.tokens > 0 && thread.turns.at(-1) !== reading.tokens
256  const turns = isTurnEnd && isNew ? [...thread.turns, reading.tokens].slice(-TURNS_KEPT) : thread.turns
257
258  return { ...thread, context: reading, turns }
259}
260
261function contextSegment(thread: Thread): ContextSegment | null {
262  const reading = thread.context
263  if (reading === null || reading.tokens <= 0) return null
264  const { sky, name, tone } = SKIES.find(one => reading.percent < one.below) ?? SKIES[SKIES.length - 1]!
265  const steps = thread.turns.slice(-(BARS_SHOWN + 1))
266  const added = steps.slice(1).map((after, i) => after - steps[i]!)
267  const largest = Math.max(1, ...added)
268  const last = added.at(-1) ?? 0
269
270  return {
271    kind: 'context',
272    sky,
273    tone,
274    tokens: tokens(reading.tokens),
275    share: `${reading.percent}%`,
276    bars: added.map(one => Math.max(0, one) / largest),
277    delta: last > 0 ? `+${tokens(last)}` : last < 0 ? `−${tokens(-last)}` : '',
278    tip: `${name} · ${reading.percent}% of ${tokens(reading.window)}`,
279  }
280}
281
282/**
283 * How long the main conversation's cache lives. Claude Code asks for an hour
284 * on a subscription within its plan's usage and five minutes otherwise, unless
285 * the person chose; what the traffic showed beats both.
286 */
287function ttlOf(thread: Thread): number {
288  if (thread.seenTtl) return TTL[thread.seenTtl]
289  if (thread.cacheRule === '5m' || thread.cacheRule === '1h') return TTL[thread.cacheRule]
290  const plan = thread.limits.filter(limit => limit.kind in WINDOWS)
291
292  return plan.length > 0 && plan.every(limit => limit.percentUsed < 100) ? TTL['1h'] : TTL['5m']
293}
294
295const promptOf = (request: Request) => request.read + request.written + request.plain
296
297/** Records a main-loop request: whether the cache served it, and what that says of the lifetime. */
298export function withRequest(thread: Thread, at: number, usage: RequestUsage): Thread {
299  const request: Request = { at, model: usage.model, read: usage.cache_read_input_tokens, written: usage.cache_creation_input_tokens, plain: usage.input_tokens }
300  const last = thread.request
301  if (last === null) return { ...thread, request }
302
303  const gap = at - last.at
304  // A prompt cut by half or more is a compaction or a new thread, which rewrite by design.
305  const isSamePrompt = promptOf(request) >= promptOf(last) / 2
306  const isRewrite = isSamePrompt && request.written >= REWRITE_FLOOR && request.written > request.read
307  let seenTtl = thread.seenTtl
308  if (!isRewrite && request.read > 0 && gap > TTL['5m']) seenTtl = '1h'
309  if (isRewrite && request.model === last.model && gap > TTL['5m'] && gap < TTL['1h']) seenTtl = '5m'
310  const next = { ...thread, seenTtl }
311  if (isRewrite) request.miss = request.model !== last.model ? 'model changed' : gap >= ttlOf(next) ? 'expired' : 'prefix changed'
312
313  return { ...next, request }
314}
315
316function cacheSegment(thread: Thread, now: number): CacheSegment | null {
317  const request = thread.request
318  if (request === null || thread.cacheRule === 'off') return null
319  const ttl = ttlOf(thread)
320  const left = request.at + ttl - now
321  if (left <= 0) {
322    const size = thread.context?.tokens ?? promptOf(request)
323
324    return { kind: 'cache', tone: 'alert', value: 'expired', detail: size >= WORTH_SAYING ? `${tokens(size)} to rewrite` : '' }
325  }
326  const isLapsing = left < ttl * LAPSING
327
328  return { kind: 'cache', tone: isLapsing || request.miss ? 'warn' : 'calm', value: duration(left), detail: request.miss ? `missed: ${request.miss}` : '' }
329}
330
331function limitSegment(limit: Limit, now: number): LimitSegment | null {
332  const resets = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
333  // Past its reset, the reading describes a window that no longer exists.
334  if (resets <= now) return null
335  const window = WINDOWS[limit.kind]
336  const label = window?.label ?? limit.kind.replaceAll('_', ' ')
337  const used = Math.max(0, limit.percentUsed)
338  const left = Number.isFinite(resets) ? resets - now : null
339  const elapsed = window && left !== null ? Math.min(100, Math.max(0, ((window.span - left) / window.span) * 100)) : null
340  const ahead = elapsed === null ? 0 : used - elapsed
341  const tone: Tone = used >= NEARLY_SPENT || ahead >= AHEAD_ALERT ? 'alert' : ahead > AHEAD_WARN ? 'warn' : 'calm'
342  const percent = `${Math.round(used)}%`
343
344  return {
345    kind: 'limit',
346    id: limit.kind,
347    label,
348    used,
349    elapsed,
350    tone,
351    percent,
352    left: left === null ? '' : duration(left),
353    alt: `${label} limit: ${percent} used${elapsed === null ? '' : `, ${Math.round(elapsed)}% of the window elapsed`}`,
354    tip: limit.kind === 'five_hour' && left !== null ? `Resets at ${clockTime(resets)}` : '',
355  }
356}
357
358/** The band's segments at `now`, in the order they are drawn. */
359export function segments(thread: Thread, now: number): Segment[] {
360  return [
361    contextSegment(thread),
362    ...thread.limits.map(limit => limitSegment(limit, now)),
363    cacheSegment(thread, now),
364    costSegment(thread),
365    heavySegment(thread),
366    agentsSegment(thread),
367  ].filter(one => one !== null)
368}
369
hooks/terminal.tsx 137 lines
1import type { Elements, RenderElement } from 'claude-code'
2
3import type { ContextSegment, LimitSegment, Segment, Tone } from './measure'
4
5// A disc that fills with the window: no terminal draws these as colour emoji.
6const FILL = { clear: '○', cloudy: '◔', rain: '◑', storm: '◕', full: '●' } as const
7const LEVELS = '▁▂▃▄▅▆▇█'
8const COLOR: Record<Tone, string | undefined> = { calm: undefined, warn: 'yellow', alert: 'red' }
9
10const PADDING = 1
11const BORDER = 1
12// The capsule's line between its top and bottom edges.
13const CAPSULE_ROWS = 3
14// From everything to the figures alone: what a narrowing terminal still shows.
15const DETAILS = ['full', 'compact', 'figures'] as const
16type Detail = (typeof DETAILS)[number]
17
18/** One stretch of the line in one style: a value is plain, what surrounds it faint. */
19type Run = { text: string; tone?: Tone; isFaint?: boolean }
20
21const GAUGE_CELLS = 8
22
23// A calm tone is no tone: the text keeps the terminal's own colour.
24const toned = (tone: Tone) => (tone === 'calm' ? {} : { tone })
25
26/** Solid up to the share used, a mark where the clock stands. */
27function gauge(segment: LimitSegment): Run[] {
28  const used = Math.round((segment.used / 100) * GAUGE_CELLS)
29  const mark = segment.elapsed === null ? -1 : Math.min(GAUGE_CELLS - 1, Math.floor((segment.elapsed / 100) * GAUGE_CELLS))
30
31  return Array.from({ length: GAUGE_CELLS }, (_, i): Run => {
32    if (i === mark) return { text: '┃' }
33    if (i < used) return { text: '━', ...toned(segment.tone) }
34
35    return { text: '─', isFaint: true }
36  })
37}
38
39function limitRuns(segment: LimitSegment, detail: Detail): Run[] {
40  const runs: Run[] = [{ text: `${segment.label} `, isFaint: true }, { text: segment.percent, ...toned(segment.tone) }]
41  if (detail === 'full') runs.push({ text: ' ' }, ...gauge(segment))
42  if (segment.left && detail !== 'figures') runs.push({ text: ` ${segment.left}`, isFaint: true })
43
44  return runs
45}
46
47function contextRuns(segment: ContextSegment, detail: Detail): Run[] {
48  const runs: Run[] = [{ text: FILL[segment.sky], ...toned(segment.tone) }, { text: ` ${segment.tokens}` }]
49  if (detail === 'figures') return runs
50  runs.push({ text: ` ${segment.share}`, isFaint: true })
51  if (detail !== 'full') return runs
52  if (segment.bars.length > 0) {
53    runs.push({ text: ` ${segment.bars.slice(0, -1).map(level).join('')}`, isFaint: true }, { text: level(segment.bars.at(-1) ?? 0), ...toned(segment.tone) })
54  }
55  if (segment.delta) runs.push({ text: ` ${segment.delta}`, isFaint: true })
56
57  return runs
58}
59
60/** A segment at a level of detail; nothing when that level leaves it out. */
61function runsOf(segment: Segment, detail: Detail): Run[] {
62  const hasMore = detail === 'full'
63  switch (segment.kind) {
64    case 'context':
65      return contextRuns(segment, detail)
66    case 'limit':
67      return limitRuns(segment, detail)
68    case 'cache':
69      // Down to the figures, a cache that is fine goes unsaid.
70      if (detail === 'figures' && segment.tone === 'calm') return []
71
72      return [{ text: 'cache ', isFaint: true }, { text: segment.value, ...toned(segment.tone) }, ...(hasMore && segment.detail ? [{ text: ` ${segment.detail}`, isFaint: true }] : [])]
73    case 'cost':
74      if (detail === 'figures') return []
75
76      return [{ text: segment.value }, ...(hasMore && segment.detail ? [{ text: ` ${segment.detail}`, isFaint: true }] : [])]
77    case 'heavy':
78      return segment.ratio
79        ? [{ text: 'heavy thread ', isFaint: true }, { text: segment.ratio, tone: segment.tone }]
80        : [{ text: 'heavy thread', tone: segment.tone }]
81    case 'agents':
82      if (detail === 'figures') return []
83
84      return [{ text: segment.count }, { text: ` ${segment.noun}`, isFaint: true }]
85  }
86}
87
88const RULE: Run = { text: ' · ', isFaint: true }
89
90function lineOf(segments: Segment[], detail: Detail): Run[] {
91  return segments
92    .map(segment => runsOf(segment, detail))
93    .filter(runs => runs.length > 0)
94    .flatMap((runs, i) => (i > 0 ? [RULE, ...runs] : runs))
95}
96
97const widthOf = (runs: Run[]) => runs.reduce((sum, run) => sum + run.text.length, 0)
98
99// Only the styles a run asks for: a prop left out stays out of the drawing.
100const style = (run: Run) => ({
101  ...(run.isFaint ? { dimColor: true } : {}),
102  ...(run.tone && COLOR[run.tone] ? { color: COLOR[run.tone] } : {}),
103})
104
105/** Neighbours in one style drawn as one Text. */
106function merged(runs: Run[]): Run[] {
107  const out: Run[] = []
108  for (const run of runs) {
109    const last = out.at(-1)
110    if (last && last.tone === run.tone && !last.isFaint === !run.isFaint) last.text += run.text
111    else out.push({ ...run })
112  }
113
114  return out
115}
116
117const level = (share: number) => LEVELS[Math.round(share * (LEVELS.length - 1))] ?? LEVELS[0]!
118
119/**
120 * The band in a terminal: one dim capsule around one line, at the richest
121 * level of detail that fits `columns`. Where the band has fewer rows than the
122 * capsule takes, the line alone.
123 */
124export function terminalBand({ Box, Text }: Elements['terminal'], segments: Segment[], columns: number, rows: number): RenderElement {
125  const hasBorder = rows >= CAPSULE_ROWS
126  const room = columns - 2 * (PADDING + (hasBorder ? BORDER : 0))
127  const runs = DETAILS.map(detail => lineOf(segments, detail)).find((line, i) => widthOf(line) <= room || i === DETAILS.length - 1) ?? []
128  const line = merged(runs).map(run => <Text {...style(run)}>{run.text}</Text>)
129  if (!hasBorder) return <Box paddingX={PADDING}>{line}</Box>
130
131  return (
132    <Box borderStyle="round" borderDimColor alignSelf="flex-start" paddingX={PADDING}>
133      {line}
134    </Box>
135  )
136}
137