SLOPSHOPPER

token-weather

A live emoji forecast of the context window above the prompt: weather, fill percentage, and a chart of the last turns, with a one-click context handoff.

newbandcommandtoasttimer
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 › /handoff ⎿ token-weather: Writing a handoff, then clearing context… ☁️ Cloudy 49% of context 97.4k / 200k last turns ██ steady writing handoff… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁️ Cloudy 49% of context 97.4k / 200k last turns ██ steady writing handoff…
README

Token Weather

A live forecast of your context window, drawn as a band above the prompt.

☀️ Clear  18% of context  36k / 200k   last turns ▁▂▂▃▃▄   ⇄ Handoff
FillForecast
< 25%☀️ Clear
< 50%☁️ Cloudy
< 75%🌧️ Showers
< 90%⛈️ Storm
≥ 90%🌪️ Compact soon

The sparkline (on wide terminals) shows the last 12 turns. Past 50% it recommends a handoff.

/handoff

Run /handoff (or press the ⇄ Handoff button in the band, hotkey h) and Claude writes a self-contained handoff note. The mod then runs /clear, pastes the note into the fresh context, and carries on. You keep the thread without dragging a full context window along.

Credits

  • Original mod: token-weather by the Claude Code DevRel team at Anthropic, from the Claude Code playground. It came up with the forecast band, the fill reading and the turn chart.
  • Remix: Karthick Arun. I rewrote it in TypeScript, switched to emoji forecast icons, and added /handoff with the one-click ⇄ Handoff button.

License

Apache License 2.0, the same license as the original. See NOTICE for attribution and a list of changes. (The rest of this marketplace is MIT.)

Source 1 files
hooks/register.tsx 186 lines
1// Copyright 2026 Anthropic PBC
2// Copyright 2026 Karthick Arun (modifications)
3// SPDX-License-Identifier: Apache-2.0
4//
5// Token Weather: a live forecast of the context window, above the prompt.
6// Port of anthropics/claude-code-playground mods/token-weather, with emoji icons.
7import type { EngineInterface as Engine, Register } from 'claude-code'
8
9const HISTORY = 12
10const BARS = '▁▂▃▄▅▆▇█'
11
12// Forecast bands, by percent of the window used.
13const FORECAST = [
14  { upTo: 25, icon: '☀️', word: 'Clear', color: 'warning' },
15  { upTo: 50, icon: '☁️', word: 'Cloudy', color: 'cyan' },
16  { upTo: 75, icon: '🌧️', word: 'Showers', color: 'blue' },
17  { upTo: 90, icon: '⛈️', word: 'Storm', color: 'magenta' },
18  { upTo: Infinity, icon: '🌪️', word: 'Compact soon', color: 'red' },
19] as const
20
21const RECOMMEND_AT = 50
22
23const HANDOFF_REQUEST =
24  'Write a handoff for a fresh instance of yourself that will continue this conversation with an empty context. ' +
25  'It will know nothing except what you write, so carry over every fact, name, number, preference and constraint the user stated, verbatim where it matters. ' +
26  'Include: the goal, what is done, what is in progress, key decisions and why, relevant file paths and commands, ' +
27  'open questions, and the exact next steps. Skip CLAUDE.md instructions and memories: they reload on their own. ' +
28  'Be self-contained and concise. Reply with only the handoff, no preamble.'
29
30type Reading = { tokens: number; window: number; percent: number }
31
32// Oldest first.
33let readings: Reading[] = []
34
35// idle -> writing (waiting for the handoff reply) -> idle once cleared and resumed.
36let phase: 'idle' | 'writing' = 'idle'
37let hasRecommended = false
38
39async function startHandoff($: Engine) {
40  if (phase !== 'idle') return
41  phase = 'writing'
42  $.ui.invalidate('ui.render')
43  try {
44    await $.prompt.submit({ text: HANDOFF_REQUEST, asUser: true })
45  } catch {
46    phase = 'idle'
47    $.ui.invalidate('ui.render')
48  }
49}
50
51async function finishHandoff($: Engine) {
52  const messages = await $.session.messages()
53  const handoff = Array.isArray(messages)
54    ? messages.filter(m => m.role === 'assistant' && m.text).at(-1)?.text
55    : undefined
56  if (!handoff) {
57    phase = 'idle'
58    $.ui.toast('Handoff failed: no reply to carry over')
59    $.ui.invalidate('ui.render')
60    return
61  }
62  // Leave the turn.complete hook before clearing, so /clear isn't run inside a turn it waits on.
63  $.clock.after(0, async () => {
64    try {
65      await $.command.run({ command: 'clear' })
66      await registerCommand($)
67      readings = []
68      hasRecommended = false
69      await $.prompt.submit({
70        text: `Handoff from the previous context. Continue from here:\n\n${handoff}`,
71        asUser: true,
72      })
73    } catch {
74      $.ui.toast('Handoff: /clear failed')
75    } finally {
76      phase = 'idle'
77      $.ui.invalidate('ui.render')
78    }
79  })
80}
81
82// /handoff runs the same flow as the band's button.
83async function registerCommand($: Engine) {
84  await $.command.register({ name: 'handoff', description: 'Hand off to a fresh context: write a handoff, /clear, paste it, continue' })
85}
86
87export const register: Register = on => {
88  on('session.start', async ($, e, next) => {
89    const result = await next(e)
90    readings = []
91    await registerCommand($)
92    await takeReading($)
93    return result
94  })
95
96  on('command.run', { command: 'handoff' }, $ => {
97    if (phase !== 'idle') return { text: 'A handoff is already in progress.' }
98    // Not awaited: the prompt queues behind this command and runs once the session is idle.
99    void startHandoff($)
100    return { text: 'Writing a handoff, then clearing context…' }
101  })
102
103  on('turn.complete', async ($, e, next) => {
104    const result = await next(e)
105    if (!e.agentId) {
106      if (phase === 'writing') await finishHandoff($)
107      else await takeReading($)
108    }
109    return result
110  })
111
112  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
113    const now = readings.at(-1)
114    if (e.props.hasSurvey || !now) return next(e)
115
116    const { Box, Button, Text } = $.ui.resolve(e)
117    const f = forecastFor(now.percent)
118    const trend = trendWord()
119    const recommend = now.percent >= RECOMMEND_AT
120    const wide = (e.props.bodyColumns ?? 80) >= 60
121
122    return (
123      <Box flexDirection="row" paddingX={1}>
124        <Text color={f.color} bold>{`${f.icon} ${f.word}`}</Text>
125        <Text>{`  ${now.percent}% of context`}</Text>
126        <Text dimColor>{`  ${short(now.tokens)} / ${short(now.window)}`}</Text>
127        {wide && <Text dimColor>{'   last turns '}</Text>}
128        {wide && <Text color={f.color}>{chart()}</Text>}
129        {wide && trend && <Text dimColor>{`  ${trend}`}</Text>}
130        <Text>{'  '}</Text>
131        {phase === 'writing' ? (
132          <Text dimColor>writing handoff…</Text>
133        ) : e.props.isWorking ? null : (
134          <Button key="handoff" hotkey="h" label={recommend ? '⇄ Handoff (recommended)' : '⇄ Handoff'} onPress={() => startHandoff($)} />
135        )}
136      </Box>
137    )
138  })
139}
140
141async function takeReading($: Engine) {
142  try {
143    const { context } = await $.session.usage()
144    if (!context?.window) return
145    const tokens = context.tokens ?? 0
146    const percent = Math.round(context.percent ?? (tokens / context.window) * 100)
147    // The session.start reading is 0 before any response; drop it once real readings arrive.
148    readings = readings.filter(r => r.tokens > 0)
149    readings.push({ tokens, window: context.window, percent })
150    readings = readings.slice(-HISTORY)
151    if (percent >= RECOMMEND_AT && !hasRecommended) {
152      hasRecommended = true
153      $.ui.toast(`Context is ${percent}% full: consider a handoff (press the Handoff button)`)
154    }
155    $.ui.invalidate('ui.render')
156  } catch {
157    // No reading this turn; the band keeps the last one.
158  }
159}
160
161function forecastFor(percent: number) {
162  return FORECAST.find(b => percent < b.upTo) ?? FORECAST[4]
163}
164
165// Bars scale to the busiest reading shown, so growth shows at any fill level.
166function chart() {
167  const top = Math.max(...readings.map(r => r.tokens), 1)
168  return readings
169    .map(r => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))])
170    .join('')
171}
172
173function trendWord() {
174  if (readings.length < 2) return ''
175  const delta = (readings.at(-1)?.tokens ?? 0) - (readings.at(-2)?.tokens ?? 0)
176  if (delta > 0) return `▲ +${short(delta)} last turn`
177  if (delta < 0) return `▼ ${short(-delta)} last turn`
178  return 'steady'
179}
180
181function short(n: number) {
182  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
183  if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`
184  return String(n)
185}
186