Neon pills above the prompt: context weather, context used, tokens and cost, and the 5-hour and 7-day usage limits with their reset times.

This repository is a plugin marketplace for Claude Code mods. It contains one mod: token-weather-limits.
This mod draws a band of neon pills above the prompt. The pills show the context window, the session cost, and your usage limits.
| Pill | Content | | :- | :- | | Weather | A forecast for the context window (Clear, Cloudy, Showers, Storm, Compact soon), a chart of the last 12 turns, and the tokens that the last turn added. | | CTX | The percent of the context window that is used, and the tokens used of the window. | | Tokens | The tokens in the context window and the session cost in US dollars. | | 5h | The percent of the 5-hour limit that is used, and the time until it resets. | | 7d | The percent of the 7-day limit that is used, and the time until it resets. |
| Place | Result | | :- | :- | | Code tab of the Claude desktop app (local and SSH sessions) | Neon pills, drawn as SVG | | claude in a terminal | The same pills, drawn as coloured text | | Cloud sessions and WSL sessions | The mod does not load |
Run this command in a Claude Code session:
/plugin install token-weather-limits --marketplace wenhao-shi/claude-mods
Or run these commands in your shell:
claude plugin marketplace add wenhao-shi/claude-mods
claude plugin install token-weather-limits@wenhao-shi-mods --scope user
If a session is already open, run /reload-plugins in that session.
In the desktop app, you can also use + > Plugins > Manage plugins to turn the mod on or off.
A mod runs with your permissions and is not in a sandbox. Read the code before you install a mod.
This mod reads only the usage figures from $.session.usage(). It does not read or write files, start processes, or make network requests. To see the events and calls of the mod, run this command on a clone of the repository:
claude plugin validate ./token-weather-limits
claude plugin test ./token-weather-limits
The test checks the pill order, the percent values, the red colour, and the layout from one line to five lines. It runs on the terminal and desktop surfaces.
This mod is based on Token Weather by Anthropic, licensed under Apache-2.0. This repository uses the same license. See LICENSE.
Changes from Token Weather:
e.props. The original read e.hasSurvey and e.bodyColumns, which are not set.hooks/register.ts 551 lines1// Based on Token Weather, Copyright 2026 Anthropic PBC, Apache-2.0.
2// Changed in 2026 by wenhao-shi: neon SVG pills, 5h and 7d limits, cost,
3// and the line layout. See README.md for the full list of changes.
4//
5// Token Weather + limits: one band above the prompt, drawn as round pills
6// weather the original forecast: icon and word, last-turns chart, trend
7// CTX context window used: badge on a track, then tokens / window
8// cost tokens in context and the session cost in US dollars
9// 5h 5-hour limit used: badge on a track, then time to reset
10// 7d 7-day (weekly) limit used, the same way
11//
12// The pills form two groups, each with a fixed gap: context (weather, CTX, cost)
13// and limits (5h, 7d). When all five fit on one line, the context group sits
14// at the left and the limit group at the right. Otherwise each group starts a
15// new line and fills lines from the left: two lines, or up to five when narrow.
16//
17// A limit pill turns red at 80% used; the CTX pill at 90% used.
18//
19// The desktop app draws each pill as an SVG (round shapes need vector drawing).
20// The terminal draws the same pills as coloured text.
21//
22// Figures come from $.session.usage(), the same source as the status line.
23// Limits exist only on a subscription, after the first response.
24
25import type { Register } from 'claude-code'
26
27type Reading = { tokens: number; window: number; percent: number }
28type Limit = { kind: string; percentUsed: number; resetsAt?: string }
29
30type Accent = { badge: string; edge: string; track: string; text: string }
31type MeterPill = {
32 kind: 'meter'
33 label: string
34 fraction: number
35 value: string
36 tail: string
37 accent: Accent
38}
39type WeatherPill = {
40 kind: 'weather'
41 icon: string
42 word: string
43 color: string
44 chart: number[]
45 trend: string
46}
47type CostPill = { kind: 'cost'; tokens: string; usd: string }
48type Pill = MeterPill | WeatherPill | CostPill
49// A drawn pill and its width: cells on the terminal, CSS pixels elsewhere.
50type Drawn = { el: unknown; width: number }
51
52const HISTORY = 12
53const BARS = '▁▂▃▄▅▆▇█'
54
55// Forecast bands, by percent of the context window used (unchanged from Token Weather).
56const FORECAST = [
57 { upTo: 25, icon: '☀', word: 'Clear', color: '#facc15' },
58 { upTo: 50, icon: '☁', word: 'Cloudy', color: '#22d3ee' },
59 { upTo: 75, icon: '☂', word: 'Showers', color: '#60a5fa' },
60 { upTo: 90, icon: '☇', word: 'Storm', color: '#e879f9' },
61 { upTo: Infinity, icon: '↯', word: 'Compact soon', color: '#f87171' },
62]
63
64const BLUE: Accent = { badge: '#2563eb', edge: '#60a5fa', track: '#3b82f6', text: '#60a5fa' }
65const PURPLE: Accent = { badge: '#7c3aed', edge: '#a78bfa', track: '#8b5cf6', text: '#a78bfa' }
66const TEAL: Accent = { badge: '#0d9488', edge: '#2dd4bf', track: '#14b8a6', text: '#2dd4bf' }
67const RED: Accent = { badge: '#dc2626', edge: '#f87171', track: '#ef4444', text: '#f87171' }
68
69const WINDOWS = [
70 { kind: 'five_hour', label: '5h', accent: BLUE },
71 { kind: 'seven_day', label: '7d', accent: PURPLE },
72]
73
74let readings: Reading[] = []
75let limits: Limit[] = []
76let costUsd: number | undefined
77let stopTimer: (() => void) | undefined
78
79export const register: Register = (on) => {
80 on('session.start', async ($, e, next) => {
81 const result = await next(e)
82 readings = []
83 await takeReading($)
84 // Redraw once a minute so "~2h 15m" stays true between turns.
85 stopTimer?.()
86 stopTimer = $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
87 return result
88 })
89
90 on('turn.complete', async ($, e, next) => {
91 const result = await next(e)
92 if (e.agentId) {
93 return result
94 }
95 await takeReading($)
96 return result
97 })
98
99 // Fires when a limit window moves a whole point or the cost grows.
100 on('session.measure', async ($, e, next) => {
101 limits = e.rateLimits ?? []
102 costUsd = e.cost?.usd ?? costUsd
103 $.ui.invalidate('ui.render')
104 return next(e)
105 })
106
107 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
108 if (e.props.hasSurvey || (readings.length === 0 && limits.length === 0)) {
109 return next(e)
110 }
111 const { first, second } = buildPills(await $.clock.now())
112 const els: any = $.ui.resolve(e)
113 // resolve() names every element on every surface; one a surface cannot draw
114 // is an empty fragment, so pick by surface. Only the terminal lacks Svg.
115 const isTerminal = e.surface === 'terminal'
116 const draw = (p: Pill): Drawn =>
117 isTerminal ? textPill(els.Box, els.Text, p) : svgPill(els.Svg, p)
118 const top = first.map(draw)
119 const bottom = second.map(draw)
120
121 // One line when every pill fits, else two lines.
122 // Widths are cells on the terminal and CSS pixels on the other surfaces.
123 // The gap is fixed: one cell on the terminal; on the other surfaces each SVG
124 // carries a margin of M on both sides, so pills sit 2 * M px apart with no gap.
125 const all = [...top, ...bottom]
126 const columns = (e.props.bodyColumns ?? 80) - 2
127 const room = isTerminal ? columns : columns * CELL_PX
128 const gap = isTerminal ? 1 : 0
129 const need = all.reduce((sum, d) => sum + d.width, 0) + gap * all.length
130 const isOneLine = need <= room
131
132 // A group: pills side by side with the fixed gap.
133 const group = (line: Drawn[]) =>
134 els.Box({ flexDirection: 'row', gap, children: line.map((d) => d.el) })
135 // A row: the full width of the band.
136 const row = (justifyContent: string, children: unknown[]) =>
137 els.Box({ flexDirection: 'row', flexWrap: 'wrap', justifyContent, gap, width: '100%', children })
138
139 // One line: the context group at the left, the limit group at the right.
140 // Otherwise each group starts a new line and fills lines from the left, so a
141 // narrow band takes up to five lines, one pill each.
142 const rows = isOneLine
143 ? [row('space-between', [top, bottom].filter((g) => g.length > 0).map(group))]
144 : [...pack(top, room, gap), ...pack(bottom, room, gap)].map((line) => row('flex-start', [group(line)]))
145 return els.Box({ flexDirection: 'column', width: '100%', paddingX: 1, children: rows })
146 })
147}
148
149// Puts pills on lines in order. A pill that does not fit starts a new line;
150// a pill wider than the room still gets a line of its own.
151function pack(pills: Drawn[], room: number, gap: number): Drawn[][] {
152 const lines: Drawn[][] = []
153 let line: Drawn[] = []
154 let used = 0
155 for (const d of pills) {
156 const add = line.length > 0 ? gap + d.width : d.width
157 if (line.length > 0 && used + add > room) {
158 lines.push(line)
159 line = [d]
160 used = d.width
161 } else {
162 line.push(d)
163 used += add
164 }
165 }
166 if (line.length > 0) lines.push(line)
167 return lines
168}
169
170async function takeReading($: any) {
171 try {
172 const usage = await $.session.usage()
173 limits = usage.rateLimits ?? limits
174 costUsd = usage.cost?.usd ?? costUsd
175 const { context } = usage
176 if (context && context.window) {
177 const tokens = context.tokens ?? 0
178 const percent = Math.round(context.percent ?? (tokens / context.window) * 100)
179 // The session.start reading is 0 before any response; drop it once real readings arrive.
180 readings = readings.filter((r) => r.tokens > 0)
181 readings.push({ tokens, window: context.window, percent })
182 if (readings.length > HISTORY) {
183 readings = readings.slice(-HISTORY)
184 }
185 }
186 $.ui.invalidate('ui.render')
187 } catch {
188 // No reading this turn; the band keeps the last one.
189 }
190}
191
192// ---------------------------------------------------------------- pill data
193
194// Order: weather, CTX, tokens on the first line; 5h, 7d after them, or on a
195// second line when one line is too narrow.
196function buildPills(now: number): { first: Pill[]; second: Pill[] } {
197 const first: Pill[] = []
198 const second: Pill[] = []
199 const last = readings[readings.length - 1]
200 if (last) {
201 const f = forecastFor(last.percent)
202 first.push({
203 kind: 'weather',
204 icon: f.icon,
205 word: f.word,
206 color: f.color,
207 chart: readings.map((r) => r.tokens),
208 trend: trendWord(),
209 })
210 first.push({
211 kind: 'meter',
212 label: 'CTX',
213 fraction: last.percent / 100,
214 value: `${last.percent}%`,
215 tail: `${short(last.tokens)}/${short(last.window)}`,
216 accent: last.percent >= 90 ? RED : TEAL,
217 })
218 if (costUsd !== undefined) {
219 first.push({ kind: 'cost', tokens: short(last.tokens), usd: `$${costUsd.toFixed(2)}` })
220 }
221 }
222 for (const w of WINDOWS) {
223 const limit = limits.find((l) => l.kind === w.kind)
224 if (!limit) continue
225 const used = clamp(Math.round(limit.percentUsed), 0, 100)
226 second.push({
227 kind: 'meter',
228 label: w.label,
229 fraction: used / 100,
230 value: `${used}%`,
231 tail: untilReset(limit.resetsAt, now),
232 accent: used >= 80 ? RED : w.accent,
233 })
234 }
235 return { first, second }
236}
237
238function altText(p: Pill) {
239 if (p.kind === 'meter') {
240 return p.label === 'CTX'
241 ? `Context ${p.value} used, ${p.tail}`
242 : `${p.label} limit ${p.value} used, resets in ${p.tail.replace('~', '')}`
243 }
244 if (p.kind === 'weather') {
245 return `${p.word}${p.trend ? `, ${p.trend}` : ''}`
246 }
247 return `${p.tokens} tokens, ${p.usd}`
248}
249
250// ---------------------------------------------------------------- desktop: SVG
251
252// The band's width arrives in cells of the app's code font. CELL_PX converts
253// it to CSS pixels. Measured in the desktop app at 100% scale: 159 cells for a
254// band about 1500 px wide, so 8.6 to 9.5 px a cell. The low end is used, so a
255// close fit takes two lines instead of one line that wraps.
256const CELL_PX = 8.6
257const FONT = `'Segoe UI Variable Text','Segoe UI',system-ui,-apple-system,sans-serif`
258const PILL_FILL = '#0e1014'
259const PILL_EDGE = '#262a33'
260const LABEL = '#8b93a1'
261const MUTED = '#9ca3af'
262
263// F is the text size, in CSS pixels. (14.5 px Segoe UI gives digits 11 px high,
264// the same as the app's own "Opus 5.5 Extra" text.) Every other size scales
265// from F with K, so a change of F resizes the whole pill. The glow does not scale.
266const F = 11
267const K = F / 10.5
268const px = (n: number) => +(n * K).toFixed(2)
269
270const F_LABEL = F
271const F_BADGE = F - 1
272const F_TAIL = F
273const F_WORD = F
274const F_ICON = F + 1
275const F_SMALL = F
276
277const H = Math.round(px(24))
278const PAD = Math.round(px(10))
279const TRACK = Math.round(px(84))
280
281// The text baseline that centres a font size on the pill's middle line.
282const base = (size: number) => +(H / 2 + size * 0.35).toFixed(2)
283
284// Neon: a bright core over a soft halo of its own colour. The halo is a blur at
285// reduced alpha, so it glows without glare. M leaves room for the outer halo,
286// and two M make the fixed gap between pills.
287const M = 5
288const NEON_DEFS =
289 `<defs>` +
290 // halo: badge and filled track
291 `<filter id="halo" x="-40%" y="-80%" width="180%" height="260%"><feGaussianBlur stdDeviation="2.4" result="b"/>` +
292 `<feComponentTransfer in="b" result="h"><feFuncA type="linear" slope="0.55"/></feComponentTransfer>` +
293 `<feMerge><feMergeNode in="h"/><feMergeNode in="SourceGraphic"/></feMerge></filter>` +
294 // soft: coloured text
295 `<filter id="soft" x="-20%" y="-60%" width="140%" height="220%"><feGaussianBlur stdDeviation="1.3" result="b"/>` +
296 `<feComponentTransfer in="b" result="h"><feFuncA type="linear" slope="0.45"/></feComponentTransfer>` +
297 `<feMerge><feMergeNode in="h"/><feMergeNode in="SourceGraphic"/></feMerge></filter>` +
298 // edge: the tube around the pill
299 `<filter id="edge" x="-10%" y="-50%" width="120%" height="200%"><feGaussianBlur stdDeviation="2.6"/></filter>` +
300 `</defs>`
301
302function svgPill(Svg: any, p: Pill) {
303 const { width, body } =
304 p.kind === 'meter' ? meterSvg(p) : p.kind === 'weather' ? weatherSvg(p) : costSvg(p)
305 const glow = p.kind === 'meter' ? p.accent.edge : p.kind === 'weather' ? p.color : '#fb923c'
306 const w = width + 2 * M
307 const h = H + 2 * M
308 const r = (H - 1) / 2
309 const source =
310 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" font-family="${FONT}">` +
311 NEON_DEFS +
312 `<g transform="translate(${M} ${M})">` +
313 // outer halo, then the dark pill, then a thin tinted tube on its edge
314 `<rect x="0.5" y="0.5" width="${width - 1}" height="${H - 1}" rx="${r}" fill="none" stroke="${glow}" stroke-width="2" stroke-opacity="0.32" filter="url(#edge)"/>` +
315 `<rect x="0.5" y="0.5" width="${width - 1}" height="${H - 1}" rx="${r}" fill="${PILL_FILL}"/>` +
316 `<rect x="0.5" y="0.5" width="${width - 1}" height="${H - 1}" rx="${r}" fill="none" stroke="${PILL_EDGE}"/>` +
317 `<rect x="0.5" y="0.5" width="${width - 1}" height="${H - 1}" rx="${r}" fill="none" stroke="${glow}" stroke-opacity="0.28"/>` +
318 body +
319 `</g></svg>`
320 return { el: Svg({ source, alt: altText(p), width: w, height: h }), width: w }
321}
322
323// LABEL ──(badge)────── tail, with the track filled up to the badge.
324function meterSvg(p: MeterPill) {
325 const a = p.accent
326 let x = PAD
327 const label = svgText(x, base(F_LABEL), p.label, { size: F_LABEL, weight: 700, fill: LABEL, spacing: px(1.1) })
328 x += textWidth(p.label, F_LABEL, px(1.1)) + px(8)
329
330 // Badge: a bolt, then the value, in a round box on the track.
331 const t0 = +x.toFixed(2)
332 const badgeH = Math.round(px(15))
333 const boltW = px(5.6)
334 const badgeW = Math.ceil(px(6) + boltW + px(3) + textWidth(p.value, F_BADGE) + px(7))
335 const cx = clamp(t0 + p.fraction * TRACK, t0 + badgeW / 2, t0 + TRACK - badgeW / 2)
336 const bx = +(cx - badgeW / 2).toFixed(2)
337 const by = (H - badgeH) / 2
338 const trackH = px(3)
339 const ty = +(H / 2 - trackH / 2).toFixed(2)
340 // An SVG id has letters and digits only: '5h' gives 'g5h'.
341 const id = `g${p.label.replace(/[^A-Za-z0-9]/g, '')}`
342 const track =
343 `<defs><linearGradient id="${id}" x1="0" x2="1"><stop offset="0" stop-color="${a.track}" stop-opacity="0.25"/><stop offset="1" stop-color="${a.track}"/></linearGradient></defs>` +
344 `<rect x="${t0}" y="${ty}" width="${TRACK}" height="${trackH}" rx="${trackH / 2}" fill="#2a2e37"/>` +
345 `<rect x="${t0}" y="${ty}" width="${Math.max(0, +(cx - t0).toFixed(2))}" height="${trackH}" rx="${trackH / 2}" fill="url(#${id})" filter="url(#halo)"/>` +
346 `<g filter="url(#halo)">` +
347 `<rect x="${bx}" y="${by}" width="${badgeW}" height="${badgeH}" rx="${badgeH / 2}" fill="${a.badge}" fill-opacity="0.88" stroke="${a.edge}" stroke-opacity="0.8"/>` +
348 bolt(bx + px(6), H / 2 - px(4), '#ffffff') +
349 svgText(+(bx + px(6) + boltW + px(3)).toFixed(2), base(F_BADGE), p.value, { size: F_BADGE, weight: 700, fill: '#ffffff' }) +
350 `</g>`
351 x = t0 + TRACK + px(8)
352
353 const tail = svgText(+x.toFixed(2), base(F_TAIL), p.tail, { size: F_TAIL, weight: 600, fill: a.text, glow: true })
354 x += textWidth(p.tail, F_TAIL) + PAD
355 return { width: Math.ceil(x), body: label + track + tail }
356}
357
358// ☀ Clear last turns ▂▃▅▇ ▲ +4k last turn
359function weatherSvg(p: WeatherPill) {
360 let x = PAD
361 let body = svgText(x, base(F_ICON), p.icon, { size: F_ICON, weight: 400, fill: p.color, glow: true })
362 x += px(14)
363 body += svgText(+x.toFixed(2), base(F_WORD), p.word, { size: F_WORD, weight: 700, fill: p.color, glow: true })
364 x += textWidth(p.word, F_WORD) + px(8)
365 body += svgText(+x.toFixed(2), base(F_SMALL), 'last turns', { size: F_SMALL, weight: 500, fill: LABEL })
366 x += textWidth('last turns', F_SMALL) + px(6)
367
368 // Bars every `step` px, up to `tall` px high, on a floor below the middle line.
369 const top = Math.max(...p.chart, 1)
370 const step = px(5)
371 const barW = px(3)
372 const tall = px(10)
373 const floor = +(H / 2 + px(5)).toFixed(2)
374 body += `<g filter="url(#halo)">`
375 p.chart.forEach((tokens, i) => {
376 const h = Math.max(2, Math.round((tokens / top) * tall))
377 const isLast = i === p.chart.length - 1
378 body += `<rect x="${+(x + i * step).toFixed(2)}" y="${+(floor - h).toFixed(2)}" width="${barW}" height="${h}" rx="0.8" fill="${p.color}" fill-opacity="${isLast ? 1 : 0.45}"/>`
379 })
380 body += `</g>`
381 x += p.chart.length * step + px(5)
382
383 if (p.trend) {
384 body += svgText(+x.toFixed(2), base(F_SMALL), p.trend, { size: F_SMALL, weight: 500, fill: MUTED })
385 x += textWidth(p.trend, F_SMALL)
386 }
387 return { width: Math.ceil(x + PAD), body }
388}
389
390// (flame) 61k tokens · $0.14
391function costSvg(p: CostPill) {
392 let x = PAD
393 let body = `<g filter="url(#halo)">${flame(x, H / 2 - px(5.4), '#fb923c')}</g>`
394 x += px(12)
395 body += svgText(+x.toFixed(2), base(F_TAIL), p.tokens, { size: F_TAIL, weight: 700, fill: '#f3f4f6' })
396 x += textWidth(p.tokens, F_TAIL) + px(4)
397 body += svgText(+x.toFixed(2), base(F_SMALL), 'tokens', { size: F_SMALL, weight: 400, fill: MUTED })
398 x += textWidth('tokens', F_SMALL) + px(5)
399 body += svgText(+x.toFixed(2), base(F_TAIL), '·', { size: F_TAIL, weight: 700, fill: '#4b5563' })
400 x += px(7)
401 body += svgText(+x.toFixed(2), base(F_TAIL), p.usd, { size: F_TAIL, weight: 700, fill: '#4ade80', glow: true })
402 x += textWidth(p.usd, F_TAIL)
403 return { width: Math.ceil(x + PAD), body }
404}
405
406// A lightning bolt, 7 x 10 px before scaling.
407function bolt(x: number, y: number, fill: string) {
408 return `<path transform="translate(${+x.toFixed(2)} ${+y.toFixed(2)}) scale(${px(0.8)})" d="M4.6 0 L0 6 H3.1 L2.3 10 L7 3.7 H3.8 Z" fill="${fill}"/>`
409}
410
411// A flame, 9.5 x 13.5 px before scaling.
412function flame(x: number, y: number, fill: string) {
413 const t = `translate(${x} ${+y.toFixed(2)}) scale(${px(0.8)})`
414 return (
415 `<path transform="${t}" d="M5.5 0 C6 3 9.5 4.5 9.5 8.5 C9.5 11.3 7.6 13.5 5 13.5 C2.4 13.5 0.5 11.4 0.5 8.8 C0.5 6.6 1.8 5.3 2.8 4.2 C3 5.6 3.6 6.4 4.4 6.8 C4 4.2 4.6 1.8 5.5 0 Z" fill="${fill}"/>` +
416 `<path transform="${t}" d="M5 13.5 C3.6 13.5 2.8 12.4 2.8 11.2 C2.8 9.8 4 9 4.6 7.9 C5.2 9.2 7.2 9.9 7.2 11.4 C7.2 12.6 6.2 13.5 5 13.5 Z" fill="#fde68a" fill-opacity="0.85"/>`
417 )
418}
419
420function svgText(
421 x: number,
422 y: number,
423 s: string,
424 o: { size: number; weight: number; fill: string; spacing?: number; glow?: boolean },
425) {
426 const spacing = o.spacing ? ` letter-spacing="${o.spacing}"` : ''
427 const glow = o.glow ? ` filter="url(#soft)"` : ''
428 return `<text x="${x}" y="${y}" font-size="${o.size}" font-weight="${o.weight}" fill="${o.fill}"${spacing}${glow}>${esc(s)}</text>`
429}
430
431// An estimate of the drawn width of `s` in Segoe UI. The pills size from it,
432// so the estimate leans wide rather than letting text run past the edge.
433function textWidth(s: string, size: number, spacing = 0) {
434 let em = 0
435 for (const ch of s) {
436 if (/[0-9]/.test(ch)) em += 0.57
437 else if (/[MW%]/.test(ch)) em += 0.86
438 else if (/[A-Z]/.test(ch)) em += 0.64
439 else if (/[mw]/.test(ch)) em += 0.82
440 else if (/[iljtfr]/.test(ch)) em += 0.33
441 else if (/[a-z]/.test(ch)) em += 0.54
442 else if (ch === ' ') em += 0.27
443 else if (/[.,:·]/.test(ch)) em += 0.27
444 else if (/[~+$/]/.test(ch)) em += 0.58
445 else em += 0.95
446 }
447 return em * size * 1.04 + spacing * [...s].length
448}
449
450function esc(s: string) {
451 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
452}
453
454// ---------------------------------------------------------------- terminal: text
455
456const T_BG = '#14171d'
457const T_DIM = '#6b7280'
458const T_BAR = 8
459
460// One piece of a terminal pill: its text and its Text props.
461type Piece = [string, Record<string, unknown>]
462
463function textPill(Box: any, Text: any, p: Pill): Drawn {
464 const pieces: Piece[] =
465 p.kind === 'meter'
466 ? meterPieces(p)
467 : p.kind === 'weather'
468 ? [
469 [`${p.icon} ${p.word}`, { color: p.color, bold: true }],
470 [' last turns ', { color: T_DIM }],
471 [chartText(p.chart), { color: p.color }],
472 ...(p.trend ? [[` ${p.trend}`, { color: T_DIM }] as Piece] : []),
473 ]
474 : [
475 ['◆ ', { color: '#fb923c' }],
476 [p.tokens, { bold: true, color: '#f3f4f6' }],
477 [' tokens · ', { color: T_DIM }],
478 [p.usd, { bold: true, color: '#4ade80' }],
479 ]
480 const children = pieces.map(([s, o]) => Text({ ...o, children: s }))
481 // Cells: the text of every piece, plus one cell of padding at each end.
482 const width = pieces.reduce((sum, [s]) => sum + [...s].length, 0) + 2
483 return { el: Box({ flexDirection: 'row', paddingX: 1, backgroundColor: T_BG, children }), width }
484}
485
486function meterPieces(p: MeterPill): Piece[] {
487 const filled = clamp(Math.round(p.fraction * T_BAR), 0, T_BAR)
488 return [
489 [`${p.label} `, { color: T_DIM, bold: true }],
490 ['━'.repeat(filled), { color: p.accent.track }],
491 [` ϟ ${p.value} `, { color: '#ffffff', backgroundColor: p.accent.badge, bold: true }],
492 ['─'.repeat(T_BAR - filled), { color: T_DIM }],
493 [` ${p.tail}`, { color: p.accent.text, bold: true }],
494 ]
495}
496
497// ---------------------------------------------------------------- shared
498
499function forecastFor(percent: number) {
500 return FORECAST.find((band) => percent < band.upTo) ?? FORECAST[FORECAST.length - 1]
501}
502
503// Bars scale to the busiest reading shown, so growth shows at any fill level.
504function chartText(chart: number[]) {
505 const top = Math.max(...chart, 1)
506 return chart
507 .map((t) => BARS[Math.min(BARS.length - 1, Math.floor((t / top) * (BARS.length - 1)))])
508 .join('')
509}
510
511function trendWord() {
512 if (readings.length < 2) {
513 return ''
514 }
515 const delta = readings[readings.length - 1].tokens - readings[readings.length - 2].tokens
516 if (delta > 0) return `▲ +${short(delta)} last turn`
517 if (delta < 0) return `▼ ${short(-delta)} last turn`
518 return 'steady'
519}
520
521// "~2h 15m", "~3d 8h", or "~now" once the reset time has passed.
522function untilReset(resetsAt: string | undefined, now: number) {
523 if (!resetsAt) {
524 return '~?'
525 }
526 const ms = new Date(resetsAt).getTime() - now
527 if (Number.isNaN(ms)) {
528 return '~?'
529 }
530 if (ms <= 0) {
531 return '~now'
532 }
533 const minutes = Math.max(1, Math.round(ms / 60_000))
534 const days = Math.floor(minutes / 1440)
535 const hours = Math.floor((minutes % 1440) / 60)
536 const mins = minutes % 60
537 if (days > 0) return `~${days}d ${hours}h`
538 if (hours > 0) return `~${hours}h ${mins}m`
539 return `~${mins}m`
540}
541
542function short(n: number) {
543 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
544 if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`
545 return String(n)
546}
547
548function clamp(n: number, lo: number, hi: number) {
549 return Math.min(hi, Math.max(lo, n))
550}
551