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

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.

| Context | Weather |
|---|---|
| under 10% | clear skies |
| 10–25% | fair |
| 25–45% | partly cloudy |
| 45–60% | overcast |
| 60–75% | light rain |
| 75–88% | heavy rain |
| 88% and up | thunderstorm, 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.

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.
/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./weather./weather opens it above the prompt, up to 8 rows tall.▀ with separate top and bottom colors)./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.
hooks/register.tsx 502 lines1import { 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}
502types/index.d.ts 12 lines1export 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