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…

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.
○ ◔ ◑ ◕ ●) and writes the share next to the tokens.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 totals it, and what the last turn added. On a subscription this is the API-price equivalent, not a bill.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.
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.
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.
The mod makes no network requests and sends nothing anywhere.
promptCacheTtl setting and the four prompt-cache environment variables named above plus DISABLE_PROMPT_CACHING.To list this yourself before installing, run claude plugin validate mods/context-weather from a clone.
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.
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.
hooks/register.tsx 216 lines1import 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}
216hooks/desktop.tsx 167 lines1import 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 => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[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}
167hooks/measure.ts 369 lines1// 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}
369hooks/terminal.tsx 137 lines1import 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