SLOPSHOPPER

context-weather

A lo-fi pixel weather window whose sky follows the context: clear when it is empty, a thunderstorm when it is full, and day or night by your clock

newpanebandspinnercommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-weather
│ ┃ 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 │ ┃ │ ┃ › /weather │ ┃ ⎿ context-weather: Weather window opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Weather
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ overcast · 49% context
README

context-weather

A Claude Code mod that shows a lo-fi pixel weather window beside the conversation. The weather follows how full the context is, and the time of day follows your clock.

Weather from an empty context to a full one

Weather

ContextWeather
under 10%clear skies
10–25%fair
25–45%partly cloudy
45–60%overcast
60–75%light rain
75–88%heavy rain
88% and upthunderstorm, with lightning

Clouds get bigger and drift faster as the context fills, and the rain slants harder in the wind. The scene eases between levels, so after a compaction the storm clears over a few seconds. A caption under the scene reads like dusk · heavy rain · 76% context.

Time of day

Night, dawn, morning, afternoon, dusk, night, and a rainy night

The sun rises at 6:00 and sets at 20:00, local time, crossing the sky between them, with half an hour of twilight on either side. Dawn and dusk tint the sky warm. At night a moon crosses the sky and stars twinkle wherever it is clear. The house's window is lit at night and whenever the weather turns gloomy. The time zone is read from date +%z once a minute, so daylight saving changes are picked up.

Commands

  • /weather opens or closes the window.
  • /weather at 22:30 shows that time of day instead of the clock (handy for a preview); /weather at now follows the clock again.

Where it shows

  • In the terminal's fullscreen layout the window opens by itself, docked beside the transcript, once the terminal is at least 144 columns wide. Narrower, open it with /weather.
  • On the main screen (not fullscreen) it never opens by itself. /weather opens it above the prompt, up to 8 rows tall.
  • The pixel scene is terminal-only. In the desktop app's Code tab the window shows the caption alone.
  • It animates at 8 frames per second, two pixels per cell (▀ with separate top and bottom colors).

Install

/plugin marketplace add akerskuuug/claude-mods
/plugin install context-weather@claude-mods

Pick the user scope when prompted.

The mod runs as function hooks, which Claude Code only loads when they are switched on. Add this to ~/.claude/settings.json (if the file already exists, merge env into it) and restart Claude Code:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Without it the mod installs but shows nothing.

Source 2 files
hooks/register.tsx 502 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DayPhase } from '../types'
5
6const PANE = 'context-weather'
7const FRAME_MS = 125 // 8 fps: lo-fi on purpose
8const UPPER_HALF = 0x2580 // '▀': foreground paints the top pixel, background the bottom
9// Where heavy rain turns into a thunderstorm: the forecast and the lightning share it.
10const STORM = 0.88
11// Where overcast turns into light rain: the forecast and the raindrops share it.
12const RAIN = 0.6
13// The terminal width from which the pane opens by itself.
14const WIDE = 144
15
16const percent = atom({ plugin: 'context-weather', key: 'percent' } as const, null)
17const phaseNow = atom({ plugin: 'context-weather', key: 'phase' } as const, null)
18const override = atom({ plugin: 'context-weather', key: 'override' } as const, null)
19
20// ---------------------------------------------------------------- weather model
21
22export function contextPercent(context: { tokens?: number; window: number; percent?: number }): number {
23  const used = context.percent ?? (context.window > 0 ? ((context.tokens ?? 0) / context.window) * 100 : 0)
24  return Math.max(0, Math.min(100, used))
25}
26
27export function forecast(pct: number): string {
28  if (pct < 10) return 'clear skies'
29  if (pct < 25) return 'fair'
30  if (pct < 45) return 'partly cloudy'
31  if (pct < RAIN * 100) return 'overcast'
32  if (pct < 75) return 'light rain'
33  if (pct < STORM * 100) return 'heavy rain'
34  return 'thunderstorm'
35}
36
37const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
38
39/** How hard it rains, 0 to 1: none until light rain, full by 90%. */
40export function rainfall(s: number): number {
41  return clamp01((s - RAIN) / 0.3)
42}
43
44// ---------------------------------------------------------------- the day
45
46// A simple day: sunrise at 6, sunset at 20, twilight half an hour either side.
47const SUNRISE = 6
48const SUNSET = 20
49const TWILIGHT = 0.75
50
51/** Hours since local midnight, 0..24, from epoch ms and the UTC offset in minutes. */
52export function localHour(ms: number, offsetMinutes: number): number {
53  const minutes = Math.floor(ms / 60_000) + offsetMinutes
54  return (((minutes % 1440) + 1440) % 1440) / 60
55}
56
57/** 0 at night, 1 by day, easing through dawn and dusk. */
58export function daylight(hour: number): number {
59  const rise = clamp01((hour - (SUNRISE - TWILIGHT)) / (2 * TWILIGHT))
60  const set = clamp01((SUNSET + TWILIGHT - hour) / (2 * TWILIGHT))
61  return Math.min(rise, set)
62}
63
64/** 1 right at sunrise or sunset, fading to 0 an hour and a quarter away. */
65function glow(hour: number): number {
66  return clamp01(1 - Math.min(Math.abs(hour - SUNRISE), Math.abs(hour - SUNSET)) / 1.25)
67}
68
69export function dayPhase(hour: number): DayPhase {
70  if (Math.abs(hour - SUNRISE) <= TWILIGHT) return 'dawn'
71  if (Math.abs(hour - SUNSET) <= TWILIGHT) return 'dusk'
72  return hour > SUNRISE && hour < SUNSET ? 'day' : 'night'
73}
74
75/** Parses `date +%z` (`+0200`, `-0530`) into minutes east of UTC. */
76export function parseOffset(text: string): number | null {
77  const m = /^([+-])(\d\d)(\d\d)$/.exec(text.trim())
78  if (!m) return null
79  return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
80}
81
82/** `22`, `22:30`, `7:05` as hours since midnight; null for anything else. */
83export function parseClock(text: string): number | null {
84  const m = /^(\d{1,2})(?::(\d\d))?$/.exec(text.trim())
85  if (!m) return null
86  const h = Number(m[1])
87  const min = Number(m[2] ?? 0)
88  return h < 24 && min < 60 ? h + min / 60 : null
89}
90
91function hash(n: number): number {
92  let x = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
93  x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35)
94  return ((x ^ (x >>> 16)) >>> 0) / 0x100000000
95}
96
97function mix(a: number, b: number, t: number): number {
98  const k = clamp01(t)
99  const ch = (shift: number) => {
100    const x = (a >> shift) & 0xff
101    const y = (b >> shift) & 0xff
102    return Math.round(x + (y - x) * k) << shift
103  }
104  return ch(16) | ch(8) | ch(0)
105}
106
107// Three anchor palettes; storminess 0..0.5 blends clear→overcast, 0.5..1 overcast→storm.
108type Pal = { top: number; bottom: number; cloud: number; shade: number; ground: number }
109
110const SKY: Pal[] = [
111  { top: 0x4f8fd9, bottom: 0xa8d8f0, cloud: 0xf4f6f8, shade: 0xc9d3de, ground: 0x5d8a4a },
112  { top: 0x6f7f90, bottom: 0xa9b3bd, cloud: 0xb7bec7, shade: 0x8b939e, ground: 0x4a6b40 },
113  { top: 0x161a24, bottom: 0x353c4b, cloud: 0x4a505c, shade: 0x2e333d, ground: 0x26331f },
114]
115
116// The same three, after dark.
117const NIGHT: Pal[] = [
118  { top: 0x0a1030, bottom: 0x22305a, cloud: 0x3a4466, shade: 0x262d48, ground: 0x1a2a1c },
119  { top: 0x14171f, bottom: 0x2a2f3c, cloud: 0x3a3f4c, shade: 0x262a33, ground: 0x161f15 },
120  { top: 0x08090d, bottom: 0x161920, cloud: 0x2a2e37, shade: 0x1a1d24, ground: 0x0e140d },
121]
122
123function blend(anchors: Pal[], s: number): Pal {
124  const [clear, gray, storm] = anchors as [Pal, Pal, Pal]
125  const [a, b, t] = s < 0.5 ? [clear, gray, s * 2] : [gray, storm, (s - 0.5) * 2]
126  return {
127    top: mix(a.top, b.top, t),
128    bottom: mix(a.bottom, b.bottom, t),
129    cloud: mix(a.cloud, b.cloud, t),
130    shade: mix(a.shade, b.shade, t),
131    ground: mix(a.ground, b.ground, t),
132  }
133}
134
135function palette(s: number, hour: number): Pal {
136  const day = blend(SKY, s)
137  const night = blend(NIGHT, s)
138  const light = daylight(hour)
139  const warm = glow(hour) * (1 - s) // dawn and dusk colors, hidden by overcast
140  const pal: Pal = {
141    top: mix(night.top, day.top, light),
142    bottom: mix(night.bottom, day.bottom, light),
143    cloud: mix(night.cloud, day.cloud, light),
144    shade: mix(night.shade, day.shade, light),
145    ground: mix(night.ground, day.ground, light),
146  }
147  pal.top = mix(pal.top, 0x5b4a8a, warm * 0.45)
148  pal.bottom = mix(pal.bottom, 0xf28c5a, warm * 0.75)
149  pal.cloud = mix(pal.cloud, 0xf5b08a, warm * 0.5)
150  return pal
151}
152
153const BAYER = [0, 2, 3, 1]
154
155/**
156 * One frame of the scene as `w * h` pixels (0xRRGGBB), `s` the storminess 0..1
157 * and `hour` the local time of day, 0..24. Pure: the same arguments draw the same frame.
158 */
159export function paint(w: number, h: number, s: number, frame: number, hour = 12): Uint32Array {
160  const px = new Uint32Array(w * h)
161  const pal = palette(s, hour)
162  const light = daylight(hour)
163  const wind = 0.08 + s * 0.7
164  const groundY = h - Math.max(2, Math.round(h * 0.12))
165
166  // Lightning: in some 24-frame windows, a double flicker.
167  const win = Math.floor(frame / 24)
168  const strikeChance = s < STORM ? 0 : 0.25 + ((s - STORM) / (1 - STORM)) * 0.6
169  const strikeAt = Math.floor(hash(win * 7 + 1) * 18)
170  const inWindow = frame % 24
171  const isStrike = hash(win * 7) < strikeChance && (inWindow === strikeAt || inWindow === strikeAt + 2)
172  const flash = isStrike ? 0.45 : 0
173
174  // Sky: banded gradient, 2x2 ordered dither between bands.
175  const bands = 6
176  for (let y = 0; y < groundY; y++) {
177    for (let x = 0; x < w; x++) {
178      const t = (y / Math.max(1, groundY - 1)) * bands + BAYER[(y & 1) * 2 + (x & 1)]! / 4 - 0.375
179      const band = Math.max(0, Math.min(bands, Math.round(t)))
180      px[y * w + x] = mix(mix(pal.top, pal.bottom, band / bands), 0xd8dcf0, flash)
181    }
182  }
183
184  // Stars, twinkling, wherever the sky is dark and clear.
185  const starAlpha = (1 - light) * clamp01(1 - s * 2.2)
186  if (starAlpha > 0) {
187    const stars = Math.round(w * groundY * 0.03)
188    for (let i = 0; i < stars; i++) {
189      const x = Math.floor(hash(i * 5 + 101) * w)
190      const y = Math.floor(hash(i * 5 + 102) * groundY * 0.75)
191      const dim = hash(i * 5 + 103 + Math.floor(frame / 6) * 977) < 0.15
192      const p = y * w + x
193      px[p] = mix(px[p]!, 0xfff6d8, starAlpha * (dim ? 0.35 : 0.9))
194    }
195  }
196
197  // Sun and moon ride an arc from the left horizon to the right one.
198  const r = Math.max(2, Math.round(Math.min(w, groundY) * 0.09))
199  const arc = (t: number) => ({
200    cx: Math.round(w * (0.1 + 0.8 * t)),
201    cy: Math.round(groundY - 1 - Math.sin(Math.PI * t) * groundY * 0.72),
202  })
203  const disc = (cx: number, cy: number, color: number, alpha: number, cut?: { dx: number; dy: number }) => {
204    for (let y = cy - r; y <= cy + r; y++) {
205      for (let x = cx - r; x <= cx + r; x++) {
206        if (x < 0 || y < 0 || x >= w || y >= groundY || Math.hypot(x - cx, y - cy) > r) continue
207        if (cut && Math.hypot(x - cx - cut.dx, y - cy - cut.dy) <= r) continue
208        px[y * w + x] = mix(px[y * w + x]!, color, alpha)
209      }
210    }
211  }
212
213  const sunAlpha = clamp01(1 - s * 2.6)
214  const sunT = (hour - (SUNRISE - 0.5)) / (SUNSET - SUNRISE + 1)
215  if (sunAlpha > 0 && sunT >= 0 && sunT <= 1) {
216    const { cx, cy } = arc(sunT)
217    const color = mix(0xffd34d, 0xff7a3d, glow(hour))
218    disc(cx, cy, color, sunAlpha)
219    const glint = frame % 16 < 8 ? 1 : 0
220    for (const [dx, dy] of [[0, -1], [0, 1], [-1, 0], [1, 0]] as const) {
221      for (let k = r + 1; k <= r + 1 + glint; k++) {
222        const x = cx + dx * k
223        const y = cy + dy * k
224        if (x >= 0 && y >= 0 && x < w && y < groundY) px[y * w + x] = mix(px[y * w + x]!, 0xffe9a0, sunAlpha * 0.8)
225      }
226    }
227  }
228
229  const moonAlpha = clamp01(1 - s * 2.2) * (1 - light)
230  const moonT = (((hour - SUNSET + 24) % 24) + 0.5) / (24 - (SUNSET - SUNRISE) + 1)
231  if (moonAlpha > 0 && moonT <= 1) {
232    const { cx, cy } = arc(moonT)
233    disc(cx, cy, 0xe9e4c8, moonAlpha, { dx: Math.ceil(r * 0.7), dy: -Math.ceil(r * 0.4) })
234  }
235
236  // Overcast ceiling with a wavy lower edge.
237  const ceiling = clamp01((s - 0.45) * 2.2) * groundY * 0.3
238  if (ceiling > 0) {
239    for (let x = 0; x < w; x++) {
240      const edge = ceiling + Math.sin((x + frame * wind) / 3) * 1.2 + Math.sin((x + frame * wind * 0.6) / 7) * 1.5
241      for (let y = 0; y < Math.min(groundY, edge); y++) px[y * w + x] = y > edge - 2 ? pal.shade : pal.cloud
242    }
243  }
244
245  // Drifting clouds: more, bigger and faster the fuller the context.
246  const count = s < 0.06 ? 0 : Math.min(12, Math.ceil(s * 12))
247  for (let k = 0; k < count; k++) {
248    const cw = Math.round(6 + hash(k * 3 + 11) * 8 + s * 8)
249    const ch = Math.max(3, Math.round(cw * 0.35))
250    const span = w + cw * 2
251    const speed = wind * (0.5 + hash(k * 3 + 12) * 0.7)
252    const cx0 = (((hash(k * 3 + 13) * span + frame * speed) % span) + span) % span - cw
253    const cy0 = 1 + Math.round(hash(k * 5 + 17) * groundY * (0.35 + s * 0.15))
254    for (let y = cy0 - ch; y <= cy0 + 1; y++) {
255      for (let dx = -cw; dx <= cw; dx++) {
256        const x = Math.round(cx0 + dx)
257        if (x < 0 || y < 0 || x >= w || y >= groundY) continue
258        // Three bumps on a flat base.
259        const bump = Math.max(
260          ch - Math.abs(dx) * 0.35,
261          ch * 1.25 - Math.abs(dx + cw * 0.3) * 0.6,
262          ch * 0.9 - Math.abs(dx - cw * 0.35) * 0.55,
263        )
264        if (cy0 - y > bump || Math.abs(dx) > cw - (y === cy0 + 1 ? 2 : 0)) continue
265        px[y * w + x] = y >= cy0 ? pal.shade : mix(pal.cloud, 0xffffff, flash)
266      }
267    }
268  }
269
270  // Rain, slanting with the wind.
271  const rain = rainfall(s)
272  if (rain > 0) {
273    const drops = Math.round(w * groundY * 0.05 * rain)
274    const color = mix(0x55657f, mix(0x8fa8c8, 0xc8d6ea, s), Math.max(light, flash))
275    for (let i = 0; i < drops; i++) {
276      const v = 1.6 + hash(i * 4 + 1) * 1.2 + s
277      const y = Math.floor((hash(i * 4 + 2) * groundY + frame * v) % groundY)
278      const x = Math.floor(((hash(i * 4 + 3) * w + y * wind * 0.5) % w + w) % w)
279      // A streak, longer and more slanted the heavier it falls.
280      const length = rain > 0.6 ? 3 : 2
281      for (let k = 0; k < length && y - k >= 0; k++) {
282        const i = (y - k) * w + Math.max(0, x - Math.round(k * wind * 0.5))
283        px[i] = k === 0 ? color : mix(color, px[i]!, 0.45)
284      }
285    }
286  }
287
288  // The bolt itself.
289  if (isStrike) {
290    let x = Math.floor(hash(win * 7 + 2) * w * 0.8 + w * 0.1)
291    for (let y = Math.floor(ceiling); y < groundY; y++) {
292      px[y * w + Math.max(0, Math.min(w - 1, x))] = 0xfff7c2
293      x += Math.floor(hash(win * 131 + y) * 3) - 1
294    }
295  }
296
297  // Ground, and a little house whose window lights up when it gets gloomy or dark.
298  for (let y = groundY; y < h; y++) {
299    for (let x = 0; x < w; x++) px[y * w + x] = y === groundY ? mix(pal.ground, 0xffffff, 0.04 + 0.08 * light) : pal.ground
300  }
301  if (w >= 16 && groundY >= 8) {
302    const hx = Math.round(w * 0.18)
303    const wall = mix(mix(0x8a5a44, 0x2a1d18, s), 0x140e0c, (1 - light) * 0.6)
304    const roof = mix(mix(0xb2483b, 0x3a1c1a, s), 0x1a0c0b, (1 - light) * 0.6)
305    for (let y = groundY - 4; y < groundY; y++) for (let x = hx; x < hx + 6; x++) px[y * w + x] = wall
306    for (let r = 0; r < 3; r++) for (let x = hx - 1 + r; x < hx + 7 - r; x++) px[(groundY - 5 - r) * w + x] = roof
307    const lit = (s > 0.3 || light < 0.7) && !(s >= STORM && frame % 40 === 0)
308    px[(groundY - 3) * w + hx + 2] = lit ? 0xffcf6b : mix(wall, 0x000000, 0.3)
309    px[(groundY - 3) * w + hx + 3] = lit ? 0xffcf6b : mix(wall, 0x000000, 0.3)
310  }
311
312  return px
313}
314
315/** Packs a `columns * rows*2` pixel frame into Raster cells, two pixels per '▀'. */
316export function encode(px: Uint32Array, columns: number, rows: number): string {
317  const words = new Uint32Array(columns * rows * 3)
318  for (let r = 0; r < rows; r++) {
319    for (let c = 0; c < columns; c++) {
320      const o = (r * columns + c) * 3
321      words[o] = UPPER_HALF
322      words[o + 1] = px[r * 2 * columns + c]!
323      words[o + 2] = px[(r * 2 + 1) * columns + c]!
324    }
325  }
326  return base64(new Uint8Array(words.buffer))
327}
328
329const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
330
331/** Standard padded base64, by hand: not every runtime has Uint8Array#toBase64. */
332export function base64(bytes: Uint8Array): string {
333  const out: string[] = []
334  let i = 0
335  for (; i + 2 < bytes.length; i += 3) {
336    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
337    out.push(B64[n >> 18]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!)
338  }
339  const rest = bytes.length - i
340  if (rest === 1) {
341    const n = bytes[i]! << 16
342    out.push(B64[n >> 18]! + B64[(n >> 12) & 63]! + '==')
343  } else if (rest === 2) {
344    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
345    out.push(B64[n >> 18]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '=')
346  }
347  return out.join('')
348}
349
350// ---------------------------------------------------------------- hooks
351
352// Module-local animation state; a reload starts it over, which is fine.
353let frame = 0
354let target = 0 // storminess the context asks for
355let shown = 0 // storminess on screen, easing toward the target
356let size: { columns: number; rows: number } | null = null
357let hour = 12 // local time of day the scene shows
358let clockHour = 12 // the real one
359let pinned: number | null = null // a time /weather at set, or null to follow the clock
360let isAutoOpened = false // opened unasked once this load, where it would be a sidebar
361
362async function syncClock($: EngineInterface) {
363  let offset: number | null = null
364  try {
365    const run = await $.process.run(['date', '+%z'], { timeoutMs: 5_000 })
366    if (run.exitCode === 0) offset = parseOffset(run.stdout)
367  } catch {}
368  clockHour = localHour(await $.clock.now(), offset ?? -new Date().getTimezoneOffset())
369  hour = pinned ?? clockHour
370  const next = dayPhase(hour)
371  await update($, phaseNow, () => next)
372}
373
374async function setPercent($: EngineInterface, pct: number) {
375  target = pct / 100
376  await update($, percent, () => Math.round(pct))
377}
378
379/** Reads the context afresh and shows its weather at once, with no easing. */
380async function resetWeather($: EngineInterface) {
381  const { context } = await $.session.usage()
382  await setPercent($, contextPercent(context))
383  shown = target
384}
385
386/** Opens the pane at the person's word; the reply says if it waits undrawn. */
387async function openPane($: EngineInterface, done: string): Promise<{ text: string }> {
388  const opened = await $.ui.open({ id: PANE, title: 'Weather', columns: 32 })
389  return { text: opened.isPlaced ? done : `${done} The window is not shown yet: ${opened.reason}` }
390}
391
392export const register: Register = on => {
393  on('session.start', async ($, e, next) => {
394    const result = await next(e)
395    await $.command.register({
396      name: 'weather',
397      description: 'Toggle the context weather window; "at 22:30" shows that time, "at now" follows the clock',
398      argumentHint: '[at HH:MM|now]',
399    })
400    await resetWeather($)
401    pinned = await read($, override)
402    await syncClock($)
403    $.clock.every(60_000, () => syncClock($))
404    $.clock.every(FRAME_MS, () => {
405      frame += 1
406      shown += (target - shown) * 0.04
407      if (!size) return
408      const { columns, rows } = size
409      $.ui
410        .blit({ requestId: PANE, key: 'sky', cells: encode(paint(columns, rows * 2, shown, frame, hour), columns, rows) })
411        .then(r => {
412          if ('deny' in r && r.deny) size = null
413        })
414        .catch(() => {
415          size = null
416        })
417    })
418    return result
419  })
420
421  on('session.measure', async ($, e, next) => {
422    await setPercent($, contextPercent(e.context))
423    return next(e)
424  })
425
426  // A /clear or a resume swaps the conversation without a session.start, and
427  // the next measure waits for a turn: show the new conversation's sky and hour now.
428  on('classic.SessionStart', async ($, e, next) => {
429    const result = await next(e)
430    if (e.source === 'clear' || e.source === 'resume') {
431      await resetWeather($)
432      // The new conversation has its own /weather at, and its own phase atom.
433      pinned = await read($, override)
434      await syncClock($)
435    }
436    return result
437  })
438
439  on('command.run', { command: 'weather' }, async ($, e) => {
440    const at = /^at\s+(.+)$/.exec(e.args.trim())
441    if (at) {
442      const wanted = at[1]!.trim() === 'now' ? null : parseClock(at[1]!)
443      if (wanted === null && at[1]!.trim() !== 'now') return { text: `Not a time: ${at[1]}. Try /weather at 22:30.` }
444      pinned = wanted
445      await update($, override, () => wanted)
446      await syncClock($)
447      return openPane($, wanted === null ? 'The weather follows the clock again.' : `The weather shows ${at[1]!.trim()}.`)
448    }
449    const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
450    if (isOpen) {
451      await $.ui.close({ id: PANE })
452      return { text: 'Weather window closed.' }
453    }
454    return openPane($, 'Weather window opened.')
455  })
456
457  // Open unasked only where the pane docks as a sidebar: the terminal's
458  // fullscreen layout, at least WIDE columns across. Only a drawing knows
459  // that, so the prompt's own sites look, pass through, and open the pane once.
460  for (const component of ['AbovePrompt', 'PromptHint'] as const) {
461    on('ui.render', { component }, ($, e, next) => {
462      if (!isAutoOpened && e.surface === 'terminal' && e.viewport?.isFullscreen === true && e.viewport.columns >= WIDE) {
463        isAutoOpened = true
464        void $.ui.open({ id: PANE, title: 'Weather', columns: 32 })
465      }
466      return next(e)
467    })
468  }
469
470  on('ui.close', { id: PANE }, ($, e, next) => {
471    size = null
472    return next(e)
473  })
474
475  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
476    const pct = (await read($, percent)) ?? 0
477    const phase = (await read($, phaseNow)) ?? 'day'
478    const columns = Math.max(1, Math.min(512, e.props.bodyColumns))
479    const room = e.props.scroll.bodyRows - 1
480    const rows = Math.max(2, Math.min(256, e.props.placement === 'dock' ? room : Math.min(room, 8)))
481    const caption = `${phase === 'day' ? '' : `${phase} · `}${forecast(pct)} · ${pct}% context`
482
483    if (e.surface !== 'terminal') {
484      // Raster is terminal-only for now; elsewhere the forecast alone. The
485      // terminal's mounted Raster, if any, keeps animating.
486      const { Text } = $.ui.resolve(e)
487      return <Text>{caption}</Text>
488    }
489    const { Box, Raster, Text } = $.ui.resolve(e)
490    size = { columns, rows }
491
492    return (
493      <Box flexDirection="column">
494        <Raster key="sky" columns={columns} rows={rows} cells={encode(paint(columns, rows * 2, shown, frame, hour), columns, rows)} />
495        <Text dimColor wrap="truncate">
496          {caption}
497        </Text>
498      </Box>
499    )
500  })
501}
502
types/index.d.ts 12 lines
1export type DayPhase = 'dawn' | 'day' | 'dusk' | 'night'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'context-weather': {
6      percent: number | null
7      phase: DayPhase | null
8      override: number | null
9    }
10  }
11}
12