SLOPSHOPPER

token-weather

A one-line forecast of your context window above the prompt: Clear to Storm, with a sparkline and cost.

newband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-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 ⟨Claude Code's own drawing⟩ ☁ Cloudy 49% of context 97.4k / 200k $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ☁ Cloudy 49% of context 97.4k / 200k $0.42
Source 2 files
hooks/register.tsx 122 lines
1// Token Weather: how full the context window is, as a weather forecast,
2// one line above the prompt. Updates after every main-loop turn.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { TokenWeatherReading } from '../types'
8
9const KEEP = 12
10const BARS = '▁▂▃▄▅▆▇█'
11
12type Sky = { below: number; icon: string; word: string; color: string }
13
14// Single-width symbols only, so the band lines up in every terminal font.
15const SKIES: readonly Sky[] = [
16  { below: 25, icon: '☀', word: 'Clear', color: 'yellow' },
17  { below: 50, icon: '☁', word: 'Cloudy', color: 'cyan' },
18  { below: 75, icon: '☂', word: 'Showers', color: 'blue' },
19  { below: 90, icon: '☇', word: 'Storm', color: 'magenta' },
20  { below: Infinity, icon: '↯', word: 'Compact soon', color: 'red' },
21]
22
23const readings = atom({ plugin: 'token-weather', key: 'readings' } as const, [])
24
25export const register: Register = on => {
26  on('session.start', async ($, e, next) => {
27    const result = await next(e)
28    await measure($)
29    return result
30  })
31
32  on('turn.complete', async ($, e, next) => {
33    const result = await next(e)
34    if (e.agentId === undefined) await measure($)
35    return result
36  })
37
38  on('session.end', async ($, e, next) => {
39    if (e.reason === 'clear') await update($, readings, () => [])
40    return next(e)
41  })
42
43  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
44    const history = await read($, readings)
45    const below = await next(e)
46    if (e.props.hasSurvey || history.length === 0) return below
47
48    const { Box, Text } = $.ui.resolve(e)
49    const now = history[history.length - 1]!
50    const sky = skyFor(now.percent)
51    const width = e.props.bodyColumns
52
53    const line = (
54      <Box key="token-weather" flexDirection="row" paddingX={1}>
55        <Text color={sky.color} bold>
56          {sky.icon} {sky.word}
57        </Text>
58        <Text> {now.percent}% of context</Text>
59        <Text dimColor>
60          {'  '}
61          {short(now.tokens)} / {short(now.window)}
62        </Text>
63        {width >= 64 && history.length > 1 && (
64          <Text color={sky.color}>{'  ' + sparkline(history)}</Text>
65        )}
66        {width >= 80 && history.length > 1 && <Text dimColor>{'  ' + trend(history)}</Text>}
67        {width >= 96 && now.usd !== undefined && <Text dimColor>{'  $' + now.usd.toFixed(2)}</Text>}
68      </Box>
69    )
70
71    return (
72      <Box flexDirection="column">
73        {below}
74        {line}
75      </Box>
76    )
77  })
78}
79
80async function measure($: EngineInterface): Promise<void> {
81  const usage = await $.session.usage()
82  const { context } = usage
83  if (!context.window) return
84  const tokens = context.tokens ?? 0
85  const percent = context.percent ?? Math.round((tokens / context.window) * 100)
86  const reading: TokenWeatherReading = { tokens, window: context.window, percent }
87  if (usage.cost !== undefined) reading.usd = usage.cost.usd
88  await update($, readings, list => {
89    const last = list[list.length - 1]
90    // A session.start after a hot reload reads the same figures again: skip the repeat.
91    if (last && last.tokens === reading.tokens && last.window === reading.window) {
92      return [...list.slice(0, -1), reading]
93    }
94    return [...list, reading].slice(-KEEP)
95  })
96}
97
98export function skyFor(percent: number): Sky {
99  return SKIES.find(s => percent < s.below) ?? SKIES[SKIES.length - 1]!
100}
101
102function sparkline(history: readonly TokenWeatherReading[]): string {
103  const top = Math.max(1, ...history.map(r => r.tokens))
104  return history
105    .map(r => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))])
106    .join('')
107}
108
109function trend(history: readonly TokenWeatherReading[]): string {
110  const a = history[history.length - 2]!
111  const b = history[history.length - 1]!
112  const delta = b.tokens - a.tokens
113  if (delta === 0) return 'steady'
114  return delta > 0 ? `▲ +${short(delta)} last turn` : `▼ ${short(-delta)} last turn`
115}
116
117export function short(n: number): string {
118  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
119  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
120  return String(n)
121}
122
types/index.d.ts 14 lines
1export type TokenWeatherReading = {
2  tokens: number
3  window: number
4  percent: number
5  /** Session cost in US dollars when the host keeps a ledger. */
6  usd?: number
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'token-weather': { readings: TokenWeatherReading[] }
12  }
13}
14