A living risograph sky behind the terminal that follows the real sun, moon and weather, with a pixel Clawd above the prompt

A Claude Code mod that puts a living sky behind your terminal while Claude works. It follows the real sun, moon and weather where you are, and a pixel Clawd lives in a band above the prompt through his day — early birdin', coffee sesh, workin', vibin', night owl, sleep sesh — cheering when Claude finishes and facepalming when a tool fails.

Everything is printed in a risograph style: a handful of inks, halftone screens and a key plate slightly off register.

/solar event ufo abduction and /solar event surf preview them).Events are a pure function of the time and the sky, so every session shows the same sky without keeping any state.
At the prompt of a Claude Code session:
/plugin install solar-clawd --marketplace Fiddy134/solar-clawd
Answer y to add the marketplace and pick a scope (user, so it runs in every session). From a shell, the same is claude plugin install solar-clawd --marketplace Fiddy134/solar-clawd.
Then, in a Claude Code session in Ghostty, run /solar ghostty once. It adds one optional include line to Ghostty's config; the included file names the shader while a Claude session runs and is empty otherwise. It also adds a right-hand margin of 240 points, where the lighthouse stands clear of the text; that is added once and kept when the sky is hidden. A config that already includes the mod gets the margin once, at the next session start in Ghostty, and a toast says so. From then on the margin is yours: change or remove its window-padding-x line and it stays as you leave it. The sky shows only in the tabs where Claude runs.
Clone the repository and load it as a plugin folder, which the session reloads as you save:
git clone https://github.com/Fiddy134/solar-clawd ~/solar-clawd
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/solar-clawd"
}
}
That goes in ~/.claude/settings.json. claude plugin test . runs the tests; tools/preview/run.sh shader and tools/preview/run.sh band render the sky and the band to PNGs without Ghostty (their flags are listed at the top of each file in tools/preview/), and tools/preview/gif.sh makes the GIF above.
Run by a few people so far; reports and fixes are welcome. /solar ghostty adds the include to $XDG_CONFIG_HOME/ghostty/config.ghostty (or to config if that is the file you have), defaulting to ~/.config/ghostty.
The mod rewrites the shader and asks Ghostty to reload its config, which on Linux means a SIGUSR2:
systemctl --user reload app-com.mitchellh.ghostty.service, the way Ghostty's docs recommend.SIGUSR2 with pkill -x ghostty, but only if ghostty --version says 1.3 or later, since an older Ghostty would quit on that signal.What differs from macOS:
/sys/class/power_supply (the sky stands still on battery).| Command | |
|---|---|
/solar | Hide or show the sky |
/solar demo | A whole day in 48 seconds |
/solar HH:MM | Pin a time today |
/solar YYYY-MM-DD HH:MM | Pin a day, e.g. /solar 2026-12-24 15:01 |
/solar now | Follow the real sun again |
/solar place <city> | Set the place (otherwise guessed from your IP, starting in Stockholm); auto goes back to guessing |
/solar events | List the events |
/solar event <name> | Play an event for 90 seconds |
/solar aurora | Toggle a forced aurora |
/solar storm | Toggle a storm |
/solar toss | Clawd throws a ball of paper in the wastebasket |
/solar burn | The wastebasket burns for a while, as on /compact (the conversation is not touched) |
/solar ghostty | Add the sky to Ghostty's config |
Open-Meteo (weather and geocoding), CelesTrak (ISS orbit), NOAA SWPC (Kp index), ipinfo.io / ipwho.is (place guess), the Yale Bright Star Catalog.
GPL-3.0. A fan project, not affiliated with Anthropic.
hooks/register.tsx 802 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { localMidnight, STOCKHOLM } from './astro'
5import type { Waiting } from './beacon'
6import { drawFrame, nextPassing, REACTION_SECONDS, ROWS, skyGained, THROW_SECONDS, toolKind, workKind } from './clawd'
7import type { Mode, Reaction } from './clawd'
8import { contextAt, EVENT_WORDS, eventsAt, OLD_EVENT_WORDS, PREVIEW_MS, showerAt, shootingRate } from './events'
9import type { EventKind, Forced } from './events'
10import {
11 endGhosttySky,
12 ghosttyConfigs,
13 ghosttyKnowsReloadSignal,
14 hasOurInclude,
15 hasOurStage,
16 includeInGhostty,
17 isIncluded,
18 reloadGhostty,
19 runtimeFolder,
20 shareWaiting,
21 STAGE_PT,
22 tidy,
23 updateGhosttySky,
24} from './ghostty'
25import type { Ghostty, SkyExtras, SkyState } from './ghostty'
26import { idleMs, isGhosttyInFront, isLinuxHost, isOnBattery } from './host'
27import { issVisible } from './iss'
28import type { Elements } from './iss'
29import { ITERM_FRONTMOST, NOW_PLAYING, SCREEN_SCALE, SET_BACKGROUND } from './mac'
30import { CHOSEN_PLACE, findPlace, GUESSED_PLACE, ISS_MS, issElements, placeText, plain, refreshWeather, settlePlace } from './net'
31import type { Where } from './net'
32import { fromBase64 } from './paint'
33import { shaderParams, skyShader } from './shader'
34import type { ShaderParams } from './shader'
35import { skyOf, wallpaperPng } from './sky'
36import type { MoonTexture, WallpaperCache, Weather } from './sky'
37
38const isHidden = atom({ plugin: 'solar-clawd', key: 'isHidden' } as const, false)
39
40const KEY = 'sky'
41// The moon's texture, baked by tools/bake-moon.sh.
42const MOON_SIZE = 256
43// Pixels per point of the window: Retina.
44const BACKING_SCALE = 2
45// Eight frames a second: Clawd's sprites step, they do not glide.
46const TICK_MS = 125
47// A whole day in this long, for /solar demo.
48const DEMO_MS = 48_000
49// Narrower than this and the scene goes away rather than squeezing.
50const MIN_COLUMNS = 34
51// How often iTerm2's wallpaper moves its clouds on while the person is at
52// it (the still sky behind them is painted once a minute), faster in the
53// time-lapse; and how often the still sky is brought up to date otherwise.
54const WALLPAPER_MS = 3_000
55const DEMO_WALLPAPER_MS = 1_500
56const IDLE_WALLPAPER_MS = 5 * 60_000
57// How often the person's idleness (and iTerm2 being in front) is asked.
58const ACTIVITY_MS = 3_000
59// Weather and aurora are asked for this often, music this often.
60const WEATHER_MS = 15 * 60_000
61const MUSIC_MS = 10_000
62// How long /solar bränn plays the wastebasket's fire.
63const BURN_SHOWN_MS = 8_000
64// How often the context window's fill is asked (it costs nothing), for the
65// pile of paper by Clawd.
66const PAPER_MS = 30_000
67
68// How often the sky is looked at: a new scene, the person away or back.
69const SKY_MS = 10_000
70// Away after this long without a key or the mouse: the sky stands still,
71// and with it Ghostty's frame loop, until the person is back.
72const AWAY_MS = 5 * 60_000
73// How often to ask whether the Mac runs on battery.
74const BATTERY_MS = 60_000
75
76// A turn of Claude's this long earns confetti when it is done.
77const MILESTONE_MS = 3 * 60_000
78// While the ISS passes, the sky follows it this often.
79const ISS_TICK_MS = 2_000
80
81// The stage strip's width in pixels: its points times the screen's scale
82// (asked once, on macOS; Linux has the points as they are). An answer that
83// is no positive number counts as 1.
84async function stagePxOf($: EngineInterface, isOnLinux: boolean): Promise<number> {
85 if (isOnLinux) return STAGE_PT
86 const answer = await $.process.run(['/usr/bin/osascript', '-l', 'JavaScript', ...SCREEN_SCALE], { timeoutMs: 5_000 }).catch(() => null)
87 const scale = Number(answer?.stdout.trim())
88
89 return STAGE_PT * (Number.isFinite(scale) && scale > 0 ? scale : 1)
90}
91
92// How full the context window is, 0 to 100, for Clawd's pile of paper;
93// null when it could not say.
94async function contextFill($: EngineInterface): Promise<number | null> {
95 const usage = await $.session.usage().catch(() => null)
96
97 return usage?.context.percent ?? null
98}
99
100// The notifications that ask the person something.
101const ASKING = ['permission_prompt', 'elicitation_dialog']
102
103export const register: Register = on => {
104 // The band as last drawn, so the timer can repaint it in place, and the
105 // terminal's size, which the wallpaper takes its shape from.
106 let site: { requestId: string; columns: number } | null = null
107 let viewport = { columns: 160, rows: 45 }
108 // The iTerm2 window in points, once iTerm2 has said.
109 let windowSize: { width: number; height: number } | null = null
110 let moonTexture: MoonTexture | null = null
111 let demoFrom: number | null = null
112 let pinnedAt: number | null = null
113 let isAuroraForced = false
114 let isWindForced = false
115 let track: string | null = null
116 const where: Where = { place: STOCKHOLM, isChosen: false, weather: null, offsetMs: undefined, kp: null, lastRainAt: null }
117 // Events played on request (/solar event) or earned (a long turn done).
118 let forced: Forced[] = []
119 // Claude's state as Clawd shows it: a turn running, waiting on the person,
120 // and the last passing reaction.
121 const claude = {
122 isWorking: false,
123 passing: null as { kind: Reaction; at: number } | null,
124 // The main conversation's calls now running that read or write, by id.
125 open: new Map<string, 'read' | 'write'>(),
126 // How full the context window is, 0 to 100, as last asked.
127 paper: null as number | null,
128 // When the conversation began compacting, while it does.
129 compactingSince: null as number | null,
130 // A fire played on request (/solar bränn) until then, and a ball thrown
131 // on request (/solar kasta) lying in the basket until then.
132 burnShownUntil: 0,
133 ballShownUntil: 0,
134 }
135 // What Claude waits on the person for: an answer (a permission or a
136 // question), or the next task once work is done. The lighthouse's.
137 let waiting: Waiting | null = null
138 // The ISS's elements, once fetched.
139 let issOrbit: Elements | null = null
140 // Whether the Mac runs on battery, as last asked.
141 const power = { isOnBattery: false }
142 // Updates the Ghostty sky at once, once a Ghostty session has started.
143 let refreshSky: (() => void) | null = null
144 // The calls the engine put to the person (tool.check's ask), by id: a
145 // permission asked for is answered when its own call goes on and ends.
146 const askingCalls = new Set<string>()
147 // Sets what Claude waits for; whether that changed (a wait keeps the
148 // moment it began).
149 const waitFor = (next: Waiting | null): boolean => {
150 if ((next?.mode ?? null) === (waiting?.mode ?? null)) return false
151 waiting = next
152
153 return true
154 }
155 // Set when this session runs in iTerm2: its session id and where the
156 // wallpaper files go.
157 let iterm: { sessionId: string; folder: string } | null = null
158 const wallpaper = { at: 0, count: 0, isBusy: false, isShown: false, key: '', last: '' }
159 // Whether the person is at the computer, and iTerm2 in front, as last asked.
160 const activity = { isAway: false, isFrontmost: true }
161 // Set when this session runs in Ghostty: where the sky's files go, and
162 // the background the terminal draws.
163 let ghostty: Ghostty | null = null
164 const wallpaperCache: { current?: WallpaperCache } = {}
165
166 const momentAt = (now: number): { ms: number; mode: Mode } => {
167 if (demoFrom !== null) {
168 const elapsed = now - demoFrom
169 if (elapsed < DEMO_MS) {
170 return {
171 ms: localMidnight(demoFrom, where.offsetMs) + (elapsed / DEMO_MS) * 86_400_000,
172 mode: 'demo',
173 }
174 }
175 demoFrom = null
176 }
177
178 return pinnedAt === null ? { ms: now, mode: 'live' } : { ms: pinnedAt, mode: 'pinned' }
179 }
180
181 // How full the context window is now: another tenth filled, Clawd
182 // throws a ball of paper in the basket.
183 // Whether he threw.
184 const takePaper = (fill: number | null, now: number): boolean => {
185 const before = claude.paper
186 claude.paper = fill ?? before
187 if (before !== null && fill !== null && claude.compactingSince === null && Math.floor(fill / 10) > Math.floor(before / 10)) {
188 claude.passing = { kind: 'toss', at: now }
189
190 return true
191 }
192
193 return false
194 }
195 // The sky's wastebasket: a ball for each tenth, and the fire.
196 const basketAt = (now: number): ShaderParams['basket'] => {
197 const isEmbers = claude.passing?.kind === 'embers' && now - claude.passing.at < REACTION_SECONDS.embers * 1000
198 const fire = claude.compactingSince !== null || now < claude.burnShownUntil ? 'burn' : isEmbers ? 'embers' : null
199 const balls = (claude.paper === null ? 0 : Math.floor(claude.paper / 10)) + (now < claude.ballShownUntil ? 1 : 0)
200
201 return { balls: isEmbers ? 0 : balls, fire }
202 }
203
204 const reactionAt = (now: number) => {
205 if (claude.compactingSince !== null) return { kind: 'burn' as const, age: (now - claude.compactingSince) / 1000 }
206 if (now < claude.burnShownUntil) return { kind: 'burn' as const, age: (now - (claude.burnShownUntil - BURN_SHOWN_MS)) / 1000 }
207 const { passing } = claude
208 // Looking up at the sky gives way to a question and to work.
209 const isBusy = waiting?.mode === 'asking' || claude.isWorking
210 if (passing && !(passing.kind === 'lookUp' && isBusy)) {
211 const age = (now - passing.at) / 1000
212 if (age < REACTION_SECONDS[passing.kind]) return { kind: passing.kind, age }
213 }
214 if (waiting?.mode === 'asking') return { kind: 'wave' as const, age: 0 }
215 if (claude.isWorking) return { kind: workKind(claude.open.values()), age: 0 }
216
217 return null
218 }
219
220 // The sky's events at a moment: those due then, and those forced, which
221 // run on the real clock whatever moment the sky shows.
222 const eventsNow = (now: number) => {
223 const { ms } = momentAt(now)
224 forced = forced.filter(event => now - event.at < PREVIEW_MS)
225 const context = contextAt(ms, where.place, where.weather, where.lastRainAt, where.offsetMs)
226 const events = eventsAt(ms, context, forced.map(event => ({ ...event, at: event.at - now + ms })))
227
228 return { events, context }
229 }
230
231 const viewAt = (now: number, columns: number) => {
232 const { ms, mode } = momentAt(now)
233 const { events, context } = eventsNow(now)
234 const shower = shootingRate(context) > 2 ? showerAt(context.time) : null
235
236 // A preview of a stormy day keeps the rest of the weather as it is.
237 const calm: Weather = { temperature: 8, code: 3, cloudCover: 60, wind: 0 }
238 const weather = isWindForced ? { ...(where.weather ?? calm), wind: 12 } : where.weather
239
240 return {
241 columns,
242 ms,
243 t: now / 1000,
244 mode,
245 ...where,
246 weather,
247 isAuroraForced,
248 moonTexture,
249 // Clear where a wallpaper or Ghostty's sky shows through the band.
250 isClear: iterm !== null || (ghostty !== null && ghostty.isIncluded && ghostty.written.include !== ''),
251 reaction: reactionAt(now),
252 paper: claude.paper,
253 isBasketInSky: ghostty !== null && ghostty.isIncluded && ghostty.written.include !== '',
254 track,
255 event: events.length > 0 ? EVENT_WORDS[events[0]!.kind] : issNow(now, mode) ? 'ISS' : shower,
256 }
257 }
258
259 // The ISS on a visible pass now; never in a demo or at a pinned time.
260 const issNow = (now: number, mode: Mode) => (mode === 'live' ? issVisible(issOrbit, now, where.place) : null)
261
262 // Whether anything in the sky moves enough to be worth Ghostty's frame
263 // loop: an event, rain or snow, a fire, a time-lapse, a storm played.
264 const livelyAt = (now: number): boolean => {
265 const sky = where.weather ? skyOf(where.weather.code) : 'clear'
266 const isWet = sky === 'drizzle' || sky === 'rain' || sky === 'thunder' || sky === 'snow'
267
268 return momentAt(now).mode === 'demo' || eventsNow(now).events.length > 0 || isWet || isWindForced || basketAt(now)?.fire != null
269 }
270 const skyState = (): SkyState => ({ isAway: activity.isAway, isOnBattery: power.isOnBattery, waiting, basketAt, livelyAt })
271
272 // The Ghostty shader for a moment: the scene then, or in a demo the day.
273 // What was in the Ghostty sky last time it was drawn; null before the first.
274 let lastSky: string[] | null = null
275
276 const shaderAt = (now: number, extras: SkyExtras) => {
277 const { ms, mode } = momentAt(now)
278 const demo = mode === 'demo' ? { seconds: DEMO_MS / 1000, offsetMs: where.offsetMs } : null
279
280 const { events, context } = eventsNow(now)
281
282 const iss = issNow(now, mode)
283 // Something new up there: Clawd looks up at it.
284 const sky = [...events.map(event => event.kind as string), ...(iss ? ['iss'] : []), ...(extras.beacon ? ['beacon'] : [])]
285 if (lastSky !== null) claude.passing = nextPassing(claude.passing, now, skyGained(lastSky, sky))
286 lastSky = sky
287
288 return skyShader(shaderParams({ ...viewAt(now, viewport.columns), ms }, { demo, events, shootingRate: shootingRate(context), iss, ...extras, basket: basketAt(now) }))
289 }
290
291 on('session.start', async ($, e, next) => {
292 await $.command.register({
293 name: 'solar',
294 description: 'Show or hide the solar sky; demo plays a day, HH:MM pins a time, plats sets the place',
295 argumentHint: '[demo | now | HH:MM | YYYY-MM-DD HH:MM | place <city> | aurora | storm | events | event <name> | toss | burn | ghostty]',
296 immediate: true,
297 })
298
299 const home = await $.env.get('HOME')
300 const sessionId = (await $.env.get('ITERM_SESSION_ID'))?.split(':')[1]
301 if (sessionId && home) {
302 iterm = { sessionId, folder: `${home}/Library/Caches/solar-clawd` }
303 await $.process.run(['/bin/mkdir', '-p', iterm.folder])
304 void tidy($, iterm.folder, [sessionId]).catch(() => undefined)
305 }
306
307 try {
308 const { base64 } = await $.fs.read(`${$.plugin.root}/assets/moon.rgba`, { as: 'bytes' })
309 const rgba = fromBase64(base64)
310 if (rgba.length === MOON_SIZE * MOON_SIZE * 4) moonTexture = { size: MOON_SIZE, rgba }
311 } catch {
312 // A plain disc, then.
313 }
314
315 const isOnLinux = await isLinuxHost($)
316 if ((await $.env.get('TERM_PROGRAM')) === 'ghostty' && home) {
317 const xdgConfig = isOnLinux ? (await $.env.get('XDG_CONFIG_HOME')) || null : null
318 const xdgState = isOnLinux ? (await $.env.get('XDG_STATE_HOME')) || null : null
319 const folder = runtimeFolder(home, isOnLinux, xdgState)
320 await $.process.run(['/bin/mkdir', '-p', folder]).catch(() => undefined)
321 const id = await $.session.id().catch(() => 'session')
322 ghostty = {
323 id,
324 folder,
325 include: `${folder}/solar-clawd.ghostty`,
326 shader: `${folder}/sky.glsl`,
327 heartbeat: `${folder}/alive-${id}`,
328 waiting: `${folder}/waiting-${id}`,
329 hidden: `${folder}/hidden`,
330 home,
331 configs: ghosttyConfigs(home, isOnLinux, xdgConfig),
332 isLinux: isOnLinux,
333 // Asked below, once the config is known to include the sky.
334 stagePx: STAGE_PT,
335 knowsReloadSignal: isOnLinux && (await ghosttyKnowsReloadSignal($)),
336 isIncluded: false,
337 written: { shader: '', include: '', mark: '', heartbeatAt: -Infinity, isReloadOwed: false, isEnded: false, running: null, isAgain: false },
338 }
339 void tidy($, folder, [`alive-${id}`]).catch(() => undefined)
340 // The sky shows only once Ghostty's config includes the mod's file.
341 ghostty.isIncluded = await isIncluded($, ghostty)
342 // An include the mod added before, to its own folder: moved here, and
343 // the old file emptied so Ghostty draws no second sky from it.
344 let isMarginNew = false
345 if (!ghostty.isIncluded && (await hasOurInclude($, ghostty))) {
346 const added = await includeInGhostty($, ghostty)
347 ghostty.isIncluded = added !== null
348 isMarginNew = added?.isMarginNew ?? false
349 const old = `${$.plugin.root}/runtime/solar-clawd.ghostty`
350 if (old !== ghostty.include && (await $.fs.exists(old).catch(() => false))) await $.fs.write(old, '').catch(() => undefined)
351 }
352 const isOurs = await hasOurInclude($, ghostty)
353 // An include from before there was a stage gets it, once: after that
354 // the margin is the person's, changed or taken away as they like.
355 if (isOurs && !(await hasOurStage($, ghostty))) isMarginNew = (await includeInGhostty($, ghostty))?.isMarginNew ?? false
356 if (isMarginNew) $.ui.toast(`Solar: gave the lighthouse a ${STAGE_PT}-point margin on the right of Ghostty's windows; new windows show it.`)
357 // The screen's scale costs a moment of osascript: asked only for a sky
358 // that shows.
359 if (isOurs) ghostty.stagePx = await stagePxOf($, isOnLinux)
360 if (!ghostty.isIncluded) $.ui.toast('Solar: run /solar ghostty to show the sky behind the terminal.')
361 }
362
363 if (ghostty !== null) {
364 const sky = ghostty
365 const update = () => updateGhosttySky($, sky, shaderAt, skyState)
366 // Whether the person is here, and the power, before the first sky.
367 const idle = await idleMs($, isOnLinux)
368 if (idle !== null) activity.isAway = idle >= AWAY_MS
369 power.isOnBattery = await isOnBattery($, isOnLinux)
370 $.clock.every(BATTERY_MS, () => {
371 void (async () => {
372 const isOn = await isOnBattery($, isOnLinux)
373 if (isOn === power.isOnBattery) return
374 power.isOnBattery = isOn
375 await update()
376 })().catch(() => undefined)
377 })
378 void update().catch(() => undefined)
379 refreshSky = () => void update().catch(() => undefined)
380 // While the ISS passes, its place every two seconds, and once more as
381 // it goes, so it does not hang there until the next tick.
382 let isPassing = false
383 $.clock.every(ISS_TICK_MS, () => {
384 void (async () => {
385 const now = await $.clock.now()
386 const wasPassing = isPassing
387 isPassing = issNow(now, momentAt(now).mode) !== null
388 if (isPassing || wasPassing) await update()
389 })().catch(() => undefined)
390 })
391 $.clock.every(SKY_MS, () => void update().catch(() => undefined))
392 }
393
394 // The network is not waited on: the sky starts in Stockholm.
395 void settlePlace($, where).catch(() => undefined)
396 const fetchIss = async () => {
397 issOrbit = (await issElements($)) ?? issOrbit
398 }
399 void fetchIss().catch(() => undefined)
400 // A ball thrown lands in the sky's basket as it gets there.
401 const readPaper = async () => {
402 if (takePaper(await contextFill($), await $.clock.now())) await $.clock.sleep(THROW_SECONDS * 1000).then(() => refreshSky?.()).catch(() => undefined)
403 }
404 void readPaper()
405 $.clock.every(PAPER_MS, () => void readPaper())
406 $.clock.every(ISS_MS / 24, () => void fetchIss().catch(() => undefined))
407 $.clock.every(WEATHER_MS, () => void refreshWeather($, where).catch(() => undefined))
408 // Away from the computer, or iTerm2 behind other windows: the wallpaper
409 // stands still and the music is not asked after.
410 $.clock.every(ACTIVITY_MS, () => {
411 void (async () => {
412 const wasIdle = activity.isAway || !activity.isFrontmost
413 // An ioreg that failed says nothing: the last answer stands.
414 const idle = await idleMs($, isOnLinux)
415 if (idle !== null) activity.isAway = idle >= AWAY_MS
416 if (iterm !== null && !activity.isAway && (wasIdle || !activity.isFrontmost)) {
417 const { stdout } = await $.process.run(['/usr/bin/osascript', ...ITERM_FRONTMOST], { timeoutMs: 5_000 })
418 activity.isFrontmost = stdout.trim() === 'true'
419 }
420 // The white lighthouse is seen once Ghostty is in front with the
421 // person at it; a question still waits for its answer.
422 if (ghostty !== null && waiting?.mode === 'done' && !activity.isAway && (await isGhosttyInFront($, isOnLinux))) {
423 if (waiting?.mode === 'done' && waitFor(null)) await shareWaiting($, ghostty, waiting, refreshSky)
424 }
425 })().catch(() => undefined)
426 })
427 // Music is asked of macOS's own apps.
428 $.clock.every(MUSIC_MS, () => {
429 if (activity.isAway || isOnLinux) return
430 void $.process
431 .run(['/usr/bin/osascript', ...NOW_PLAYING], { timeoutMs: 5_000 })
432 .then(({ stdout }) => {
433 track = stdout.trim() ? plain(stdout.trim()) : null
434 })
435 .catch(() => undefined)
436 })
437
438 // Writes the sky as a PNG and hands it to iTerm2, two files taken in
439 // turn so iTerm2 sees a new path and reloads; an empty path clears it.
440 const setWallpaper = async (now: number | null) => {
441 if (iterm === null) return
442 let path = ''
443 if (now !== null) {
444 const view = viewAt(now, viewport.columns)
445 // At the window's own pixels once iTerm2 has said its size, so the moon
446 // and stars are sharp; until then a guess from the terminal's cells,
447 // about 2.15 times as tall as wide. The time-lapse paints at half size.
448 const scale = momentAt(now).mode === 'demo' ? BACKING_SCALE / 2 : BACKING_SCALE
449 const guess = { width: viewport.columns * 7.2, height: viewport.rows * 7.2 * 2.15 }
450 const points = windowSize ?? guess
451 const width = Math.round(Math.min(3200, Math.max(240, points.width * scale)))
452 const height = Math.round((width * points.height) / points.width)
453 const png = await wallpaperPng(view, width, height, () => $.clock.sleep(1), wallpaperCache)
454 // The same picture again (a still sky): nothing to write or send.
455 if (png === wallpaper.last && wallpaper.isShown) return
456 wallpaper.last = png
457 wallpaper.count += 1
458 path = `${iterm.folder}/${iterm.sessionId}-${wallpaper.count % 2}.png`
459 await $.process.run(['/usr/bin/base64', '-D', '-o', path], { stdin: png })
460 } else {
461 wallpaper.last = ''
462 }
463 const { stdout } = await $.process.run([
464 '/usr/bin/osascript',
465 ...SET_BACKGROUND,
466 iterm.sessionId,
467 path,
468 ])
469 const [answer, w, h, frontmost] = stdout.trim().split(' ')
470 wallpaper.isShown = now !== null && answer === 'ok'
471 if (frontmost) activity.isFrontmost = frontmost === 'true'
472 if (Number(w) > 0 && Number(h) > 0) windowSize = { width: Number(w), height: Number(h) }
473 }
474
475 $.clock.every(TICK_MS, () => {
476 void (async () => {
477 const now = await $.clock.now()
478 const hidden = await read($, isHidden)
479
480 if (iterm !== null && !wallpaper.isBusy) {
481 const { mode } = momentAt(now)
482 // A new place, weather, size or preview repaints at once.
483 const weather = where.weather
484 const key = `${mode}|${where.place.name}|${weather?.code}|${weather?.cloudCover}|${where.kp}|${isAuroraForced}|${viewport.columns}x${viewport.rows}|${windowSize?.width}x${windowSize?.height}`
485 const isActive = !activity.isAway && activity.isFrontmost
486 const every = mode === 'demo' ? DEMO_WALLPAPER_MS : isActive ? WALLPAPER_MS : IDLE_WALLPAPER_MS
487 const isDue = now - wallpaper.at >= every || key !== wallpaper.key
488 if (hidden ? wallpaper.isShown : isDue) {
489 wallpaper.isBusy = true
490 wallpaper.at = now
491 wallpaper.key = key
492 await setWallpaper(hidden ? null : now).finally(() => {
493 wallpaper.isBusy = false
494 })
495 }
496 }
497
498 const drawn = site
499 if (drawn === null) return
500 const result = await $.ui.blit({
501 requestId: drawn.requestId,
502 key: KEY,
503 cells: drawFrame(viewAt(now, drawn.columns)),
504 columns: drawn.columns,
505 rows: ROWS,
506 })
507 // Hidden, collapsed or resized: wait for the next draw to say where.
508 if (result.deny && site === drawn) site = null
509 })().catch(() => {
510 // Gone between ticks: the next draw says where it is.
511 site = null
512 })
513 })
514
515 return next(e)
516 })
517
518 // Leave iTerm2 as it was found; in Ghostty the sky goes with the last
519 // session that shows it.
520 on('session.end', async ($, e, next) => {
521 if (iterm !== null && wallpaper.isShown) {
522 await $.process.run(['/usr/bin/osascript', ...SET_BACKGROUND, iterm.sessionId, ''])
523 }
524 if (ghostty !== null) await endGhosttySky($, ghostty, e.reason)
525
526 return next(e)
527 })
528
529 // Clawd follows the main conversation: busy while a turn runs, cheering
530 // when it ends, a hand to his face when a tool fails, waving when Claude
531 // waits on the person.
532 on('turn.start', async ($, e, next) => {
533 if (e.agentId === undefined) {
534 claude.isWorking = true
535 claude.open.clear()
536 if (waitFor(null)) await shareWaiting($, ghostty, waiting, refreshSky)
537 }
538
539 return next(e)
540 })
541
542 on('turn.complete', async ($, e, next) => {
543 if (e.agentId === undefined) {
544 claude.isWorking = false
545 claude.open.clear()
546 const now = await $.clock.now()
547 if (takePaper(await contextFill($), now)) void $.clock.sleep(THROW_SECONDS * 1000).then(() => refreshSky?.()).catch(() => undefined)
548 if (e.reason === 'answer') {
549 claude.passing = { kind: 'cheer', at: now }
550 if (waitFor({ mode: 'done', since: now })) await shareWaiting($, ghostty, waiting, refreshSky)
551 // A long piece of work done: confetti over the sky.
552 if (e.durationMs > MILESTONE_MS) forced.push({ kind: 'milestone', at: now })
553 }
554 }
555
556 return next(e)
557 })
558
559 // Which calls are put to the person: the call that asked is the one whose
560 // end answers the question.
561 on('tool.check', async ($, e, next) => {
562 const verdict = await next(e)
563 if (verdict.decision === 'ask' && e.tool_use_id) askingCalls.add(e.tool_use_id)
564
565 return verdict
566 })
567
568 on('tool.call', async ($, e, next) => {
569 // A question put to the person waits inside its call; any other call in
570 // the conversation means Claude is at work again (unless a permission it
571 // asked for is still open).
572 const isQuestion = e.tool === 'AskUserQuestion'
573 const startedAt = await $.clock.now()
574 const asked = isQuestion ? { mode: 'asking' as const, since: startedAt } : null
575 const isOpen = waiting?.mode === 'asking' && askingCalls.size > 0
576 if ((isQuestion || (e.agentId === undefined && !isOpen)) && waitFor(asked)) await shareWaiting($, ghostty, waiting, refreshSky)
577 const kind = e.agentId === undefined ? toolKind(e.tool) : null
578 if (kind && e.tool_use_id) claude.open.set(e.tool_use_id, kind)
579 const result = await next(e).finally(() => claude.open.delete(e.tool_use_id))
580 // Answered: the question's own call, or the last call that asked for a
581 // permission, has ended. A notice no call asked for (an MCP server's
582 // question) ends with the main call it came up in.
583 const wasAsking = askingCalls.delete(e.tool_use_id)
584 const isAnswered =
585 isQuestion ||
586 (wasAsking && askingCalls.size === 0) ||
587 (!wasAsking && askingCalls.size === 0 && e.agentId === undefined && (waiting?.since ?? -Infinity) >= startedAt)
588 if (waiting?.mode === 'asking' && isAnswered && waitFor(null)) await shareWaiting($, ghostty, waiting, refreshSky)
589 if (result.isError && e.agentId === undefined) {
590 claude.passing = { kind: 'facepalm', at: await $.clock.now() }
591 }
592
593 return result
594 })
595
596 // The conversation compacting (not ahead of time): Clawd throws a match
597 // in the basket and the context burns while it runs; smoke after, unless
598 // it was skipped.
599 on('session.compact', async ($, e, next) => {
600 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
601 claude.compactingSince = await $.clock.now()
602 // The match lands, and the sky's basket catches fire.
603 void $.clock.sleep(THROW_SECONDS * 1000).then(() => refreshSky?.()).catch(() => undefined)
604 try {
605 const result = await next(e)
606 if (!('skip' in result)) {
607 const now = await $.clock.now()
608 claude.passing = { kind: 'embers', at: now }
609 claude.compactingSince = null
610 claude.paper = null
611 takePaper(await contextFill($), now)
612 // Embers now, and an empty bin once they are out.
613 void $.clock.sleep(REACTION_SECONDS.embers * 1000 + 200).then(() => refreshSky?.()).catch(() => undefined)
614 }
615
616 return result
617 } finally {
618 claude.compactingSince = null
619 refreshSky?.()
620 }
621 })
622
623 // A permission or a question for the person; Claude Code's reminder that
624 // it has been idle (`idle_prompt`) and other notices ask nothing.
625 on('classic.Notification', async ($, e, next) => {
626 if (ASKING.includes(e.notification_type) && waiting?.mode !== 'asking') {
627 if (waitFor({ mode: 'asking', since: await $.clock.now() })) await shareWaiting($, ghostty, waiting, refreshSky)
628 }
629
630 return next(e)
631 })
632
633 on('prompt.submit', async ($, e, next) => {
634 if (waitFor(null)) await shareWaiting($, ghostty, waiting, refreshSky)
635
636 return next(e)
637 })
638
639 on('command.run', { command: 'solar' }, async ($, e) => {
640 // The Swedish words it once took still work.
641 const said = e.args.trim()
642 const [first = '', ...rest] = said.split(/\s+/)
643 const english = ({ plats: 'place', norrsken: 'aurora', blåst: 'storm', kasta: 'toss', bränn: 'burn' } as Record<string, string>)[first.toLowerCase()]
644 const arg = english ? [english, ...rest].join(' ') : said
645 const word = arg.toLowerCase()
646 const now = await $.clock.now()
647
648 if (word === '') {
649 const wasHidden = await read($, isHidden)
650 await update($, isHidden, () => !wasHidden)
651 // Ghostty's sky is one for every session: hidden in all of them.
652 if (ghostty !== null) {
653 if (wasHidden) await $.process.run(['/bin/rm', '-f', ghostty.hidden]).catch(() => undefined)
654 else await $.fs.write(ghostty.hidden, '')
655 }
656
657 return { text: wasHidden ? 'Solar: the sky is back.' : 'Solar: the sky is hidden.' }
658 }
659
660 if (word === 'demo') {
661 demoFrom = now
662 await update($, isHidden, () => false)
663
664 return { text: 'Solar: a whole day in 48 seconds.' }
665 }
666
667 if (word === 'now') {
668 demoFrom = null
669 pinnedAt = null
670
671 return { text: 'Solar: following the sun again.' }
672 }
673
674 if (word === 'ghostty') {
675 if (ghostty === null) return { text: 'Solar: this session is not running in Ghostty.' }
676 if (await isIncluded($, ghostty)) return { text: 'Solar: Ghostty already shows the sky.' }
677 const added = await includeInGhostty($, ghostty)
678 if (added === null) return { text: `Solar: could not read Ghostty's config, so it is left as it is.` }
679 const { line, config } = added
680 ghostty.isIncluded = true
681 ghostty.stagePx = await stagePxOf($, ghostty.isLinux)
682 await updateGhosttySky($, ghostty, shaderAt, skyState)
683 await reloadGhostty($, ghostty)
684
685 return { text: `Solar: added "${line}" to ${config}.` }
686 }
687
688 if (word === 'events') {
689 const words = Object.values(EVENT_WORDS).join(', ')
690
691 return { text: `Solar: ${words}. Play one with /solar event <name>.` }
692 }
693
694 if (word.startsWith('event')) {
695 const name = word.slice('event'.length).trim()
696 const kind = (Object.keys(EVENT_WORDS) as EventKind[]).find(k => EVENT_WORDS[k] === name || k.toLowerCase() === name) ?? OLD_EVENT_WORDS[name]
697 if (!kind) return { text: `Solar: no event called "${name}". /solar events lists them.` }
698 // The keeper lives in Ghostty's sky alone: the band has no lighthouse.
699 if ((kind === 'abduction' || kind === 'surf') && ghostty === null) {
700 return { text: `Solar: the ${EVENT_WORDS[kind]} shows only in Ghostty's sky, and this session is not running in Ghostty.` }
701 }
702 forced = [...forced.filter(event => event.kind !== kind), { kind, at: now }]
703 await update($, isHidden, () => false)
704
705 return { text: `Solar: ${EVENT_WORDS[kind]} for ${PREVIEW_MS / 1000} seconds.` }
706 }
707
708 // Clawd's wastebasket, played: a ball thrown in, or the context burnt.
709 if (word === 'toss') {
710 claude.passing = { kind: 'toss', at: now }
711 claude.ballShownUntil = now + PREVIEW_MS
712 void $.clock.sleep(THROW_SECONDS * 1000).then(() => refreshSky?.()).catch(() => undefined)
713 void $.clock.sleep(PREVIEW_MS + 100).then(() => refreshSky?.()).catch(() => undefined)
714
715 return { text: 'Solar: Clawd tosses a ball of paper into the wastebasket.' }
716 }
717
718 if (word === 'burn') {
719 claude.burnShownUntil = now + BURN_SHOWN_MS
720 void $.clock.sleep(THROW_SECONDS * 1000).then(() => refreshSky?.()).catch(() => undefined)
721 void $.clock
722 .sleep(BURN_SHOWN_MS)
723 .then(() => {
724 claude.passing = { kind: 'embers', at: now + BURN_SHOWN_MS }
725 refreshSky?.()
726 })
727 .catch(() => undefined)
728 void $.clock.sleep(BURN_SHOWN_MS + REACTION_SECONDS.embers * 1000 + 200).then(() => refreshSky?.()).catch(() => undefined)
729
730 return { text: `Solar: the wastebasket burns for ${BURN_SHOWN_MS / 1000} seconds, as on /compact (the conversation is not touched).` }
731 }
732
733 if (word === 'aurora') {
734 isAuroraForced = !isAuroraForced
735
736 return { text: isAuroraForced ? 'Solar: aurora on, to see how it looks.' : 'Solar: aurora only when there is one.' }
737 }
738
739 if (word === 'storm') {
740 isWindForced = !isWindForced
741
742 return { text: isWindForced ? 'Solar: a storm, to see Clawd chase his hat.' : 'Solar: the wind as it really is.' }
743 }
744
745 if (word === 'place' || word.startsWith('place ')) {
746 const name = arg.slice('place'.length).trim()
747 if (name === '') return { text: `Solar: ${placeText(where)}.` }
748
749 if (name.toLowerCase() === 'auto') {
750 await $.store.delete(CHOSEN_PLACE)
751 await $.store.delete(GUESSED_PLACE)
752 await settlePlace($, where)
753
754 return { text: `Solar: following where you are again, ${placeText(where)}.` }
755 }
756
757 const found = await findPlace($, name)
758 if (!found) return { text: `Solar: found no place called "${name}".` }
759 await $.store.set(CHOSEN_PLACE, found)
760 where.place = found
761 where.isChosen = true
762 await refreshWeather($, where)
763
764 return { text: `Solar: the sky is now over ${placeText(where)}.` }
765 }
766
767 // HH:MM pins a time today; YYYY-MM-DD, with a time or at noon, pins a day.
768 const at = /^(?:(\d{4})-(\d{2})-(\d{2})\s*)?(?:(\d{1,2})(?::(\d{2}))?)?$/.exec(word)
769 if (at && (at[1] || at[4])) {
770 const hour = Number(at[4] ?? 12)
771 const minute = Number(at[5] ?? 0)
772 if (hour < 24 && minute < 60) {
773 const day = at[1]
774 ? Date.UTC(Number(at[1]), Number(at[2]) - 1, Number(at[3])) - (where.offsetMs ?? 3_600_000)
775 : localMidnight(now, where.offsetMs)
776 demoFrom = null
777 pinnedAt = day + (hour * 60 + minute) * 60_000
778 await update($, isHidden, () => false)
779
780 return { text: `Solar: pinned at ${at[1] ? `${at[1]}-${at[2]}-${at[3]} ` : ''}${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}.` }
781 }
782 }
783
784 return { text: 'Solar: /solar, /solar demo, /solar now, /solar HH:MM, /solar YYYY-MM-DD HH:MM, /solar place <city>, /solar aurora, /solar storm, /solar events, /solar event <name>, /solar toss or /solar burn.' }
785 })
786
787 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
788 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
789 if (await read($, isHidden)) return next(e)
790
791 if (e.viewport) viewport = { columns: e.viewport.columns, rows: e.viewport.rows }
792 const columns = e.props.bodyColumns
793 if (columns < MIN_COLUMNS || e.props.maxRows < ROWS) return next(e)
794
795 const now = await $.clock.now()
796 site = { requestId: e.requestId, columns }
797 const { Raster } = $.ui.resolve(e)
798
799 return <Raster key={KEY} columns={columns} rows={ROWS} cells={drawFrame(viewAt(now, columns))} />
800 })
801}
802hooks/astro.ts 256 lines1// Time, sun and moon: the local clock and calendar of a place, where the sun
2// and the moon stand over it, and which days are holidays there.
3
4export type Place = { name: string; lat: number; lon: number }
5
6export const STOCKHOLM: Place = { name: 'Stockholm', lat: 59.3293, lon: 18.0686 }
7
8export const DAY_MS = 86_400_000
9const HOUR_MS = 3_600_000
10const RAD = Math.PI / 180
11// Days from the Unix epoch to J2000.0 (2000-01-01 12:00 UTC).
12const J2000_DAYS = 10957.5
13
14// --- Clock ------------------------------------------------------------------
15
16function lastSundayAtOneUtc(year: number, month: number): number {
17 const lastDay = new Date(Date.UTC(year, month + 1, 0))
18
19 return Date.UTC(year, month, lastDay.getUTCDate() - lastDay.getUTCDay(), 1)
20}
21
22// CET, or CEST from the last Sunday of March to the last Sunday of October:
23// the clock until the weather service has said the place's own offset.
24export function swedishOffsetMs(ms: number): number {
25 const year = new Date(ms).getUTCFullYear()
26 const isSummer =
27 ms >= lastSundayAtOneUtc(year, 2) && ms < lastSundayAtOneUtc(year, 9)
28
29 return (isSummer ? 2 : 1) * HOUR_MS
30}
31
32export type LocalTime = {
33 year: number
34 month: number // 1 to 12
35 day: number
36 weekday: number // 0 Sunday to 6 Saturday
37 hour: number
38 minute: number
39}
40
41export function localTime(ms: number, offsetMs = swedishOffsetMs(ms)): LocalTime {
42 const d = new Date(ms + offsetMs)
43
44 return {
45 year: d.getUTCFullYear(),
46 month: d.getUTCMonth() + 1,
47 day: d.getUTCDate(),
48 weekday: d.getUTCDay(),
49 hour: d.getUTCHours(),
50 minute: d.getUTCMinutes(),
51 }
52}
53
54export function swedishClock(ms: number): { hour: number; minute: number } {
55 const { hour, minute } = localTime(ms)
56
57 return { hour, minute }
58}
59
60export function localMidnight(ms: number, offsetMs = swedishOffsetMs(ms)): number {
61 const local = ms + offsetMs
62
63 return local - (local % DAY_MS) - offsetMs
64}
65
66// --- Sun and moon -------------------------------------------------------------
67
68export type Sun = { elevation: number; azimuth: number }
69
70// Azimuth from north, clockwise, given the hour angle and declination.
71function horizontal(hourAngle: number, dec: number, lat: number): Sun {
72 const phi = lat * RAD
73 const elevation =
74 Math.asin(
75 Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(hourAngle),
76 ) / RAD
77 const fromSouth = Math.atan2(
78 Math.sin(hourAngle),
79 Math.cos(hourAngle) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi),
80 )
81
82 return { elevation, azimuth: (fromSouth / RAD + 540) % 360 }
83}
84
85// NOAA's low-precision solar position: a fraction of a degree off, plenty
86// for a sky.
87export function sunAt(ms: number, lat = STOCKHOLM.lat, lon = STOCKHOLM.lon): Sun {
88 const [x, y, z] = sunDirection(ms)
89
90 return horizontal((gmstDegrees(ms) + lon) * RAD - Math.atan2(y, x), Math.asin(z), lat)
91}
92
93// The sun's direction from the Earth, a unit vector in equatorial
94// coordinates (x to the March equinox, z to the north pole).
95export function sunDirection(ms: number): [number, number, number] {
96 const d = ms / DAY_MS - J2000_DAYS
97 const g = (357.529 + 0.98560028 * d) * RAD
98 const q = 280.459 + 0.98564736 * d
99 const l = (q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * RAD
100 const tilt = (23.439 - 0.00000036 * d) * RAD
101
102 return [Math.cos(l), Math.cos(tilt) * Math.sin(l), Math.sin(tilt) * Math.sin(l)]
103}
104
105// Greenwich mean sidereal time, in degrees.
106export function gmstDegrees(ms: number): number {
107 const d = ms / DAY_MS - J2000_DAYS
108
109 return (((18.697374558 + 24.06570982441908 * d) * 15) % 360 + 360) % 360
110}
111
112// Where a star (right ascension and declination, in degrees) stands from a
113// place at a moment.
114export function horizontalAt(raDeg: number, decDeg: number, ms: number, lat: number, lon: number): Sun {
115 return horizontal((gmstDegrees(ms) + lon - raDeg) * RAD, decDeg * RAD, lat)
116}
117
118export type Moon = Sun & {
119 // The lit share of the disc, 0 new to 1 full.
120 illumination: number
121 isWaxing: boolean
122}
123
124const SYNODIC_DAYS = 29.530588853
125
126// The moon's place to about a degree (the series SunCalc uses) and its
127// phase from the mean synodic month since the new moon of 2000-01-06.
128export function moonAt(ms: number, lat = STOCKHOLM.lat, lon = STOCKHOLM.lon): Moon {
129 const d = ms / DAY_MS - J2000_DAYS
130 const meanLongitude = (218.316 + 13.176396 * d) * RAD
131 const anomaly = (134.963 + 13.064993 * d) * RAD
132 const distance = (93.272 + 13.22935 * d) * RAD
133 const l = meanLongitude + 6.289 * RAD * Math.sin(anomaly)
134 const b = 5.128 * RAD * Math.sin(distance)
135 const tilt = 23.4397 * RAD
136 const ra = Math.atan2(
137 Math.sin(l) * Math.cos(tilt) - Math.tan(b) * Math.sin(tilt),
138 Math.cos(l),
139 )
140 const dec = Math.asin(
141 Math.sin(b) * Math.cos(tilt) + Math.cos(b) * Math.sin(tilt) * Math.sin(l),
142 )
143 const sidereal = (280.16 + 360.9856235 * d) * RAD + lon * RAD
144
145 const age = (((ms / DAY_MS + 2440587.5 - 2451550.1) % SYNODIC_DAYS) + SYNODIC_DAYS) % SYNODIC_DAYS
146
147 return {
148 ...horizontal(sidereal - ra, dec, lat),
149 illumination: (1 - Math.cos((2 * Math.PI * age) / SYNODIC_DAYS)) / 2,
150 isWaxing: age < SYNODIC_DAYS / 2,
151 }
152}
153
154// --- Calendar -------------------------------------------------------------
155
156// Easter Sunday (the anonymous Gregorian algorithm).
157export function easter(year: number): { month: number; day: number } {
158 const a = year % 19
159 const b = Math.floor(year / 100)
160 const c = year % 100
161 const d = Math.floor(b / 4)
162 const e = b % 4
163 const f = Math.floor((b + 8) / 25)
164 const g = Math.floor((b - f + 1) / 3)
165 const h = (19 * a + b - d - g + 15) % 30
166 const i = Math.floor(c / 4)
167 const k = c % 4
168 const l = (32 + 2 * e + 2 * i - h - k) % 7
169 const m = Math.floor((a + 11 * h + 22 * l) / 451)
170
171 return {
172 month: Math.floor((h + l - 7 * m + 114) / 31),
173 day: ((h + l - 7 * m + 114) % 31) + 1,
174 }
175}
176
177export type Occasion =
178 | 'lucia'
179 | 'christmas'
180 | 'newYear'
181 | 'midsummer'
182 | 'halloween'
183 | 'cinnamonBun'
184 | 'semla'
185
186export const OCCASION_WORDS: Record<Occasion, string> = {
187 lucia: 'Lucia',
188 christmas: 'Merry Christmas',
189 newYear: 'Happy New Year',
190 midsummer: 'Happy Midsummer',
191 halloween: 'Halloween',
192 cinnamonBun: 'Cinnamon Bun Day',
193 semla: 'Semla Day',
194}
195
196function dayOfYear(year: number, month: number, day: number): number {
197 return Math.round((Date.UTC(year, month - 1, day) - Date.UTC(year, 0, 1)) / DAY_MS)
198}
199
200export function occasionAt(time: LocalTime): Occasion | null {
201 const { year, month, day, weekday, hour } = time
202 if ((month === 12 && day === 31 && hour >= 20) || (month === 1 && day === 1 && hour < 2)) {
203 return 'newYear'
204 }
205 if (month === 12 && day === 13) return 'lucia'
206 if ((month === 12 && day >= 10) || (month === 1 && day <= 6)) return 'christmas'
207 // Midsummer's Eve: the Friday from 19 to 25 June.
208 if (month === 6 && day >= 19 && day <= 25 && weekday === 5) return 'midsummer'
209 if (month === 10 && day === 31) return 'halloween'
210 if (month === 10 && day === 4) return 'cinnamonBun'
211 // Shrove Tuesday: 47 days before Easter Sunday.
212 const { month: em, day: ed } = easter(year)
213 if (dayOfYear(year, month, day) === dayOfYear(year, em, ed) - 47) return 'semla'
214
215 return null
216}
217
218// How far through its day (sunrise to sunset) or its night (sunset to the
219// next sunrise) the sky is, 0 to 1; null where the sun does not rise or set
220// within a day and a half (a polar summer or winter).
221export function dayArc(
222 ms: number,
223 lat = STOCKHOLM.lat,
224 lon = STOCKHOLM.lon,
225): { isDay: boolean; progress: number } | null {
226 const isUp = (at: number) => sunAt(at, lat, lon).elevation > -0.83
227 const isDay = isUp(ms)
228 const step = 10 * 60_000
229 // The moment the sun last (or next) crossed the horizon, to the minute.
230 const crossing = (direction: 1 | -1): number | null => {
231 let at = ms
232 for (let i = 0; i < 216; i++) {
233 const next = at + direction * step
234 if (isUp(next) !== isDay) {
235 let near = at
236 let far = next
237 while (Math.abs(far - near) > 60_000) {
238 const middle = (near + far) / 2
239 if (isUp(middle) === isDay) near = middle
240 else far = middle
241 }
242
243 return near
244 }
245 at = next
246 }
247
248 return null
249 }
250 const start = crossing(-1)
251 const end = crossing(1)
252 if (start === null || end === null) return null
253
254 return { isDay, progress: (ms - start) / Math.max(1, end - start) }
255}
256hooks/beacon.ts 42 lines1// The lighthouse: when it is lit, and how Ghostty animates the sky for it.
2// Every session that waits on the person writes what it waits for; the sky
3// any of them draws lights the lighthouse for the most pressing.
4
5// A question that has waited this long makes the beam hurry.
6export const BEACON_URGENT_MS = 120_000
7// The beam sweeps this long, even with Ghostty in the background; after
8// that the lighthouse stands lit, still, costing nothing.
9export const BEACON_SWEEP_MS = 300_000
10// And goes out this long after the wait began: the person has gone.
11export const BEACON_MS = 1_800_000
12
13export type Waiting = { mode: 'asking' | 'done'; since: number }
14
15// The lighthouse for these waits: a question before work done, from the
16// first of them; null when nothing waits, or has waited too long.
17export function beaconAt(waits: readonly Waiting[], now: number): (Waiting & { isUrgent: boolean }) | null {
18 const live = waits.filter(wait => now - wait.since < BEACON_MS)
19 const mode = live.some(wait => wait.mode === 'asking') ? 'asking' : live.length > 0 ? 'done' : null
20 if (mode === null) return null
21 const since = Math.min(...live.filter(wait => wait.mode === mode).map(wait => wait.since))
22
23 return { mode, since, isUrgent: mode === 'asking' && now - since >= BEACON_URGENT_MS }
24}
25
26// Ghostty's custom-shader-animation for the sky now: `always` while the
27// beam sweeps (so it moves with Ghostty in the background), `false` for a
28// still sky (the lighthouse lit but no longer sweeping, the person away,
29// or on battery),
30// `true` (animate while focused) otherwise.
31//
32// A sky with nothing lively in it (no event, rain, fire or time-lapse)
33// stands still too: the clouds drift a pixel a second, and Ghostty draws
34// it anyway whenever the terminal changes, so it moves while Claude works,
35// at no cost when nothing happens.
36export function animationOf(o: { beacon: Waiting | null; now: number; isAway: boolean; isOnBattery: boolean; isLively: boolean }): 'true' | 'false' | 'always' {
37 if (o.beacon && !o.isAway && o.now - o.beacon.since < BEACON_SWEEP_MS) return 'always'
38 if (o.beacon || o.isAway || o.isOnBattery || !o.isLively) return 'false'
39
40 return 'true'
41}
42hooks/clawd.ts 819 lines1// Clawd in the band's right-hand corner: what he does at this hour, what he
2// wears for the weather and the day, how he reacts to Claude's work, and the
3// line of words beside him. One frame is one Raster's cells, printed as a
4// risograph would: the inks of paint.ts, a deeper tone dithered in where a
5// halftone would shade, night as an indigo plate rather than darkness, and
6// the odd indigo pixel printed off register.
7
8import { localTime, moonAt, OCCASION_WORDS, occasionAt, sunAt } from './astro'
9import type { LocalTime, Occasion } from './astro'
10import { Canvas, INKS, luminance, mix, rand, smoothstep, toBase64 } from './paint'
11import { auroraStrength, paintBandSky, paintPrecipitation, SKY_WORDS, skyOf } from './sky'
12import type { SkyInput } from './sky'
13
14// Terminal rows the band takes; every sprite below is laid out for it.
15export const ROWS = 3
16
17export type Activity = 'earlyBird' | 'coffee' | 'work' | 'vibe' | 'nightOwl' | 'sleep'
18
19export type Mode = 'live' | 'demo' | 'pinned'
20
21// What Claude is up to, as Clawd shows it: `working` while it writes or
22// runs things, `reading` while it only reads; `toss` as the context window
23// fills another tenth, `burn` while the conversation is compacted and
24// `embers` just after.
25export type Reaction = 'working' | 'reading' | 'cheer' | 'facepalm' | 'wave' | 'lookUp' | 'toss' | 'burn' | 'embers'
26
27// What a tool does, as Clawd shows it: reads, writes (or runs), or neither
28// as far as he can tell.
29export function toolKind(tool: string): 'read' | 'write' | null {
30 if (['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'NotebookRead', 'ToolSearch'].includes(tool)) return 'read'
31 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'].includes(tool)) return 'write'
32
33 return null
34}
35
36// Clawd at work: reading only while every open call reads.
37export function workKind(open: Iterable<'read' | 'write'>): 'working' | 'reading' {
38 const kinds = [...open]
39
40 return kinds.length > 0 && kinds.every(kind => kind === 'read') ? 'reading' : 'working'
41}
42
43export type View = SkyInput & {
44 columns: number
45 mode: Mode
46 // The place's UTC offset; absent, Swedish time.
47 offsetMs?: number
48 // Leave the sky out so the terminal's wallpaper shows through.
49 isClear?: boolean
50 reaction?: { kind: Reaction; age: number } | null
51 // What is playing, "title - artist", if anything.
52 track?: string | null
53 // The word for what is happening in the sky, if anything.
54 event?: string | null
55 // How full Claude's context window is, 0 to 100: the paper in the
56 // wastebasket behind him.
57 paper?: number | null
58 // The wastebasket stands in the sky's corner (Ghostty's shader) instead:
59 // he throws the paper down toward it.
60 isBasketInSky?: boolean
61}
62
63const LABELS: Record<Activity, string> = {
64 earlyBird: "early birdin'",
65 coffee: 'coffee sesh',
66 work: "workin'",
67 vibe: "vibin'",
68 nightOwl: 'night owl',
69 sleep: 'sleep sesh',
70}
71
72// Wind from which his hat goes, in metres per second.
73export const WINDY = 9
74
75// The same day every day: up with the birds at six, coffee till noon, work
76// till four, then vibing, a night owl from ten, and asleep from three.
77export function activityAt({ hour }: Pick<LocalTime, 'hour'>): Activity {
78 if (hour >= 3 && hour < 6) return 'sleep'
79 if (hour >= 6 && hour < 8) return 'earlyBird'
80 if (hour >= 8 && hour < 12) return 'coffee'
81 if (hour >= 12 && hour < 16) return 'work'
82 if (hour >= 16 && hour < 22) return 'vibe'
83
84 return 'nightOwl'
85}
86
87// --- Riso ------------------------------------------------------------------
88
89const CREAM = 0xf5efe0
90// Clawd's own ink, Claude's coral: the orange drum printed thin on cream,
91// with a breath of indigo under it.
92const CORAL = mix(mix(INKS.orange, CREAM, 0.15), INKS.indigo, 0.1)
93// Wood and desks: orange printed over indigo.
94const WOOD = mix(INKS.orange, INKS.indigo, 0.35)
95// Blue printed thin, for screens and drops; paler still, for words.
96const SKY_INK = mix(INKS.blue, 0xffffff, 0.45)
97const PALE = mix(INKS.blue, 0xffffff, 0.6)
98// The key plate at its darkest, for screens and eyes.
99const KEY_DARK = mix(INKS.indigo, 0x000000, 0.55)
100
101// A deeper tone of an ink, as its halftone's dots would print it.
102function deeper(c: number): number {
103 return mix(c, INKS.indigo, 0.22)
104}
105
106// A sprite printed: rows of characters looked up in the palette ('.' and
107// unknown characters see-through). Night lays the indigo plate over all but
108// the `glows`; the `shaded` keys get their deeper tone on every other pixel,
109// from row `shadeFrom` down, as a halftone shades.
110function print(
111 c: Canvas,
112 x0: number,
113 y0: number,
114 rows: readonly string[],
115 palette: Record<string, number>,
116 light: number,
117 options: { glows?: string; shaded?: string; shadeFrom?: number } = {},
118): void {
119 const { glows = '', shaded = '', shadeFrom = 0 } = options
120 const night = clamp01((1 - light) / 0.45) * 0.45
121 rows.forEach((row, dy) => {
122 for (let dx = 0; dx < row.length; dx++) {
123 const ch = row[dx]!
124 const ink = palette[ch]
125 if (ink === undefined) continue
126 const x = x0 + dx
127 const y = y0 + dy
128 const isShaded = shaded.includes(ch) && dy >= shadeFrom && (Math.round(x) + Math.round(y)) % 2 === 0
129 const color = isShaded ? deeper(ink) : ink
130 c.set(x, y, glows.includes(ch) ? color : mix(color, KEY_DARK, night))
131 }
132 })
133}
134
135function clamp01(v: number): number {
136 return Math.min(1, Math.max(0, v))
137}
138
139// --- Clawd ----------------------------------------------------------------
140
141const CLAWD = { O: CORAL, K: INKS.indigo, k: deeper(CORAL), G: KEY_DARK, h: deeper(deeper(CORAL)) }
142
143export type Eyes = 'open' | 'closed' | 'right' | 'left' | 'shades' | 'up'
144export type Arms = 'down' | 'typing' | 'up' | 'right-up' | 'none'
145export type Legs = 'stand' | 'stepA' | 'stepB'
146
147const FACES: Record<Eyes, string> = {
148 open: '.KOOK.',
149 closed: '.kOOk.',
150 right: '.OKOK.',
151 left: '.KOKO.',
152 shades: '.GGGG.',
153 // Looking up: the eyes move to the top row of his head (clawdRows).
154 up: '.OOOO.',
155}
156
157const ARMS: Record<Arms, string> = {
158 down: 'OOOOOO',
159 typing: 'OOOOO.',
160 up: '.OOOO.',
161 'right-up': 'OOOOO.',
162 none: '.OOOO.',
163}
164
165const LEGS: Record<Legs, string> = {
166 stand: '.O..O.',
167 stepA: 'O...O.',
168 stepB: '.O...O',
169}
170
171// Clawd's four rows for a pose: head, face, arms, legs.
172export function clawdRows(eyes: Eyes, arms: Arms, legs: Legs): string[] {
173 return [eyes === 'up' ? '.KOOK.' : '.OOOO.', FACES[eyes], ARMS[arms], LEGS[legs]]
174}
175
176// Six wide, four tall: the logo's shape at the band's scale.
177function paintClawd(
178 c: Canvas,
179 x: number,
180 y: number,
181 eyes: Eyes,
182 arms: Arms,
183 legs: Legs,
184 light: number,
185): void {
186 // Shaded in his legs, the halftone darker below.
187 print(c, x, y, clawdRows(eyes, arms, legs), CLAWD, light, { shaded: 'O', shadeFrom: 3 })
188 if (arms === 'typing') print(c, x + 5, y + 3, ['O'], CLAWD, light)
189 if (arms === 'up') print(c, x, y - 1, ['O....O'], CLAWD, light)
190 // The key plate a pixel off register, just past his right side.
191 if (!c.isSolid(x + 6, y + 1)) c.blend(x + 6, y + 1, INKS.indigo, 0.55)
192}
193
194// --- Hats -------------------------------------------------------------------
195
196type Hat = { rows: string[]; palette: Record<string, number>; glows?: string }
197
198// Red is pink printed over orange.
199const RED = mix(INKS.pink, INKS.orange, 0.45)
200
201const HATS = {
202 nightcap: { rows: ['....NW', '.NNNN.'], palette: { N: INKS.blue, W: CREAM } },
203 beanie: { rows: ['..W...', '.RRRR.'], palette: { W: CREAM, R: RED } },
204 cap: { rows: ['......', '.BBBBB'], palette: { B: INKS.blue } },
205 santa: { rows: ['..RRRW', '.WWWW.'], palette: { R: RED, W: CREAM } },
206 lucia: { rows: ['.f..f.', '.wGGw.'], palette: { f: INKS.yellow, w: CREAM, G: INKS.green }, glows: 'f' },
207 midsummer: { rows: ['......', '.pGyG.'], palette: { p: INKS.pink, G: INKS.green, y: INKS.yellow } },
208} satisfies Record<string, Hat>
209
210export type HatKind = keyof typeof HATS
211
212function hatFor(occasion: Occasion | null, temperature: number | undefined, wind: number, isAwake: boolean): HatKind | null {
213 if (!isAwake) return 'nightcap'
214 if (occasion === 'lucia') return 'lucia'
215 if (occasion === 'christmas') return 'santa'
216 if (occasion === 'midsummer') return 'midsummer'
217 if (temperature !== undefined && temperature <= 0) return 'beanie'
218 if (wind >= WINDY) return 'cap'
219
220 return null
221}
222
223function paintHat(c: Canvas, hat: Hat, x: number, y: number, light: number, t: number): void {
224 const palette = { ...hat.palette }
225 // Lucia's candles flicker.
226 if (palette.f !== undefined) palette.f = Math.sin(t * 9) > 0 ? INKS.yellow : mix(INKS.yellow, INKS.orange, 0.5)
227 print(c, x, y - 2, hat.rows, palette, light, { glows: hat.glows })
228}
229
230// --- Wind -------------------------------------------------------------------
231
232export type Chase = {
233 dx: number
234 dy: number
235 legs: Legs
236 eyes: Eyes
237 // Where the hat is, relative to Clawd's resting place; `held` while in hand.
238 hat: { dx: number; dy: number; held: boolean } | null
239}
240
241// One ten-second loop: the hat lifts, flies off to the left, Clawd runs
242// `distance` after it, catches it with a jump, walks back and puts it on again.
243export function chaseAt(t: number, distance = 20): Chase | null {
244 const p = t % 10
245 const step = (rate: number): Legs => (Math.floor(p * rate) % 2 === 0 ? 'stepA' : 'stepB')
246 const ease = (a: number, b: number) => smoothstep(a, b, p)
247
248 if (p < 0.8) {
249 return { dx: 0, dy: 0, legs: 'stand', eyes: 'open', hat: { dx: -p * 3, dy: -p * 1.2, held: false } }
250 }
251 if (p < 3.6) {
252 const run = -distance * ease(1, 3.6)
253 const flight = -2.4 - (distance + 1) * ease(0.8, 3.4)
254
255 return {
256 dx: run,
257 dy: 0,
258 legs: p < 1 ? 'stand' : step(8),
259 eyes: 'left',
260 hat: { dx: flight, dy: 1.5 + Math.sin(p * 5), held: false },
261 }
262 }
263 if (p < 4.2) {
264 const jump = -Math.sin(((p - 3.6) / 0.6) * Math.PI) * 2
265
266 return { dx: -distance - (p - 3.6) * 2, dy: jump, legs: 'stand', eyes: 'left', hat: { dx: -distance - 2, dy: jump, held: true } }
267 }
268 if (p < 7.6) {
269 const back = -(distance + 1.2) * (1 - ease(4.2, 7.6))
270
271 return { dx: back, dy: 0, legs: step(5), eyes: 'right', hat: { dx: back, dy: 0, held: true } }
272 }
273 if (p < 8.4) {
274 const on = ease(7.6, 8.4)
275
276 return { dx: 0, dy: 0, legs: 'stand', eyes: 'open', hat: { dx: 0, dy: (1 - on) * 2, held: on < 0.5 } }
277 }
278
279 return null
280}
281
282// --- Scenes -----------------------------------------------------------------
283
284type Stage = {
285 c: Canvas
286 // Clawd's resting place: his top-left pixel.
287 x: number
288 y: number
289 t: number
290 light: number
291 occasion: Occasion | null
292 isFridayNight: boolean
293 // Out of his seat chasing his hat.
294 isAway: boolean
295}
296
297// Each scene paints what stands behind Clawd and in front of him, and how he
298// looks while he is in his seat.
299type Scene = {
300 behind: (s: Stage) => void
301 front: (s: Stage) => void
302 pose: (s: Stage) => { eyes: Eyes; arms: Arms }
303 glyphs?: (s: Stage) => Glyph[]
304}
305
306type Glyph = { x: number; y: number; text: string; fg: number; dim?: boolean }
307
308const SYNTAX = [INKS.pink, INKS.green, INKS.yellow, mix(INKS.blue, 0xffffff, 0.35), INKS.orange]
309
310const sleep: Scene = {
311 behind: ({ c, x, y, light }) => {
312 print(c, x - 1, y, ['F', 'F', 'F', 'F'], { F: WOOD }, light)
313 print(c, x + 7, y + 1, ['F', 'F', 'F'], { F: WOOD }, light)
314 },
315 front: ({ c, x, y, t, light }) => {
316 // The blanket rises and falls with his breath.
317 const isIn = Math.sin(t * 1.4) > 0
318 // A violet blanket, its halftone breathing with him.
319 const blanket = isIn ? mix(INKS.violet, CREAM, 0.35) : INKS.violet
320 print(c, x, y + 2, ['BBBBBBB'], { B: blanket }, light, { shaded: 'B' })
321 print(c, x - 1, y + 3, ['FFFFFFFFF'], { F: WOOD }, light)
322 },
323 pose: () => ({ eyes: 'closed', arms: 'none' }),
324 glyphs: ({ x, t }) => {
325 const shown = Math.floor(t / 0.9) % 3
326
327 return [
328 { x: x + 7, y: 0, text: 'z', fg: PALE },
329 { x: x + 9, y: 0, text: 'Z', fg: PALE },
330 ].slice(0, shown)
331 },
332}
333
334const coffee: Scene = {
335 behind: ({ c, x, y, light, occasion }) => {
336 print(c, x + 6, y + 3, ['DDDDDDD'], { D: WOOD }, light, { shaded: 'D' })
337 const bun = mix(INKS.orange, INKS.yellow, 0.4)
338 if (occasion === 'cinnamonBun') print(c, x + 10, y + 1, ['.b.', 'bBb'], { b: bun, B: WOOD }, light)
339 if (occasion === 'semla') print(c, x + 10, y + 1, ['ww', 'bb'], { w: CREAM, b: bun }, light)
340 },
341 front: ({ c, x, y, t, light, isAway }) => {
342 // A sip now and then lifts the mug a pixel; else it steams.
343 const isSipping = !isAway && t % 7 > 6
344 const lift = isSipping ? 1 : 0
345 print(c, x + 6, y + 1 - lift, ['WCW.', 'WWWW'], { W: CREAM, C: deeper(WOOD) }, light)
346 if (!isSipping) {
347 for (let k = 0; k < 3; k++) {
348 const sway = Math.round(Math.sin(t * 3 + k * 1.3) * 0.6)
349 c.blend(x + 7 + sway, y - k, 0xffffff, 0.5 - k * 0.14)
350 }
351 }
352 },
353 pose: ({ t }) => {
354 const isSipping = t % 7 > 6
355 const isBlinking = t % 4.3 < 0.15
356
357 return { eyes: isSipping || isBlinking ? 'closed' : 'open', arms: 'down' }
358 },
359}
360
361const code: Scene = {
362 behind: ({ c, x, y, t, light }) => {
363 print(c, x + 6, y + 3, ['DDDDDDD'], { D: WOOD }, light, { shaded: 'D' })
364 print(c, x + 7, y - 1, ['GGGGG', 'GsssG', 'GGGGG'], { G: mix(INKS.indigo, 0x808080, 0.35), s: KEY_DARK }, light, { glows: 's' })
365 // A line of code at a time, a new one every quarter second.
366 const line = Math.floor(t * 4)
367 for (let i = 0; i < 3; i++) {
368 if (rand(line * 7 + i) < 0.75) c.set(x + 8 + i, y, SYNTAX[Math.floor(rand(line * 13 + i) * SYNTAX.length)]!)
369 }
370 print(c, x + 6, y + 2, ['AAAAAA'], { A: mix(CREAM, INKS.blue, 0.2) }, light, { shaded: 'A' })
371 },
372 front: () => {},
373 pose: ({ t }) => ({ eyes: 'right', arms: Math.floor(t * 6) % 2 === 0 ? 'down' : 'typing' }),
374}
375
376const vibe: Scene = {
377 behind: ({ c, x, y, t, light, isFridayNight }) => {
378 const tx = x + 8
379 print(c, tx, y - 1, ['TTTTTT', 'T....T', 'T....T', 'TTTTTT'], { T: KEY_DARK }, light)
380 print(c, tx + 1, y + 3, ['L..L'], { L: deeper(KEY_DARK) }, light)
381 // Vice City: a pink sky with its sun, an orange causeway with a car on
382 // it, dithered as the screen's halftone would print it.
383 print(c, tx + 1, y, ['ppyp', 'oooo'], { p: INKS.pink, y: INKS.yellow, o: INKS.orange }, 1, { shaded: 'po' })
384 c.set(tx + 1 + (Math.floor(t * 3) % 4), y + 1, SKY_INK)
385 if (t % 1.2 < 0.25) {
386 c.set(tx + 1, y, RED)
387 c.set(tx + 4, y, INKS.blue)
388 }
389 if (isFridayNight) print(c, x + 6, y + 2, ['yY', 'BB'], { y: INKS.yellow, Y: mix(INKS.yellow, INKS.orange, 0.4), B: INKS.blue }, light)
390 },
391 front: ({ c, x, y, t, light, isAway }) => {
392 // The pad, mashed; on the floor while he is off after his hat.
393 const jiggle = Math.round(Math.sin(t * 14))
394 const at = isAway ? { x: x + 1, y: y + 3 } : { x: x + 2 + jiggle * 0.5, y: y + 2 }
395 print(c, at.x, at.y, ['cp'], { c: SKY_INK, p: INKS.pink }, light, { glows: 'cp' })
396 },
397 pose: () => ({ eyes: 'right', arms: 'down' }),
398}
399
400// Early birds and night owls share the coffee and the screen.
401const SCENES: Record<Activity, Scene> = { earlyBird: coffee, coffee, work: code, vibe, nightOwl: code, sleep }
402
403// Seasonal things standing to the left of the scene.
404function paintOccasion(c: Canvas, occasion: Occasion | null, x: number, y: number, t: number, light: number): void {
405 if (occasion === 'halloween') {
406 const glow = Math.sin(t * 5) > -0.3 ? INKS.yellow : mix(INKS.yellow, INKS.orange, 0.6)
407 print(c, x, y + 1, ['.g.', 'OOO', 'yOy'], { g: INKS.green, O: INKS.orange, y: glow }, light, { glows: 'y', shaded: 'O' })
408 }
409 if (occasion === 'christmas' || occasion === 'lucia') {
410 const lights = [RED, INKS.yellow, INKS.blue]
411 const bulb = (k: number) => lights[(Math.floor(t * 2) + k) % 3]!
412 print(c, x, y, ['.y.', '.G.', 'aGb', 'GGG'], { y: INKS.yellow, G: INKS.green, a: bulb(0), b: bulb(1) }, light, { glows: 'yab', shaded: 'G' })
413 }
414}
415
416// Fireworks over the band at the turn of the year.
417function paintFireworks(c: Canvas, right: number, t: number): void {
418 for (let k = 0; k < 3; k++) {
419 const period = 2.2 + k * 0.4
420 const age = (t + k * 0.9) % period
421 const round = Math.floor((t + k * 0.9) / period)
422 const cx = Math.floor(rand(round * 31 + k) * Math.max(1, right - 6)) + 3
423 const cy = 2
424 const color = [INKS.pink, SKY_INK, INKS.yellow, INKS.violet][Math.floor(rand(round * 17 + k) * 4)]!
425 if (age < 0.5) {
426 c.blend(cx, c.h - 1 - (age / 0.5) * (c.h - 1 - cy), mix(INKS.yellow, CREAM, 0.6), 0.9)
427 } else if (age < 1.6) {
428 const r = ((age - 0.5) / 1.1) * 3
429 const fade = 1 - (age - 0.5) / 1.1
430 for (let i = 0; i < 8; i++) {
431 const a = (i / 8) * Math.PI * 2
432 c.blend(cx + Math.cos(a) * r * 1.6, cy + Math.sin(a) * r * 0.7, color, fade)
433 }
434 }
435 }
436}
437
438// --- Reactions ------------------------------------------------------------
439
440// How long a passing reaction lasts, in seconds.
441export const REACTION_SECONDS: Record<Reaction, number> = {
442 working: Infinity,
443 reading: Infinity,
444 toss: 1.8,
445 burn: Infinity,
446 embers: 3,
447 cheer: 2.5,
448 facepalm: 3,
449 wave: Infinity,
450 lookUp: 10,
451}
452
453// Whether the sky has something it did not have before: Clawd looks up.
454export function skyGained(before: readonly string[], after: readonly string[]): boolean {
455 return after.some(thing => !before.includes(thing))
456}
457
458type Overrides = { eyes?: Eyes; arms?: Arms; dy?: number; glyphs: Glyph[] }
459
460function reactionOverrides(
461 c: Canvas,
462 reaction: View['reaction'],
463 x: number,
464 y: number,
465 t: number,
466 isAwake: boolean,
467 isInSky: boolean,
468): Overrides {
469 if (!reaction) return { glyphs: [] }
470 const { kind, age } = reaction
471 const dots = '.'.repeat(1 + (Math.floor(t * 3) % 3))
472
473 if (kind === 'working' || kind === 'reading') {
474 const glyphs = [{ x: x - 4, y: 0, text: dots.padEnd(3), fg: PALE }]
475 if (!isAwake) return { glyphs }
476 if (kind === 'reading') return { eyes: 'right', arms: 'typing', glyphs }
477
478 return { eyes: 'right', arms: Math.floor(t * 8) % 2 === 0 ? 'typing' : 'down', glyphs }
479 }
480 if (!isAwake) {
481 const mark = kind === 'cheer' ? '!' : kind === 'lookUp' ? '*' : '?'
482
483 return { glyphs: [{ x: x - 2, y: 0, text: mark, fg: INKS.yellow }] }
484 }
485 if (kind === 'cheer') {
486 const dy = -Math.round(Math.abs(Math.sin(age * Math.PI * 2.2)))
487 const sparkle = Math.floor(age * 6) % 2 === 0 ? '*' : '+'
488
489 return { arms: 'up', dy, glyphs: [{ x: x + 6, y: 0, text: sparkle, fg: INKS.yellow }] }
490 }
491 if (kind === 'facepalm') {
492 c.set(x + 4, y + 1, CLAWD.h)
493 c.blend(x + 6, y, SKY_INK, 0.9)
494
495 return { eyes: 'closed', arms: 'typing', glyphs: [] }
496 }
497 // A ball of paper over his shoulder into the basket.
498 if (kind === 'toss') return { eyes: isInSky ? 'right' : 'left', arms: age < 0.5 ? 'up' : 'down', glyphs: [] }
499 // A match thrown in, then watching the context burn away, and the smoke.
500 if (kind === 'burn') return { eyes: isInSky ? 'right' : 'left', arms: age < 0.6 ? 'up' : 'down', glyphs: [] }
501 if (kind === 'embers') return { eyes: 'closed', glyphs: [] }
502 // Something in the sky: eyes up, a claw pointing at it.
503 if (kind === 'lookUp') {
504 c.set(x + 6, y, CLAWD.O)
505
506 return { eyes: 'up', arms: 'right-up', glyphs: [{ x: x + 7, y: 0, text: '*', fg: INKS.yellow }] }
507 }
508 // Waving for attention, a question over his head.
509 const isHigh = Math.floor(t * 4) % 2 === 0
510 c.set(x + 6, y + (isHigh ? 0 : 1), CLAWD.O)
511
512 return { arms: 'right-up', glyphs: [{ x: x + 7, y: 0, text: '?', fg: INKS.yellow }] }
513}
514
515// What he works with, in front of whatever the scene has there: a book
516// open in his claws while Claude reads, a page turning now and then; keys
517// under them while it writes.
518function paintWork(c: Canvas, reaction: View['reaction'], x: number, y: number, t: number, light: number): void {
519 if (reaction?.kind === 'reading') {
520 print(c, x + 6, y + 2, [t % 2.5 < 0.3 ? 'pP' : 'PP', 'CC'], { P: CREAM, p: SKY_INK, C: INKS.blue }, light)
521 }
522 if (reaction?.kind === 'working') print(c, x + 6, y + 3, ['kkk'], { k: PALE }, light, { shaded: 'k' })
523}
524
525const STEEL = mix(PALE, INKS.indigo, 0.35)
526const FLAME = [mix(INKS.pink, INKS.orange, 0.45), INKS.orange, INKS.yellow]
527
528// The wastebasket behind him: a wire-mesh bin, the context window's paper
529// showing through it as it fills (the last of it heaped over the rim).
530// While the conversation compacts the paper burns, flames licking up and
531// the paper going; then smoke from an empty bin.
532function paintBasket(c: Canvas, x: number, y: number, paper: number | null | undefined, light: number, t: number, reaction: View['reaction']): void {
533 const left = x - 5
534 const burning = reaction?.kind === 'burn' ? reaction.age : null
535 const isEmpty = reaction?.kind === 'embers' || (burning !== null && burning > 4)
536 // The bin: a rim, a mesh over its dark inside, a narrower base.
537 print(c, left, y, ['RRRR', 'MiMi', 'iMiM', '.RR.'], { R: STEEL, M: deeper(STEEL), i: mix(KEY_DARK, STEEL, 0.25) }, light)
538 // The paper in the mesh's gaps, bottom up, the last of it heaped over the rim.
539 const cells = [[0, 2], [2, 2], [1, 1], [3, 1], [1, -1], [2, -1]]
540 const shown = isEmpty ? 0 : Math.round((Math.min(100, paper ?? 0) / 100) * cells.length) - (burning === null ? 0 : Math.floor(burning * 1.5))
541 cells.slice(0, Math.max(0, shown)).forEach(([dx, dy], k) => print(c, left + dx!, y + dy!, ['P'], { P: k % 2 === 0 ? CREAM : deeper(CREAM) }, light))
542 if (burning !== null && burning > 0.6) {
543 for (let k = 0; k < 4; k++) {
544 const tall = 1 + Math.floor(rand(Math.floor(t * 9) * 4 + k) * (isEmpty ? 1.5 : 3))
545 for (let j = 0; j < tall; j++) c.set(left + k, y - j, FLAME[Math.min(2, j + (k === 1 || k === 2 ? 1 : 0))]!)
546 }
547 }
548 if (burning !== null || reaction?.kind === 'embers') {
549 for (let k = 0; k < 3; k++) {
550 const age = (t * 0.8 + k / 3) % 1
551 c.blend(left + 1 + k % 2 + Math.round(Math.sin(t * 2 + k) * 0.7), y - 2 - Math.floor(age * 3), 0xb8b8c0, 0.6 * (1 - age))
552 }
553 }
554}
555
556// How long a ball (or a match) takes to reach the basket.
557export const THROW_SECONDS = 1.4
558
559// What flies to the basket: a ball of paper, or a lit match, arcing back
560// over his shoulder to the one behind him, or out and down toward the one
561// in the sky's corner.
562function paintThrow(c: Canvas, x: number, y: number, reaction: View['reaction'], isInSky: boolean): void {
563 const age = reaction?.kind === 'toss' ? reaction.age : reaction?.kind === 'burn' ? reaction.age : null
564 if (age === null || age > THROW_SECONDS) return
565 const f = age / THROW_SECONDS
566 // Into the sky's basket: high over his screen to the band's last
567 // columns, and only there down out of it, above where it stands.
568 const px = Math.round(isInSky ? x + 5 + 9 * f : x - 4 * f)
569 const py = Math.round(isInSky ? y + 1 - 12 * f * (1 - f) + 5 * f * f * f : y + 1 - 3 * f * (1 - f) * 2 - f)
570 c.set(px, py, reaction!.kind === 'toss' ? CREAM : INKS.yellow)
571 if (reaction!.kind === 'burn') c.set(px + (isInSky ? -1 : 1), py + 1, WOOD)
572}
573
574// --- Frame ----------------------------------------------------------------
575
576// Clawd and his things in the corner, as cells; the words he puts above
577// his head come back.
578function paintScene(c: Canvas, view: View, state: SceneState, x: number, y: number): Glyph[] {
579 const { t, reaction, track } = view
580 const { activity, occasion, isAwake, light, isWindy } = state
581 // A throw at the wastebasket stops even a chase after his hat.
582 const isThrowing = reaction?.kind === 'toss' || reaction?.kind === 'burn'
583 const chase = isWindy && !isThrowing ? chaseAt(t) : null
584 const stage: Stage = {
585 c,
586 x,
587 y,
588 t,
589 light,
590 occasion,
591 isFridayNight: state.isFridayNight,
592 isAway: chase !== null,
593 }
594 const scene = SCENES[activity]
595 const seated = scene.pose(stage)
596 const isInSky = !!view.isBasketInSky
597 const override = reactionOverrides(c, chase ? null : reaction, x, y, t, isAwake, isInSky)
598
599 paintOccasion(c, occasion, x - 8, y, t, light)
600 scene.behind(stage)
601 if (!isInSky) paintBasket(c, x, y, view.paper, light, t, chase ? null : reaction)
602
603 const cx = x + Math.round(chase?.dx ?? 0)
604 const cy = y + Math.round(chase?.dy ?? override.dy ?? 0)
605 const eyes = chase?.eyes ?? override.eyes ?? (state.isCool ? 'shades' : seated.eyes)
606 const arms = chase ? 'down' : override.arms ?? seated.arms
607 paintClawd(c, cx, cy, eyes, arms, chase?.legs ?? 'stand', light)
608 scene.front(stage)
609 if (isAwake && !chase) paintWork(c, reaction, x, y, t, light)
610 if (!chase) paintThrow(c, x, y, reaction, isInSky)
611
612 const hat = state.hat && HATS[state.hat]
613 if (hat) {
614 const at = chase?.hat
615 if (!at) paintHat(c, hat, cx, cy, light, t)
616 else if (at.held) paintHat(c, hat, x + Math.round(at.dx) + 3, y + Math.round(at.dy) + 3, light, t)
617 else paintHat(c, hat, x + Math.round(at.dx), y + Math.round(at.dy), light, t)
618 }
619
620 // Headphones and notes while music plays.
621 if (track && isAwake) {
622 print(c, cx, cy - 1, ['.HHHH.'], { H: KEY_DARK }, light)
623 print(c, cx, cy + 1, ['H....H'], { H: KEY_DARK }, light)
624 for (let k = 0; k < 2; k++) {
625 const age = (t * 0.7 + k * 0.5) % 1
626 c.blend(cx + 6 + k * 2, cy + 1 - age * 3, k === 0 ? INKS.pink : SKY_INK, 1 - age)
627 }
628 }
629
630 return [...(scene.glyphs?.(stage) ?? []), ...override.glyphs]
631}
632
633// The scene's corner in pixels (= columns), counted from the right edge.
634const CORNER = 22
635
636function pad(n: number): string {
637 return String(n).padStart(2, '0')
638}
639
640// Segments joined with ' · ' while they fit in `room`.
641function fit(segments: (string | null | undefined | false)[], room: number): string {
642 let line = ''
643 for (const s of segments) {
644 if (!s) continue
645 const next = line ? `${line} · ${s}` : s
646 if (next.length > room) break
647 line = next
648 }
649
650 return line
651}
652
653// What the moment holds for Clawd, whoever draws him.
654export type SceneState = {
655 time: LocalTime
656 occasion: Occasion | null
657 activity: Activity
658 isAwake: boolean
659 // How lit the scene is, 0.55 at night to 1 by day.
660 light: number
661 wind: number
662 isWindy: boolean
663 // Shades in a warm sun.
664 isCool: boolean
665 isFridayNight: boolean
666 hat: HatKind | null
667}
668
669// What Clawd does at a time: the usual hours, but up all New Year's night,
670// and early for the Lucia train.
671export function activityOn(time: LocalTime): Activity {
672 const usual = activityAt(time)
673 if (usual !== 'sleep') return usual
674 const occasion = occasionAt(time)
675
676 return occasion === 'newYear' ? 'nightOwl' : occasion === 'lucia' && time.hour >= 5 ? 'earlyBird' : usual
677}
678
679export function sceneOf(view: View): SceneState {
680 const { ms, place, offsetMs, weather } = view
681 const sun = sunAt(ms, place.lat, place.lon)
682 const time = localTime(ms, offsetMs)
683 const occasion = occasionAt(time)
684 const activity = activityOn(time)
685 const isAwake = activity !== 'sleep'
686 const wind = weather?.wind ?? 0
687
688 return {
689 time,
690 occasion,
691 activity,
692 isAwake,
693 light: 0.55 + 0.45 * smoothstep(-12, 6, sun.elevation),
694 wind,
695 isWindy: isAwake && wind >= WINDY,
696 isCool: isAwake && !!weather && weather.temperature >= 25 && sun.elevation > 0,
697 isFridayNight: time.weekday === 5 && activity === 'vibe',
698 hat: hatFor(occasion, weather?.temperature, wind, isAwake),
699 }
700}
701
702// The band as cells: the sky (or the terminal's own background, where a
703// wallpaper or shader shows through), the words, and Clawd's corner.
704export function drawFrame(view: View): string {
705 const { columns, ms, t, mode, place, weather, isClear = false, reaction, track } = view
706 const c = new Canvas(columns, ROWS * 2)
707 const sun = sunAt(ms, place.lat, place.lon)
708 const state = sceneOf(view)
709 const { time, occasion, activity, isAwake, light, isWindy } = state
710
711 if (!isClear) paintBandSky(c, view)
712 paintPrecipitation(c, weather, t)
713
714 const x = columns - CORNER + 7
715 const y = c.h - 4
716 const labelRight = columns - CORNER - 1 - (isWindy ? 22 : 0)
717 if (occasion === 'newYear') paintFireworks(c, labelRight - 30, t)
718 const glyphs: Glyph[] = []
719 glyphs.push(...paintScene(c, view, state, x, y))
720
721 // The words, right-aligned against the scene.
722 const room = labelRight - 1
723 const aurora = auroraStrength(view, sun) > 0.15
724 const moon = moonAt(ms, place.lat, place.lon)
725 const status = mode === 'demo' ? 'time-lapse · /solar now' : mode === 'pinned' ? 'pinned time · /solar now' : null
726 const lines = [
727 fit([`${pad(time.hour)}:${pad(time.minute)} ${LABELS[activity]}`, occasion && OCCASION_WORDS[occasion], state.isFridayNight && 'friday night'], room),
728 fit([
729 place.name,
730 weather && `${Math.round(weather.temperature)}°C ${SKY_WORDS[skyOf(weather.code)]}`,
731 weather && weather.wind >= 5 && `${Math.round(weather.wind)} m/s`,
732 ], room),
733 fit([
734 view.event && `* ${view.event}`,
735 `sun ${Math.round(sun.elevation)}°`,
736 moon.elevation > 0 && `moon ${Math.round(moon.illumination * 100)}%`,
737 aurora && 'aurora',
738 status,
739 track && isAwake && `listening: ${track.length > 28 ? `${track.slice(0, 27)}~` : track}`,
740 ], room),
741 ]
742 lines.forEach((text, row) => {
743 if (text) glyphs.push({ x: labelRight - text.length, y: row, text, fg: 0, dim: row > 0 })
744 })
745
746 return encode(c, glyphs, isClear)
747}
748
749// Latin a cell can hold, without the C0 and C1 control characters.
750export function isPrintable(code: number): boolean {
751 // A soft hyphen (U+00AD) draws as nothing in some terminals: not one.
752 return (code >= 0x20 && code < 0x7f) || (code >= 0xa0 && code < 0x250 && code !== 0xad)
753}
754
755// The terminal's own background, where a clear band lets the wallpaper show.
756const DEFAULT = 0x01000000
757
758function encode(canvas: Canvas, glyphs: Glyph[], isClear: boolean): string {
759 const { w } = canvas
760 const words = new Uint32Array(w * ROWS * 3)
761 const background = (x: number, y: number) =>
762 isClear ? DEFAULT : mix(canvas.get(x, y * 2), canvas.get(x, y * 2 + 1), 0.5)
763
764 for (let y = 0; y < ROWS; y++) {
765 for (let x = 0; x < w; x++) {
766 const i = (y * w + x) * 3
767 const top = canvas.get(x, y * 2)
768 const bottom = canvas.get(x, y * 2 + 1)
769 const isTop = !isClear || canvas.isSolid(x, y * 2)
770 const isBottom = !isClear || canvas.isSolid(x, y * 2 + 1)
771 if (isTop) {
772 words[i] = 0x2580
773 words[i + 1] = top
774 words[i + 2] = isBottom ? bottom : DEFAULT
775 } else if (isBottom) {
776 words[i] = 0x2584
777 words[i + 1] = bottom
778 words[i + 2] = DEFAULT
779 } else {
780 words[i] = 0x20
781 words[i + 1] = DEFAULT
782 words[i + 2] = DEFAULT
783 }
784 }
785 }
786
787 // Text sits on the blended colour of its cell, light or dark to suit it;
788 // on a clear band, on the wallpaper, which is kept dark enough for white.
789 for (const glyph of glyphs) {
790 if (glyph.y < 0 || glyph.y >= ROWS) continue
791 for (let k = 0; k < glyph.text.length; k++) {
792 const x = glyph.x + k
793 // One cell per character: anything past Latin (a wide or combining
794 // character, half a surrogate pair) or a control character is '?'.
795 const char = glyph.text.charCodeAt(k)
796 const code = isPrintable(char) ? char : 0x3f
797 if (x < 0 || x >= w) continue
798 const bg = background(x, glyph.y)
799 const ink = glyph.fg || (!isClear && luminance(bg) > 0.6 ? 0x1b1f33 : 0xffffff)
800 const i = (glyph.y * w + x) * 3
801 words[i] = code
802 words[i + 1] = glyph.dim ? mix(ink, isClear ? 0x404660 : bg, 0.3) : ink
803 words[i + 2] = bg
804 }
805 }
806
807 return toBase64(new Uint8Array(words.buffer))
808}
809
810// Clawd's passing reaction once the sky has (or has not) gained something:
811// he looks up at it, unless a reaction is still under way (a cheer for work
812// done keeps its moment).
813export function nextPassing(passing: { kind: Reaction; at: number } | null, now: number, isGained: boolean): { kind: Reaction; at: number } | null {
814 if (!isGained) return passing
815 if (passing && (now - passing.at) / 1000 < REACTION_SECONDS[passing.kind]) return passing
816
817 return { kind: 'lookUp', at: now }
818}
819hooks/events.ts 310 lines1// Small, rare things in the sky, printed in risograph like the rest of it:
2// balloons, planes, birds passing, a comet some nights. Which are up is a
3// pure function of the time (and the sky then), so every session, the band
4// and the shader agree without keeping anything.
5//
6// Each event has a slot it may come once in, a chance that it does, a
7// length, and a condition the sky must meet while it plays. Shooting stars
8// are too quick to schedule from here (Ghostty's shader has no clock but
9// its own): they have a rate instead, and the shader places them.
10
11import { easter, localTime, sunAt } from './astro'
12import type { LocalTime, Place } from './astro'
13import { hash, rand } from './paint'
14import { greyness, skyOf } from './sky'
15import type { Sky, Weather } from './sky'
16
17export type EventKind =
18 | 'satellite'
19 | 'balloon'
20 | 'birds'
21 | 'plane'
22 | 'rainbow'
23 | 'kite'
24 | 'leaves'
25 | 'blossom'
26 | 'comet'
27 | 'constellation'
28 | 'dolphin'
29 | 'pinkDolphin'
30 | 'milestone'
31 | 'shootingStar'
32 | 'abduction'
33 | 'surf'
34 // The Swedish days.
35 | 'witch'
36 | 'feathers'
37 | 'flag'
38 | 'maypole'
39 | 'luciaTrain'
40 | 'christmasStar'
41 | 'sleigh'
42 | 'fireworks'
43
44// What an event's condition may look at.
45export type EventContext = {
46 time: LocalTime
47 elevation: number
48 cloudiness: number
49 sky: Sky
50 wind: number
51 // When it last rained, if lately.
52 lastRainAt: number | null
53 ms: number
54}
55
56export type ActiveEvent = {
57 kind: EventKind
58 // Repeatable per occurrence: where it flies, which way, its colours.
59 seed: number
60 start: number
61 durationMs: number
62 // 0 to 1 through its length.
63 progress: number
64}
65
66type Spec = {
67 kind: EventKind
68 // The word the band shows while it plays.
69 word: string
70 slotMs: number
71 chance: number
72 durationMs: number
73 when: (ctx: EventContext) => boolean
74}
75
76const MINUTE = 60_000
77const HOUR = 60 * MINUTE
78const DAY = 24 * HOUR
79
80const isNight = (ctx: EventContext) => ctx.elevation < -8
81const isDay = (ctx: EventContext) => ctx.elevation > 2
82const isClear = (ctx: EventContext, most = 0.3) => ctx.cloudiness <= most
83const isWet = (sky: Sky) => sky === 'drizzle' || sky === 'rain' || sky === 'thunder' || sky === 'snow'
84const inMonths = (ctx: EventContext, ...months: number[]) => months.includes(ctx.time.month)
85
86// The catalogue, at a pace that leaves the sky mostly quiet.
87export const EVENTS: readonly Spec[] = [
88 { kind: 'balloon', word: 'balloon', slotMs: 3 * HOUR, chance: 1, durationMs: 4 * MINUTE, when: ctx => isDay(ctx) && ctx.wind < 6 && !isWet(ctx.sky) },
89 {
90 kind: 'birds',
91 word: 'migrating birds',
92 slotMs: 40 * MINUTE,
93 chance: 1,
94 durationMs: 90_000,
95 // Spring and autumn, around sunrise and sunset.
96 when: ctx => inMonths(ctx, 3, 4, 5, 8, 9, 10) && ctx.elevation > -3 && ctx.elevation < 15 && !isWet(ctx.sky),
97 },
98 { kind: 'plane', word: 'plane', slotMs: HOUR, chance: 1, durationMs: 2 * MINUTE, when: ctx => isDay(ctx) && isClear(ctx) },
99 { kind: 'kite', word: 'kite', slotMs: HOUR, chance: 1, durationMs: 5 * MINUTE, when: ctx => isDay(ctx) && ctx.wind >= 6 && ctx.wind <= 12 && !isWet(ctx.sky) },
100 {
101 kind: 'leaves',
102 word: 'autumn leaves',
103 slotMs: HOUR,
104 chance: 0.8,
105 durationMs: 3 * MINUTE,
106 when: ctx => inMonths(ctx, 9, 10, 11) && ctx.wind >= 5 && ctx.elevation > -6,
107 },
108 { kind: 'blossom', word: 'cherry blossom', slotMs: HOUR, chance: 0.8, durationMs: 3 * MINUTE, when: ctx => inMonths(ctx, 5) && ctx.elevation > -6 },
109 { kind: 'comet', word: 'comet', slotMs: 60 * DAY, chance: 1, durationMs: 3 * DAY, when: ctx => isNight(ctx) && isClear(ctx, 0.6) },
110 { kind: 'dolphin', word: 'dolphin', slotMs: 30 * MINUTE, chance: 1, durationMs: 60_000, when: ctx => isDay(ctx) && ctx.wind < 12 && !isWet(ctx.sky) },
111 // Rarer: a pink one, as the Amazon's river dolphins are.
112 { kind: 'pinkDolphin', word: 'pink dolphin', slotMs: 6 * HOUR, chance: 0.5, durationMs: 60_000, when: ctx => isDay(ctx) && ctx.wind < 12 && !isWet(ctx.sky) },
113 { kind: 'constellation', word: 'clawd constellation', slotMs: 3.5 * DAY, chance: 1, durationMs: 10 * MINUTE, when: ctx => isNight(ctx) && isClear(ctx, 0.5) },
114]
115
116// Not scheduled: while its condition holds, and when Claude says so. A
117// satellite is the real ISS now (iss.ts), and played only as a preview.
118const RAINBOW = { kind: 'rainbow', word: 'rainbow' } as const
119const MILESTONE = { kind: 'milestone', word: 'milestone', durationMs: 20_000 } as const
120const SHOOTING_STAR = { kind: 'shootingStar', word: 'shooting star' } as const
121// The keeper's own: the shader schedules his abduction and surf from iTime,
122// so these are previews only.
123const ABDUCTION = { kind: 'abduction', word: 'ufo abduction' } as const
124const SURF = { kind: 'surf', word: 'surf' } as const
125const SATELLITE = { kind: 'satellite', word: 'satellite' } as const
126
127// The Swedish days' words, as the band and /solar say them.
128const DAY_WORDS = {
129 witch: 'easter witch',
130 feathers: 'easter feathers',
131 flag: 'national day',
132 maypole: 'maypole',
133 luciaTrain: 'lucia procession',
134 christmasStar: 'christmas star',
135 sleigh: "santa's sleigh",
136 fireworks: 'fireworks',
137} as const
138type DayKind = keyof typeof DAY_WORDS
139
140// The words the mod once spoke, Swedish, still taken by /solar event.
141export const OLD_EVENT_WORDS: Record<string, EventKind> = {
142 luftballong: 'balloon', flyttfåglar: 'birds', flygplan: 'plane', drake: 'kite', höstlöv: 'leaves', körsbärsblad: 'blossom',
143 komet: 'comet', delfin: 'dolphin', 'rosa delfin': 'pinkDolphin', 'clawd-stjärnbild': 'constellation', regnbåge: 'rainbow',
144 milstolpe: 'milestone', stjärnfall: 'shootingStar', satellit: 'satellite', påskkärring: 'witch', påskfjädrar: 'feathers',
145 nationaldagen: 'flag', midsommarstång: 'maypole', luciatåg: 'luciaTrain', julstjärna: 'christmasStar', tomten: 'sleigh', fyrverkeri: 'fireworks',
146}
147
148// Every event the band and /solar can name, its word first.
149export const EVENT_WORDS: Record<EventKind, string> = {
150 ...(Object.fromEntries(EVENTS.map(spec => [spec.kind, spec.word])) as Record<Exclude<EventKind, 'rainbow' | 'milestone' | 'shootingStar' | 'abduction' | 'surf' | 'satellite' | DayKind>, string>),
151 ...DAY_WORDS,
152 satellite: SATELLITE.word,
153 rainbow: RAINBOW.word,
154 milestone: MILESTONE.word,
155 shootingStar: SHOOTING_STAR.word,
156 abduction: ABDUCTION.word,
157 surf: SURF.word,
158}
159
160// How long a preview (`/solar event <name>`) plays an event that has no
161// length of its own, or a long one.
162export const PREVIEW_MS = 90_000
163
164export function contextAt(ms: number, place: Place, weather: Weather | null | undefined, lastRainAt: number | null, offsetMs?: number): EventContext {
165 return {
166 ms,
167 time: localTime(ms, offsetMs),
168 elevation: sunAt(ms, place.lat, place.lon).elevation,
169 cloudiness: greyness(weather),
170 sky: weather ? skyOf(weather.code) : 'clear',
171 wind: weather?.wind ?? 0,
172 lastRainAt,
173 }
174}
175
176// The meteor showers worth a night up: the Perseids and the Geminids.
177export function showerAt(time: LocalTime): string | null {
178 if (time.month === 8 && time.day >= 11 && time.day <= 13) return 'Perseids'
179 if (time.month === 12 && time.day >= 13 && time.day <= 14) return 'Geminids'
180
181 return null
182}
183
184// Shooting stars an hour: a couple on a clear night, many in a shower.
185export function shootingRate(ctx: EventContext): number {
186 if (!isNight(ctx) || !isClear(ctx)) return 0
187
188 return showerAt(ctx.time) ? 10 : 2
189}
190
191function isRainbow(ctx: EventContext): boolean {
192 // Rain that has been: a pinned or played time may lie before it.
193 const since = ctx.lastRainAt === null ? -1 : ctx.ms - ctx.lastRainAt
194 const isRecent = since >= 0 && since < 30 * MINUTE
195
196 return isRecent && !isWet(ctx.sky) && ctx.elevation > 0 && ctx.elevation < 42
197}
198
199// --- The Swedish days ---------------------------------------------------------
200
201const DAY_MS = 24 * HOUR
202
203// Days from Easter Sunday to this local date.
204function fromEaster(time: LocalTime): number {
205 const { month, day } = easter(time.year)
206
207 return Math.round((Date.UTC(time.year, time.month - 1, time.day) - Date.UTC(time.year, month - 1, day)) / DAY_MS)
208}
209
210// One pass of `passMs` in each `slotMinutes` slot from local minute
211// `from` to `to`, each at a moment its seed picks.
212function passes(kind: EventKind, ms: number, time: LocalTime, from: number, to: number, slotMinutes: number, passMs: number): ActiveEvent | null {
213 const minutes = time.hour * 60 + time.minute
214 if (minutes < from || minutes >= to) return null
215 const slot = Math.floor((minutes - from) / slotMinutes)
216 const seed = hash(time.year * 10_000 + time.month * 400 + time.day * 12 + slot)
217 const slotStart = ms - (minutes - from - slot * slotMinutes) * MINUTE - (ms % MINUTE)
218 const start = slotStart + Math.floor(rand(seed) * (slotMinutes * MINUTE - passMs))
219 if (ms < start || ms >= start + passMs) return null
220
221 return { kind, seed: seed % 1000, start, durationMs: passMs, progress: (ms - start) / passMs }
222}
223
224// Up all through the local day (or from `from` to `to`, local minutes),
225// `progress` how far through.
226function lasting(kind: EventKind, ms: number, time: LocalTime, from = 0, to = 1440): ActiveEvent {
227 const minutes = time.hour * 60 + time.minute
228 const start = ms - (minutes - from) * MINUTE - (ms % MINUTE)
229 const durationMs = (to - from) * MINUTE
230
231 return { kind, seed: time.year % 1000, start, durationMs, progress: (ms - start) / durationMs }
232}
233
234// The fireworks' strength: a rocket now and then through the evening, the
235// whole sky from ten to midnight to a quarter past, dying out by half past.
236function fireworksAt(time: LocalTime): number | null {
237 const minutes = time.hour * 60 + time.minute
238 if (time.month === 12 && time.day === 31 && minutes >= 18 * 60) return minutes >= 23 * 60 + 50 ? 1 : 0.15
239 if (time.month === 1 && time.day === 1 && minutes < 30) return minutes < 15 ? 1 : 1 - (minutes - 15) / 15
240
241 return null
242}
243
244// The Swedish days up at `ms`, in Swedish local time: Easter, the national
245// day, Midsummer's Eve, Lucia, Christmas Eve and New Year.
246export function dayEventsAt(ms: number, ctx: EventContext): ActiveEvent[] {
247 const { time } = ctx
248 const days: (ActiveEvent | null)[] = []
249 const easterDay = fromEaster(time)
250 if (easterDay === -3) days.push(passes('witch', ms, time, 19 * 60, 23 * 60, 40, 3 * MINUTE))
251 if (easterDay >= -3 && easterDay <= 1) days.push(lasting('feathers', ms, time))
252 if (time.month === 6 && time.day === 6) days.push(lasting('flag', ms, time))
253 if (time.month === 6 && time.day >= 19 && time.day <= 25 && time.weekday === 5) {
254 days.push(lasting('maypole', ms, time), lasting('blossom', ms, time))
255 }
256 if (time.month === 12 && time.day === 13 && time.hour >= 5 && time.hour < 8 && ctx.elevation < -3) {
257 const start = Math.floor(ms / (3 * MINUTE)) * 3 * MINUTE
258 days.push({ kind: 'luciaTrain', seed: 13, start, durationMs: 3 * MINUTE, progress: (ms - start) / (3 * MINUTE) })
259 }
260 if (time.month === 12 && time.day === 24) {
261 days.push(lasting('christmasStar', ms, time))
262 days.push(passes('sleigh', ms, time, 15 * 60, 15 * 60 + 2, 2, 2 * MINUTE) ?? passes('sleigh', ms, time, 17 * 60, 22 * 60, 30, 2 * MINUTE))
263 }
264 const fireworks = fireworksAt(time)
265 if (fireworks !== null) days.push({ ...lasting('fireworks', ms, time), progress: fireworks })
266
267 return days.filter((day): day is ActiveEvent => day !== null)
268}
269
270export type Forced = { kind: EventKind; at: number }
271
272// The events up at `ms`: scheduled ones whose condition holds, a rainbow if
273// the sky makes one, and those `forced` (a preview or a milestone) still
274// within their length.
275export function eventsAt(ms: number, ctx: EventContext, forced: readonly Forced[] = []): ActiveEvent[] {
276 const active: ActiveEvent[] = []
277
278 EVENTS.forEach((spec, index) => {
279 const slot = Math.floor(ms / spec.slotMs)
280 const seed = hash(index * 1_000_003 + slot * 7919 + 17)
281 if (rand(seed) >= spec.chance) return
282 const start = slot * spec.slotMs + Math.floor(rand(seed + 1) * (spec.slotMs - spec.durationMs))
283 if (ms < start || ms >= start + spec.durationMs || !spec.when(ctx)) return
284 active.push({ kind: spec.kind, seed: seed % 1000, start, durationMs: spec.durationMs, progress: (ms - start) / spec.durationMs })
285 })
286
287 active.push(...dayEventsAt(ms, ctx))
288
289 if (isRainbow(ctx)) {
290 active.push({ kind: 'rainbow', seed: 0, start: ctx.lastRainAt ?? ms, durationMs: 30 * MINUTE, progress: 0 })
291 }
292
293 const played: ActiveEvent[] = []
294 for (const { kind, at } of forced) {
295 const durationMs = kind === 'milestone' ? MILESTONE.durationMs : PREVIEW_MS
296 if (ms < at || ms >= at + durationMs) continue
297 if (active.some(event => event.kind === kind) || played.some(event => event.kind === kind)) {
298 played.push(...active.filter(event => event.kind === kind && !played.includes(event)))
299 continue
300 }
301 // A preview of the fireworks is the full display.
302 const progress = kind === 'fireworks' ? 1 : (ms - at) / durationMs
303 played.push({ kind, seed: hash(at) % 1000, start: at, durationMs, progress })
304 }
305
306 // What was asked for (a preview, a milestone) first: the shader shows the
307 // first few, and the band names the first.
308 return [...played, ...active.filter(event => !played.includes(event))]
309}
310hooks/ghostty.ts 406 lines1import type { EngineInterface } from 'claude-code'
2
3import { animationOf, beaconAt } from './beacon'
4import type { Waiting } from './beacon'
5import { field, number } from './net'
6import { tabMark } from './shader'
7import type { ShaderParams } from './shader'
8import { staleFiles } from './tidy'
9
10// Ghostty draws the sky itself, from a shader the mod writes into its
11// runtime folder: the config, the include, the tab's mark, the reload, and
12// the files sessions share.
13
14// Ghostty's config includes runtime/solar-clawd.ghostty, which names the
15// shader while the sky is up and is empty otherwise; each change is
16// followed by a reload, which Ghostty takes without a flicker.
17const RELOAD_GHOSTTY = [
18 '-e',
19 'tell application id "com.mitchellh.ghostty" to perform action "reload_config" on focused terminal of selected tab of front window',
20]
21
22// Each Ghostty session leaves a heartbeat this often; one older than
23// ALIVE_MS is a session gone.
24const HEARTBEAT_MS = 60_000
25const ALIVE_MS = 5 * 60_000
26
27// Ghostty's config files, the one to add the include to first: on macOS
28// Application Support before ~/.config, on Linux only the XDG folder.
29export function ghosttyConfigs(home: string, isOnLinux: boolean, xdgConfig: string | null): string[] {
30 const xdg = `${xdgConfig ?? `${home}/.config`}/ghostty`
31 if (isOnLinux) return [`${xdg}/config.ghostty`, `${xdg}/config`]
32 const support = `${home}/Library/Application Support/com.mitchellh.ghostty`
33
34 return [`${support}/config.ghostty`, `${support}/config`, `${xdg}/config.ghostty`, `${xdg}/config`]
35}
36
37// Where a Ghostty session keeps the sky's files, and what it last wrote:
38// the shader, the include, the heartbeat's time.
39export type Ghostty = {
40 // This session's id, which its heartbeat and its temp files are named by.
41 id: string
42 folder: string
43 include: string
44 shader: string
45 heartbeat: string
46 // What this session waits on the person for, while it does: other
47 // sessions read it, so any of their skies lights the lighthouse.
48 waiting: string
49 // Present while the sky is hidden: /solar in any session hides it for all.
50 hidden: string
51 home: string
52 configs: string[]
53 isLinux: boolean
54 // The stage strip's width in device pixels: STAGE_PT times the screen scale.
55 stagePx: number
56 // Whether Ghostty is new enough to reload on SIGUSR2 (Linux only).
57 knowsReloadSignal: boolean
58 // Whether Ghostty's config includes the mod's file, asked each minute.
59 isIncluded: boolean
60 written: {
61 shader: string
62 include: string
63 // The tab's mark as last written (markOf).
64 mark: string
65 heartbeatAt: number
66 // A change Ghostty has not yet reloaded for: tried again each tick.
67 isReloadOwed: boolean
68 // Set as the session ends: no tick writes after it.
69 isEnded: boolean
70 // The update under way, which the session's end waits on, and whether
71 // something changed while it ran, so it runs again.
72 running: Promise<void> | null
73 isAgain: boolean
74 }
75}
76
77// Whether one of Ghostty's config files includes the mod's file, on a line
78// of its own (a commented-out line does not count).
79export async function isIncluded($: EngineInterface, ghostty: Ghostty): Promise<boolean> {
80 for (const path of ghostty.configs) {
81 const text = await $.fs.read(path).catch(() => '')
82 const lines = text.split('\n').map(line => line.trim().replace(/^config-file\s*=\s*\??/, ''))
83 if (text.split('\n').some((line, i) => /^config-file\s*=/.test(line.trim()) && lines[i] === ghostty.include)) return true
84 }
85
86 return false
87}
88
89// Removes what crashed sessions left in `folder`; `keep` is this session's.
90export async function tidy($: EngineInterface, folder: string, keep: string[]): Promise<void> {
91 const now = await $.clock.now()
92 const entries = await $.fs.list(folder).catch(() => [])
93 const paths = staleFiles(entries, folder, now, keep)
94 if (paths.length > 0) await $.process.run(['/bin/rm', '-f', ...paths]).catch(() => undefined)
95}
96
97// Ghostty's config, and with it the shader, is one for every tab and
98// window. A session marks its own tab instead: palette colour 16 (black,
99// in the colour cube) one step off, which no one sees and the shader looks
100// for, so the sky shows only where Claude runs. Written to the session's
101// own terminal: the tty of the process the mod's commands run under.
102const UNMARK_TAB = '\x1b]104;16\x1b\\'
103// The mark for what this tab's wastebasket holds (tabMark).
104function markOf(basket: ShaderParams['basket']): string {
105 const hex = tabMark(basket).map(v => v.toString(16).padStart(2, '0'))
106
107 return `\x1b]4;16;rgb:${hex.join('/')}\x1b\\`
108}
109export async function markTab($: EngineInterface, sequence: string): Promise<void> {
110 const script = 'tty=$(ps -o tty= -p $PPID | tr -d " "); case "$tty" in ""|"?"*) exit 1;; esac; printf "%s" "$1" > "/dev/$tty"'
111 await $.process.run(['sh', '-c', script, 'sh', sequence], { timeoutMs: 5_000 }).catch(() => undefined)
112}
113
114// Whether Ghostty took the reload. On Linux Ghostty reloads on SIGUSR2:
115// through systemd while it runs as its service, else sent to it straight,
116// but only to a Ghostty new enough to know the signal (to an older one it
117// is the end).
118export async function reloadGhostty($: EngineInterface, ghostty: Ghostty): Promise<boolean> {
119 try {
120 if (ghostty.isLinux) {
121 const service = await $.process
122 .run(['systemctl', '--user', 'reload', 'app-com.mitchellh.ghostty.service'], { timeoutMs: 5_000 })
123 .catch(() => null)
124 if (service?.exitCode === 0) return true
125 if (!ghostty.knowsReloadSignal) return false
126 const { exitCode } = await $.process.run(['pkill', '-USR2', '-x', 'ghostty'], { timeoutMs: 5_000 })
127
128 return exitCode === 0
129 }
130 const { exitCode } = await $.process.run(['/usr/bin/osascript', ...RELOAD_GHOSTTY], { timeoutMs: 5_000 })
131
132 return exitCode === 0
133 } catch {
134 return false
135 }
136}
137
138// Whether the Ghostty on the PATH is 1.3 or later.
139export async function ghosttyKnowsReloadSignal($: EngineInterface): Promise<boolean> {
140 const answer = await $.process.run(['ghostty', '--version'], { timeoutMs: 5_000 }).catch(() => null)
141 const [major = 0, minor = 0] = (/Ghostty (\d+)\.(\d+)/.exec(answer?.stdout ?? '') ?? []).slice(1).map(Number)
142
143 return major > 1 || (major === 1 && minor >= 3)
144}
145
146// Writes `text` to a file of this session's own beside `path`, then moves it
147// into place: Ghostty, reloading at any moment, never reads half a file.
148async function writeWhole($: EngineInterface, path: string, text: string, id: string): Promise<void> {
149 const temp = `${path}.tmp-${id}`
150 await $.fs.write(temp, text)
151 const { exitCode } = await $.process.run(['/bin/mv', '-f', temp, path])
152 if (exitCode !== 0) throw new Error(`mv exited ${exitCode}`)
153}
154
155// What the Ghostty sky depends on besides the time: whether the person is
156// away, whether the Mac runs on battery, and what this session waits on the
157// person for.
158export type SkyState = {
159 isAway: boolean
160 isOnBattery: boolean
161 waiting: Waiting | null
162 basketAt: (now: number) => ShaderParams['basket']
163 livelyAt: (now: number) => boolean
164}
165
166// What the session's shader is asked to draw beyond the scene.
167export type SkyExtras = { beacon: { mode: 'asking' | 'done'; isUrgent: boolean } | null; isYUp: boolean; stagePx: number }
168
169// The sky in Ghostty as it should be now: `shaderAt`'s shader while the
170// sky is not hidden, nothing otherwise, and Ghostty animating it as the
171// lighthouse, the battery and the person's being away say. Ghostty
172// reloads only when a file actually changed.
173//
174// Every session wants the same on or off (the hidden file and the person's
175// idleness are shared), so the include is checked against the disk and put
176// right whoever changed it. The shader's text is this session's own: it is
177// written when it changes here, the last session to change it winning.
178// One update at a time, again if asked for while one ran, and none once the
179// session has ended.
180export async function updateGhosttySky(
181 $: EngineInterface,
182 ghostty: Ghostty,
183 shaderAt: (now: number, extras: SkyExtras) => string,
184 state: () => SkyState,
185): Promise<void> {
186 const { written } = ghostty
187 if (written.isEnded) return
188 if (written.running !== null) {
189 written.isAgain = true
190 return
191 }
192 do {
193 written.isAgain = false
194 written.running = writeGhosttySky($, ghostty, shaderAt, state())
195 try {
196 await written.running
197 } finally {
198 written.running = null
199 }
200 } while (written.isAgain && !written.isEnded)
201}
202
203// What other live sessions wait on the person for: their waiting files,
204// counted only while their heartbeat is fresh.
205async function othersWaiting($: EngineInterface, ghostty: Ghostty, now: number): Promise<Waiting[]> {
206 const entries = await $.fs.list(ghostty.folder).catch(() => [])
207 const own = ghostty.waiting.slice(ghostty.folder.length + 1)
208 const alive = new Map(entries.filter(entry => entry.name.startsWith('alive-')).map(entry => [entry.name.slice(6), entry.mtimeMs]))
209 const waits: Waiting[] = []
210 for (const entry of entries) {
211 if (!entry.name.startsWith('waiting-') || entry.name === own) continue
212 const beat = alive.get(entry.name.slice(8))
213 if (beat === undefined || now - beat >= ALIVE_MS) continue
214 const wait = asWaiting(await $.fs.read(`${ghostty.folder}/${entry.name}`).catch(() => ''))
215 if (wait) waits.push(wait)
216 }
217
218 return waits
219}
220
221function asWaiting(text: string): Waiting | null {
222 try {
223 const value = JSON.parse(text) as unknown
224 const mode = field(value, 'mode')
225 const since = number(field(value, 'since'))
226
227 return (mode === 'asking' || mode === 'done') && since !== null ? { mode, since } : null
228 } catch {
229 return null
230 }
231}
232
233
234// Shares a change in what this session waits on the person for, and
235// updates the sky for it at once.
236export async function shareWaiting($: EngineInterface, ghostty: Ghostty | null, waiting: Waiting | null, refresh: (() => void) | null): Promise<void> {
237 if (ghostty === null) return
238 await writeWaiting($, ghostty, waiting).catch(() => undefined)
239 refresh?.()
240}
241
242// Writes what this session waits on the person for where others read it,
243// or takes it away.
244async function writeWaiting($: EngineInterface, ghostty: Ghostty, waiting: Waiting | null): Promise<void> {
245 if (waiting) await $.fs.write(ghostty.waiting, JSON.stringify(waiting))
246 else await $.process.run(['/bin/rm', '-f', ghostty.waiting]).catch(() => undefined)
247}
248
249async function writeGhosttySky(
250 $: EngineInterface,
251 ghostty: Ghostty,
252 shaderAt: (now: number, extras: SkyExtras) => string,
253 state: SkyState,
254): Promise<void> {
255 const { written } = ghostty
256 const now = await $.clock.now()
257 // The tab's mark when its basket changes, and again each minute: a
258 // `reset` in the tab would have cleared it.
259 const mark = markOf(state.basketAt(now))
260 if (mark !== written.mark || now - written.heartbeatAt >= HEARTBEAT_MS) {
261 await markTab($, mark)
262 written.mark = mark
263 }
264 if (now - written.heartbeatAt >= HEARTBEAT_MS) {
265 await $.fs.write(ghostty.heartbeat, String(now))
266 written.heartbeatAt = now
267 ghostty.isIncluded = await isIncluded($, ghostty)
268 }
269 const waits = [...(state.waiting ? [state.waiting] : []), ...(await othersWaiting($, ghostty, now))]
270 const beacon = beaconAt(waits, now)
271 const isHidden = await $.fs.exists(ghostty.hidden).catch(() => false)
272 const isWanted = !isHidden
273 let include = ''
274 if (isWanted) {
275 // Ghostty draws with OpenGL on Linux, where fragCoord has y up.
276 const extras = { beacon: beacon && { mode: beacon.mode, isUrgent: beacon.isUrgent }, isYUp: ghostty.isLinux, stagePx: ghostty.stagePx }
277 // Against the disk, not what this session last wrote: another session
278 // may have written the same (no reload) or something else (put right).
279 const shader = shaderAt(now, extras)
280 if (shader !== (await $.fs.read(ghostty.shader).catch(() => null))) {
281 await writeWhole($, ghostty.shader, shader, ghostty.id)
282 written.isReloadOwed = true
283 }
284 written.shader = shader
285 const animation = animationOf({ beacon, now, isAway: state.isAway, isOnBattery: state.isOnBattery, isLively: state.livelyAt(now) })
286 include = `custom-shader = ${ghostty.shader}\ncustom-shader-animation = ${animation}\n`
287 }
288 const onDisk = await $.fs.read(ghostty.include).catch(() => null)
289 if (include !== onDisk) {
290 await writeWhole($, ghostty.include, include, ghostty.id)
291 written.isReloadOwed = true
292 }
293 written.include = include
294 if (written.isReloadOwed) written.isReloadOwed = !(await reloadGhostty($, ghostty))
295}
296
297// The session is ending: its heartbeat goes, and the sky with it unless
298// another session is still alive. A /clear or a resume goes on in the same
299// process: the sky stays.
300export async function endGhosttySky($: EngineInterface, ghostty: Ghostty, reason: string): Promise<void> {
301 if (reason === 'clear' || reason === 'resume') return
302 ghostty.written.isEnded = true
303 await markTab($, UNMARK_TAB)
304 await ghostty.written.running?.catch(() => undefined)
305 const { folder, heartbeat, include } = ghostty
306 await $.process.run(['/bin/rm', '-f', heartbeat, ghostty.waiting]).catch(() => undefined)
307 const now = await $.clock.now()
308 const entries = await $.fs.list(folder).catch(() => [])
309 const isAnotherAlive = entries.some(
310 entry => entry.name.startsWith('alive-') && `${folder}/${entry.name}` !== heartbeat && now - entry.mtimeMs < ALIVE_MS,
311 )
312 if (!isAnotherAlive && (await $.fs.read(include).catch(() => '')) !== '') {
313 await writeWhole($, include, '', ghostty.id)
314 await reloadGhostty($, ghostty)
315 }
316}
317
318// The width in points of the stage strip: the window padding on the right
319// that terminal text never reaches, where the lighthouse stands.
320export const STAGE_PT = 240
321
322// The comment the mod writes above its include. The stage's margin came
323// later, and is given once, with this comment: from then on the padding is
324// the person's to change or take away, and stays as they leave it.
325const STAGED = '# Solar Clawd: the sky while a Claude session runs, and a stage for the lighthouse.'
326
327// The lines the mod itself added to a Ghostty config, from any folder it was
328// loaded from: its comment (now or as it was), the include of its runtime
329// file, and the padding that makes the stage strip.
330const OUR_LINES = [
331 /^# Solar Clawd: /,
332 /^config-file\s*=\s*\??.*\/runtime\/solar-clawd\.ghostty$/,
333 new RegExp(`^window-padding-x\\s*=\\s*\\d+\\s*,\\s*${STAGE_PT}$`),
334]
335
336// Whether a config was given the stage: the comment it came with, whatever
337// the person has since done with the padding.
338export async function hasOurStage($: EngineInterface, ghostty: Ghostty): Promise<boolean> {
339 for (const path of ghostty.configs) {
340 const text = await $.fs.read(path).catch(() => '')
341 if (text.split('\n').some(line => line.trim() === STAGED)) return true
342 }
343
344 return false
345}
346
347// Whether a config has an include line the mod added, wherever it points.
348export async function hasOurInclude($: EngineInterface, ghostty: Ghostty): Promise<boolean> {
349 for (const path of ghostty.configs) {
350 const text = await $.fs.read(path).catch(() => '')
351 if (text.split('\n').some(line => OUR_LINES[1]!.test(line.trim()))) return true
352 }
353
354 return false
355}
356
357// Where a Ghostty session keeps the sky's files: a folder of the person's
358// own, so the include line in Ghostty's config outlives the plugin's folder
359// (an installed plugin's moves with each update). It ends in /runtime, as
360// OUR_LINES expects.
361export function runtimeFolder(home: string, isOnLinux: boolean, xdgState: string | null): string {
362 if (isOnLinux) return `${xdgState ?? `${home}/.local/state`}/solar-clawd/runtime`
363
364 return `${home}/Library/Application Support/solar-clawd/runtime`
365}
366
367// Adds the one include line, under the mod's comment, to the config Ghostty
368// reads first, in place of any the mod added before; `?` keeps Ghostty quiet
369// should the file ever be missing. A config not yet given the stage gets
370// its padding after the include, keeping the left number of the person's
371// own window-padding-x (the last, which is the one Ghostty uses; else 2);
372// one given it before keeps its padding lines as the person left them.
373// Answers the line, the file, and whether the margin is new to the config;
374// null when the config is there but cannot be read: never written over.
375export async function includeInGhostty(
376 $: EngineInterface,
377 ghostty: Ghostty,
378): Promise<{ line: string; config: string; isMarginNew: boolean } | null> {
379 let config = ghostty.configs[0]!
380 // On Linux the file that is there, config.ghostty if neither is.
381 if (ghostty.isLinux) {
382 for (const path of ghostty.configs) {
383 if (await $.fs.exists(path).catch(() => false)) {
384 config = path
385 break
386 }
387 }
388 await $.process.run(['mkdir', '-p', config.slice(0, config.lastIndexOf('/'))]).catch(() => undefined)
389 }
390 const isThere = await $.fs.exists(config).catch(() => true)
391 const read = isThere ? await $.fs.read(config).catch(() => null) : ''
392 if (read === null) return null
393 const isStaged = read.split('\n').some(row => row.trim() === STAGED)
394 const hadMargin = read.split('\n').some(row => OUR_LINES[2]!.test(row.trim()))
395 const ours = isStaged ? OUR_LINES.slice(0, 2) : OUR_LINES
396 const rows = read.split('\n').filter(row => !ours.some(pattern => pattern.test(row.trim())))
397 const text = rows.join('\n')
398 const own = rows.map(row => /^window-padding-x\s*=\s*(\d+)/.exec(row.trim())).findLast(found => found !== null)
399 const padding = isStaged ? '' : `window-padding-x = ${own ? own[1] : '2'},${STAGE_PT}\n`
400 const line = `config-file = ?${ghostty.include}`
401 const gap = text === '' || text.endsWith('\n') ? '' : '\n'
402 await $.fs.write(config, `${text}${gap}${STAGED}\n${line}\n${padding}`)
403
404 return { line, config, isMarginNew: !isStaged && !hadMargin }
405}
406hooks/host.ts 61 lines1import type { EngineInterface } from 'claude-code'
2
3// What the computer says: which system it is, its power, the person's
4// idleness and the app in front.
5
6// Whether the session runs on Linux, as uname says; anything it does not
7// say plainly counts as the Mac.
8export async function isLinuxHost($: EngineInterface): Promise<boolean> {
9 const answer = await $.process.run(['uname', '-s'], { timeoutMs: 5_000 }).catch(() => null)
10
11 return answer?.stdout.trim() === 'Linux'
12}
13
14// Whether the computer runs on battery: on battery the Ghostty sky stands
15// still. Anything pmset (or Linux's power supplies) does not say plainly
16// counts as mains power.
17export async function isOnBattery($: EngineInterface, isOnLinux: boolean): Promise<boolean> {
18 try {
19 if (isOnLinux) {
20 const { stdout } = await $.process.run(['sh', '-c', 'cat /sys/class/power_supply/*/status 2>/dev/null'], { timeoutMs: 5_000 })
21
22 return stdout.split('\n').some(line => line.trim() === 'Discharging')
23 }
24 const { stdout } = await $.process.run(['/usr/bin/pmset', '-g', 'batt'], { timeoutMs: 5_000 })
25
26 return stdout.includes("'Battery Power'")
27 } catch {
28 return false
29 }
30}
31
32// How long since the person last touched a key or the mouse; null on
33// Linux, which has no one way to ask.
34export async function idleMs($: EngineInterface, isOnLinux: boolean): Promise<number | null> {
35 if (isOnLinux) return null
36 try {
37 const { stdout } = await $.process.run(['/usr/sbin/ioreg', '-c', 'IOHIDSystem', '-d', '4'], { timeoutMs: 5_000 })
38 const ns = /"HIDIdleTime" = (\d+)/.exec(stdout)?.[1]
39
40 return ns ? Number(ns) / 1e6 : null
41 } catch {
42 return null
43 }
44}
45
46// Whether Ghostty is the app in front, as lsappinfo says (it needs no
47// permissions); null when it could not say, as on Linux.
48export async function isGhosttyInFront($: EngineInterface, isOnLinux: boolean): Promise<boolean | null> {
49 if (isOnLinux) return null
50 try {
51 const { stdout: front } = await $.process.run(['/usr/bin/lsappinfo', 'front'], { timeoutMs: 5_000 })
52 if (front.trim() === '') return null
53 const { stdout } = await $.process.run(['/usr/bin/lsappinfo', 'info', '-only', 'bundleid', front.trim()], { timeoutMs: 5_000 })
54 const id = /bundleID="([^"]+)"/.exec(stdout)?.[1]
55
56 return id ? id === 'com.mitchellh.ghostty' : null
57 } catch {
58 return null
59 }
60}
61hooks/iss.ts 138 lines1// The International Space Station from its orbital elements (CelesTrak's
2// OMM JSON): where it stands from a place, and when it is the bright point
3// worth showing, high enough, the sky dark and the station in sunlight.
4//
5// A Kepler orbit with the Earth's flattening (J2) turning its plane and
6// perigee, and its decay from MEAN_MOTION_DOT: within a day of epoch it
7// keeps to SGP4 within a degree or so (tools/iss/check.sh), which a sky
8// needs, not a telescope.
9
10import { gmstDegrees, sunAt, sunDirection } from './astro'
11import type { Place } from './astro'
12import { domePoint, HORIZON } from './dome'
13import type { DomePoint } from './dome'
14
15export type Elements = {
16 epochMs: number
17 // Revolutions a day, and its change (half the rate, rev/day², as TLEs give it).
18 meanMotion: number
19 meanMotionDot: number
20 eccentricity: number
21 // Degrees.
22 inclination: number
23 raan: number
24 argPerigee: number
25 meanAnomaly: number
26}
27
28// Older elements than this show no ISS: they would put it in the wrong place.
29export const ELEMENTS_MAX_AGE_MS = 259_200_000
30
31const RAD = Math.PI / 180
32const MU = 398600.4418
33const EARTH_KM = 6378.137
34const J2 = 1.08262668e-3
35const FLATTENING = 0.00669438
36// sqrt(GM) in Earth radii per minute, as SGP4 has it.
37const XKE = 60 / Math.sqrt(EARTH_KM ** 3 / MU)
38
39function finite(value: unknown): number | null {
40 return typeof value === 'number' && Number.isFinite(value) ? value : null
41}
42
43// The ISS's elements from CelesTrak's answer, or null for anything else.
44export function elementsOf(json: unknown): Elements | null {
45 const rows: unknown[] = Array.isArray(json) ? json : [json]
46 const row = rows.find(r => typeof r === 'object' && r !== null && (r as Record<string, unknown>).NORAD_CAT_ID === 25544) as
47 | Record<string, unknown>
48 | undefined
49 if (!row || typeof row.EPOCH !== 'string') return null
50 // Milliseconds at most: not every engine reads microseconds.
51 const epochMs = Date.parse(`${row.EPOCH.replace(/(\.\d{3})\d*/, '$1')}Z`)
52 const values = [row.MEAN_MOTION, row.ECCENTRICITY, row.INCLINATION, row.RA_OF_ASC_NODE, row.ARG_OF_PERICENTER, row.MEAN_ANOMALY].map(finite)
53 if (!Number.isFinite(epochMs) || values.some(v => v === null)) return null
54 const [meanMotion, eccentricity, inclination, raan, argPerigee, meanAnomaly] = values as number[]
55
56 return { epochMs, meanMotion: meanMotion!, meanMotionDot: finite(row.MEAN_MOTION_DOT) ?? 0, eccentricity: eccentricity!, inclination: inclination!, raan: raan!, argPerigee: argPerigee!, meanAnomaly: meanAnomaly! }
57}
58
59// The station's position in equatorial coordinates (km) at `ms`. The
60// elements' mean motion is Kozai's, as SGP4 reads it: recovered to
61// Brouwer's first, so the J2 drift is not counted twice.
62function position(el: Elements, ms: number): [number, number, number] {
63 const days = (ms - el.epochMs) / 86_400_000
64 const minutes = days * 1440
65 const e = el.eccentricity
66 const i = el.inclination * RAD
67 const cos2 = Math.cos(i) ** 2
68 const beta2 = 1 - e * e
69 const beta = Math.sqrt(beta2)
70 const k2 = 0.5 * J2
71 const kozai = (el.meanMotion * 2 * Math.PI) / 1440
72 const a1 = (XKE / kozai) ** (2 / 3)
73 const d1 = (1.5 * k2 * (3 * cos2 - 1)) / (a1 * a1 * beta * beta2)
74 const a0 = a1 * (1 - d1 * (1 / 3 + d1 * (1 + (134 / 81) * d1)))
75 const d0 = (1.5 * k2 * (3 * cos2 - 1)) / (a0 * a0 * beta * beta2)
76 const n = kozai / (1 + d0)
77 const a = (a0 / (1 - d0)) * EARTH_KM
78 // The secular drifts, per minute.
79 const t1 = (3 * k2 * n) / (((a / EARTH_KM) * beta2) ** 2)
80 const raan = el.raan * RAD - t1 * Math.cos(i) * minutes
81 const argp = el.argPerigee * RAD - 0.5 * t1 * (1 - 5 * cos2) * minutes
82 const meanAnomaly = el.meanAnomaly * RAD + (n + 0.5 * t1 * beta * (3 * cos2 - 1)) * minutes + 2 * Math.PI * el.meanMotionDot * days * days
83 let E = meanAnomaly
84 for (let step = 0; step < 8; step++) E -= (E - e * Math.sin(E) - meanAnomaly) / (1 - e * Math.cos(E))
85 const nu = 2 * Math.atan2(Math.sqrt(1 + e) * Math.sin(E / 2), Math.sqrt(1 - e) * Math.cos(E / 2))
86 const r = a * (1 - e * Math.cos(E))
87 const u = argp + nu
88
89 return [
90 r * (Math.cos(raan) * Math.cos(u) - Math.sin(raan) * Math.sin(u) * Math.cos(i)),
91 r * (Math.sin(raan) * Math.cos(u) + Math.cos(raan) * Math.sin(u) * Math.cos(i)),
92 r * Math.sin(u) * Math.sin(i),
93 ]
94}
95
96// Where the station stands from `place` (degrees, azimuth from north), and
97// whether the sun lights it: out of the Earth's shadow, a cylinder behind it.
98export function issAt(el: Elements, ms: number, place: Place): { elevation: number; azimuth: number; isSunlit: boolean } {
99 const [x, y, z] = position(el, ms)
100 const theta = gmstDegrees(ms) * RAD
101 const sx = x * Math.cos(theta) + y * Math.sin(theta)
102 const sy = -x * Math.sin(theta) + y * Math.cos(theta)
103 const phi = place.lat * RAD
104 const lambda = place.lon * RAD
105 const N = EARTH_KM / Math.sqrt(1 - FLATTENING * Math.sin(phi) ** 2)
106 const rx = sx - N * Math.cos(phi) * Math.cos(lambda)
107 const ry = sy - N * Math.cos(phi) * Math.sin(lambda)
108 const rz = z - N * (1 - FLATTENING) * Math.sin(phi)
109 const south = Math.sin(phi) * Math.cos(lambda) * rx + Math.sin(phi) * Math.sin(lambda) * ry - Math.cos(phi) * rz
110 const east = -Math.sin(lambda) * rx + Math.cos(lambda) * ry
111 const up = Math.cos(phi) * Math.cos(lambda) * rx + Math.cos(phi) * Math.sin(lambda) * ry + Math.sin(phi) * rz
112 const range = Math.hypot(rx, ry, rz)
113 const sun = sunDirection(ms)
114 const along = x * sun[0] + y * sun[1] + z * sun[2]
115 const off = Math.hypot(x - along * sun[0], y - along * sun[1], z - along * sun[2])
116
117 return {
118 elevation: Math.asin(up / range) / RAD,
119 azimuth: ((Math.atan2(east, -south) / RAD) % 360 + 360) % 360,
120 isSunlit: along > 0 || off > EARTH_KM,
121 }
122}
123
124// The station on the dome while it is worth looking up for, with where it
125// was 20 s before (its trail); null otherwise (out of the window too), or
126// from missing or old elements.
127export function issVisible(el: Elements | null, ms: number, place: Place): { at: DomePoint; trail: DomePoint } | null {
128 if (!el || Math.abs(ms - el.epochMs) > ELEMENTS_MAX_AGE_MS) return null
129 const now = issAt(el, ms, place)
130 if (now.elevation <= 10 || !now.isSunlit || sunAt(ms, place.lat, place.lon).elevation >= -6) return null
131
132 // Beyond the window's top (low in the north on the dome) it is not shown.
133 const at = domePoint(now)
134 if (at.y < 0 || at.y > HORIZON) return null
135
136 return { at, trail: domePoint(issAt(el, ms - 20_000, place)) }
137}
138hooks/mac.ts 49 lines1// AppleScripts the mod runs on macOS: iTerm2's background and front, and
2// what plays.
3
4// Sets (or, given an empty path, clears) the background image of the one
5// iTerm2 session whose id is the first argument, and answers the size of its
6// window in points: "ok <width> <height>".
7export const SET_BACKGROUND = [
8 'on run argv',
9 'set targetId to item 1 of argv',
10 'set imagePath to item 2 of argv',
11 'tell application id "com.googlecode.iterm2"',
12 'repeat with w in windows',
13 'repeat with t in tabs of w',
14 'repeat with s in sessions of t',
15 'if (id of s) is targetId then',
16 'set background image of s to imagePath',
17 'set b to bounds of w',
18 'return "ok " & ((item 3 of b) - (item 1 of b)) & " " & ((item 4 of b) - (item 2 of b)) & " " & (frontmost as text)',
19 'end if',
20 'end repeat',
21 'end repeat',
22 'end repeat',
23 'end tell',
24 'return "missing"',
25 'end run',
26].flatMap(line => ['-e', line])
27
28// Whether iTerm2 is the app in front.
29export const ITERM_FRONTMOST = ['-e', 'tell application id "com.googlecode.iterm2" to return frontmost as text']
30
31// "title - artist" of what Spotify or Music plays, or nothing. Neither app is
32// started: each is asked only while it already runs, through `run script`
33// so its terms are not looked up unless it does.
34export const NOW_PLAYING = [
35 'set out to ""',
36 'try',
37 'repeat with appId in {"com.spotify.client", "com.apple.Music"}',
38 'if out is "" and application id appId is running then',
39 'set out to run script "tell application id \\"" & appId & "\\"\nif player state is playing then return name of current track & \\" - \\" & artist of current track\nreturn \\"\\"\nend tell"',
40 'end if',
41 'end repeat',
42 'end try',
43 'return out',
44].flatMap(line => ['-e', line])
45
46// The scale of the main screen (2 on a Retina display), for
47// `osascript -l JavaScript`: the pixels in a point.
48export const SCREEN_SCALE = ['-e', 'ObjC.import("AppKit"); $.NSScreen.mainScreen.backingScaleFactor']
49hooks/net.ts 187 lines1import type { EngineInterface } from 'claude-code'
2
3import type { Place } from './astro'
4import { isPrintable } from './clawd'
5import { elementsOf } from './iss'
6import type { Elements } from './iss'
7import { SKY_WORDS, skyOf } from './sky'
8import type { Weather } from './sky'
9
10// What the network says: where the computer is, the weather there, the
11// aurora's Kp index and the ISS's elements; and the store's memory of them.
12
13// A place guessed from the IP address is trusted this long before it is guessed again.
14const GUESS_MS = 12 * 3_600_000
15
16// $.store keys: a place the person chose, and the last guess with its time.
17export const CHOSEN_PLACE = 'place'
18export const GUESSED_PLACE = 'guessedPlace'
19// The ISS's elements as last fetched: { fetchedAt, json }.
20const ISS_ELEMENTS = 'iss'
21const ISS_URL = 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=json'
22// The ISS's elements are fetched at most this often.
23export const ISS_MS = 86_400_000
24
25type Json = Record<string, unknown>
26
27export function field(value: unknown, key: string): unknown {
28 return typeof value === 'object' && value !== null ? (value as Json)[key] : undefined
29}
30
31export function number(value: unknown): number | null {
32 return typeof value === 'number' && Number.isFinite(value) ? value : null
33}
34
35function asPlace(value: unknown): Place | null {
36 const name = field(value, 'name')
37 const lat = number(field(value, 'lat'))
38 const lon = number(field(value, 'lon'))
39
40 // A name from the network goes into cells and replies: printable and short.
41 return typeof name === 'string' && lat !== null && lon !== null ? { name: plain(name).slice(0, 40), lat, lon } : null
42}
43
44// The ISS's elements: as stored while under a day old, fetched again after,
45// the stored ones kept should the network fail.
46export async function issElements($: EngineInterface): Promise<Elements | null> {
47 const now = await $.clock.now()
48 const cached = (await $.store.get(ISS_ELEMENTS).catch(() => null)) as { fetchedAt?: unknown; json?: unknown } | null
49 const stored = cached ? elementsOf(cached.json) : null
50 if (stored && typeof cached?.fetchedAt === 'number' && now - cached.fetchedAt < ISS_MS) return stored
51 const json = await getJson($, ISS_URL)
52 const fresh = elementsOf(json)
53 if (!fresh) return stored
54 await $.store.set(ISS_ELEMENTS, { fetchedAt: now, json }).catch(() => undefined)
55
56 return fresh
57}
58
59async function getJson($: EngineInterface, url: string): Promise<unknown> {
60 try {
61 const response = await $.http.fetch(url)
62
63 return response.ok ? (JSON.parse(response.text) as unknown) : null
64 } catch {
65 return null
66 }
67}
68
69// Where this machine seems to be, from its IP address: a town, roughly.
70async function guessPlace($: EngineInterface): Promise<Place | null> {
71 const info = await getJson($, 'https://ipinfo.io/json')
72 const city = field(info, 'city')
73 const [lat, lon] = String(field(info, 'loc') ?? '').split(',').map(Number)
74 if (typeof city === 'string' && Number.isFinite(lat) && Number.isFinite(lon)) {
75 return asPlace({ name: city, lat, lon })
76 }
77
78 const who = await getJson($, 'https://ipwho.is/')
79 const fallback = {
80 name: field(who, 'city'),
81 lat: field(who, 'latitude'),
82 lon: field(who, 'longitude'),
83 }
84
85 return field(who, 'success') === true ? asPlace(fallback) : null
86}
87
88export async function findPlace($: EngineInterface, name: string): Promise<Place | null> {
89 const url = `https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&format=json&name=${encodeURIComponent(name)}`
90 const first = (field(await getJson($, url), 'results') as unknown[] | undefined)?.[0]
91
92 return asPlace({
93 name: field(first, 'name'),
94 lat: field(first, 'latitude'),
95 lon: field(first, 'longitude'),
96 })
97}
98
99// The weather now and the place's UTC offset, which carries its summer time.
100async function fetchWeather(
101 $: EngineInterface,
102 place: Place,
103): Promise<{ weather: Weather; offsetMs: number } | null> {
104 const url = `https://api.open-meteo.com/v1/forecast?latitude=${place.lat}&longitude=${place.lon}¤t=temperature_2m,weather_code,cloud_cover,wind_speed_10m&wind_speed_unit=ms&timezone=auto`
105 const body = await getJson($, url)
106 const current = field(body, 'current')
107 const temperature = number(field(current, 'temperature_2m'))
108 const code = number(field(current, 'weather_code'))
109 const cloudCover = number(field(current, 'cloud_cover'))
110 const wind = number(field(current, 'wind_speed_10m'))
111 const offset = number(field(body, 'utc_offset_seconds'))
112 if (temperature === null || code === null || cloudCover === null || wind === null || offset === null) {
113 return null
114 }
115
116 return { weather: { temperature, code, cloudCover, wind }, offsetMs: offset * 1000 }
117}
118
119// The planetary Kp index (0 to 9) over the last few minutes, from NOAA.
120async function fetchKp($: EngineInterface): Promise<number | null> {
121 const rows = await getJson($, 'https://services.swpc.noaa.gov/json/planetary_k_index_1m.json')
122 if (!Array.isArray(rows)) return null
123 const recent = rows.slice(-10).map(row => number(field(row, 'estimated_kp')) ?? 0)
124
125 return recent.length > 0 ? Math.max(...recent) : null
126}
127
128// Where the sky is and what it is doing there: Stockholm and Swedish time
129// until a place and its weather come in.
130export type Where = {
131 place: Place
132 isChosen: boolean
133 weather: Weather | null
134 offsetMs: number | undefined
135 kp: number | null
136 // When it last rained there, for a rainbow after.
137 lastRainAt: number | null
138}
139
140export async function refreshWeather($: EngineInterface, where: Where): Promise<void> {
141 const [fetched, kp] = await Promise.all([fetchWeather($, where.place), fetchKp($)])
142 if (fetched) {
143 where.weather = fetched.weather
144 where.offsetMs = fetched.offsetMs
145 const sky = skyOf(fetched.weather.code)
146 if (sky === 'drizzle' || sky === 'rain' || sky === 'thunder') where.lastRainAt = await $.clock.now()
147 }
148 if (kp !== null) where.kp = kp
149}
150
151// A chosen place wins; else a recent guess; else a new guess.
152export async function settlePlace($: EngineInterface, where: Where): Promise<void> {
153 const chosen = asPlace(await $.store.get(CHOSEN_PLACE))
154 where.isChosen = chosen !== null
155 if (chosen) {
156 where.place = chosen
157 } else {
158 const now = await $.clock.now()
159 const cached = await $.store.get(GUESSED_PLACE)
160 const guessed = asPlace(field(cached, 'place'))
161 const at = number(field(cached, 'at')) ?? 0
162 if (guessed && now - at < GUESS_MS) {
163 where.place = guessed
164 } else {
165 const fresh = await guessPlace($)
166 if (fresh) {
167 where.place = fresh
168 await $.store.set(GUESSED_PLACE, { place: fresh, at: now })
169 }
170 }
171 }
172 await refreshWeather($, where)
173}
174
175export function placeText({ place, isChosen, weather }: Where): string {
176 const guessed = isChosen ? '' : ' (guessed from your IP address)'
177 const now = weather ? `, ${Math.round(weather.temperature)}°C ${SKY_WORDS[skyOf(weather.code)]}` : ''
178
179 return `${place.name}${guessed}${now}`
180}
181
182// Text as the band can draw it: one cell per printable character, so no
183// control character from a track or a place name reaches the terminal.
184export function plain(text: string): string {
185 return [...text].map(ch => (isPrintable(ch.codePointAt(0)!) ? ch : '?')).join('')
186}
187hooks/paint.ts 434 lines1// Colour, noise, a pixel canvas and the encodings that leave this module:
2// base64, and a PNG built without a compressor.
3
4export function clamp(v: number, lo = 0, hi = 1): number {
5 return Math.min(hi, Math.max(lo, v))
6}
7
8export function smoothstep(lo: number, hi: number, v: number): number {
9 const t = clamp((v - lo) / (hi - lo))
10
11 return t * t * (3 - 2 * t)
12}
13
14export function mix(a: number, b: number, t: number): number {
15 const ch = (shift: number) => {
16 const x = (a >> shift) & 0xff
17 const y = (b >> shift) & 0xff
18
19 return Math.round(x + (y - x) * t) << shift
20 }
21
22 return ch(16) | ch(8) | ch(0)
23}
24
25export function scale(c: number, k: number): number {
26 return mix(0, c, k)
27}
28
29export function luminance(c: number): number {
30 return (
31 (0.2126 * ((c >> 16) & 0xff) +
32 0.7152 * ((c >> 8) & 0xff) +
33 0.0722 * (c & 0xff)) /
34 255
35 )
36}
37
38export function grey(c: number): number {
39 const v = Math.round(luminance(c) * 255)
40
41 return (v << 16) | (v << 8) | v
42}
43
44// Pushes a colour away from its grey (k > 1) or toward it (k < 1).
45export function saturate(c: number, k: number): number {
46 const g = grey(c) & 0xff
47 const ch = (shift: number) => {
48 const x = (c >> shift) & 0xff
49
50 return Math.round(clamp(g + (x - g) * k, 0, 255)) << shift
51 }
52
53 return ch(16) | ch(8) | ch(0)
54}
55
56export function hash(n: number): number {
57 let x = Math.imul(n | 0, 0x2c1b3c6d)
58 x ^= x >>> 12
59 x = Math.imul(x, 0x297a2d39)
60 x ^= x >>> 15
61
62 return x >>> 0
63}
64
65// A repeatable number in [0, 1) for a seed.
66export function rand(seed: number): number {
67 return hash(seed) / 4294967296
68}
69
70// Smooth 1D value noise in [0, 1].
71export function noise(x: number, seed: number): number {
72 const i = Math.floor(x)
73 const a = rand(i * 374761 + seed * 668265)
74 const b = rand((i + 1) * 374761 + seed * 668265)
75
76 return a + (b - a) * smoothstep(0, 1, x - i)
77}
78
79export class Canvas {
80 readonly w: number
81 readonly h: number
82 readonly px: Uint32Array
83 // 1 where something stands in front of the sky.
84 readonly solid: Uint8Array
85
86 constructor(w: number, h: number) {
87 this.w = w
88 this.h = h
89 this.px = new Uint32Array(w * h)
90 this.solid = new Uint8Array(w * h)
91 }
92
93 isSolid(x: number, y: number): boolean {
94 return this.solid[y * this.w + x] === 1
95 }
96
97 get(x: number, y: number): number {
98 return this.px[y * this.w + x] ?? 0
99 }
100
101 // Sky: paints without standing in front of it.
102 paint(x: number, y: number, c: number): void {
103 if (x >= 0 && y >= 0 && x < this.w && y < this.h) this.px[y * this.w + x] = c
104 }
105
106 set(x: number, y: number, c: number): void {
107 x = Math.round(x)
108 y = Math.round(y)
109 if (x >= 0 && y >= 0 && x < this.w && y < this.h) {
110 this.px[y * this.w + x] = c
111 this.solid[y * this.w + x] = 1
112 }
113 }
114
115 blend(x: number, y: number, c: number, a: number): void {
116 x = Math.round(x)
117 y = Math.round(y)
118 if (x >= 0 && y >= 0 && x < this.w && y < this.h && a > 0) {
119 this.set(x, y, mix(this.get(x, y), c, clamp(a)))
120 }
121 }
122
123 // Like blend, but leaves the pixel sky: for what is part of the sky
124 // itself (stars, clouds, aurora on the wallpaper).
125 glaze(x: number, y: number, c: number, a: number): void {
126 x = Math.round(x)
127 y = Math.round(y)
128 if (x >= 0 && y >= 0 && x < this.w && y < this.h && a > 0) {
129 this.paint(x, y, mix(this.get(x, y), c, clamp(a)))
130 }
131 }
132
133 // Rows of characters, each looked up in the palette; '.' and unknown
134 // characters are see-through. `light` dims everything but the glow keys.
135 sprite(
136 x0: number,
137 y0: number,
138 rows: readonly string[],
139 palette: Record<string, number>,
140 light = 1,
141 glows = '',
142 ): void {
143 rows.forEach((row, dy) => {
144 for (let dx = 0; dx < row.length; dx++) {
145 const ch = row[dx]!
146 const c = palette[ch]
147 if (c === undefined) continue
148 this.set(x0 + dx, y0 + dy, glows.includes(ch) ? c : scale(c, light))
149 }
150 })
151 }
152}
153
154const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
155
156// The runtime's own encoder where it has one: a wallpaper is megabytes.
157type Base64Bytes = Uint8Array & { toBase64?: () => string }
158
159export function toBase64(bytes: Uint8Array): string {
160 const native = (bytes as Base64Bytes).toBase64
161 if (typeof native === 'function') return native.call(bytes)
162
163 // Character codes straight into a byte array, decoded once: no string
164 // per character.
165 const out = new Uint8Array(Math.ceil(bytes.length / 3) * 4)
166 let at = 0
167 for (let i = 0; i < bytes.length; i += 3) {
168 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
169 out[at] = B64_CODES[(n >> 18) & 63]!
170 out[at + 1] = B64_CODES[(n >> 12) & 63]!
171 out[at + 2] = i + 1 < bytes.length ? B64_CODES[(n >> 6) & 63]! : 61
172 out[at + 3] = i + 2 < bytes.length ? B64_CODES[n & 63]! : 61
173 at += 4
174 }
175
176 return new TextDecoder().decode(out)
177}
178
179const B64_CODES = Uint8Array.from(B64, ch => ch.charCodeAt(0))
180
181const CRC_TABLE = (() => {
182 const table = new Uint32Array(256)
183 for (let n = 0; n < 256; n++) {
184 let c = n
185 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
186 table[n] = c >>> 0
187 }
188
189 return table
190})()
191
192function crc32(bytes: Uint8Array): number {
193 let c = 0xffffffff
194 for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff]! ^ (c >>> 8)
195
196 return (c ^ 0xffffffff) >>> 0
197}
198
199function chunk(type: string, data: Uint8Array): Uint8Array {
200 const out = new Uint8Array(12 + data.length)
201 const view = new DataView(out.buffer)
202 view.setUint32(0, data.length)
203 for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
204 out.set(data, 8)
205 view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)))
206
207 return out
208}
209
210// A PNG's raw rows, RGB, each led by filter 1 ("Sub": every byte minus the
211// one a pixel to its left), filled a band of rows at a time so a large
212// picture can be built between other work. A sky's rows are nearly flat
213// left to right, so they become runs of zeros that compress to almost nothing.
214export function pngRows(canvas: Canvas): Uint8Array {
215 return new Uint8Array(canvas.h * (canvas.w * 3 + 1))
216}
217
218export function fillPngRows(
219 canvas: Canvas,
220 rows: Uint8Array,
221 from: number,
222 to: number,
223 color: (c: number) => number = c => c,
224): void {
225 const { w } = canvas
226 for (let y = from; y < Math.min(to, canvas.h); y++) {
227 const row = y * (w * 3 + 1)
228 rows[row] = 1
229 let last = 0
230 for (let x = 0; x < w; x++) {
231 const c = color(canvas.px[y * w + x] ?? 0)
232 rows[row + 1 + x * 3] = (((c >> 16) & 0xff) - ((last >> 16) & 0xff)) & 0xff
233 rows[row + 2 + x * 3] = (((c >> 8) & 0xff) - ((last >> 8) & 0xff)) & 0xff
234 rows[row + 3 + x * 3] = ((c & 0xff) - (last & 0xff)) & 0xff
235 last = c
236 }
237 }
238}
239
240// Bits written least significant first, as deflate packs them.
241class BitWriter {
242 bytes: Uint8Array
243 at = 0
244 bit = 0
245 value = 0
246
247 constructor(size: number) {
248 this.bytes = new Uint8Array(size)
249 }
250
251 write(value: number, count: number): void {
252 this.value |= value << this.bit
253 this.bit += count
254 while (this.bit >= 8) {
255 if (this.at >= this.bytes.length) {
256 const grown = new Uint8Array(this.bytes.length * 2)
257 grown.set(this.bytes)
258 this.bytes = grown
259 }
260 this.bytes[this.at++] = this.value & 0xff
261 this.value >>>= 8
262 this.bit -= 8
263 }
264 }
265
266 // A Huffman code goes most significant bit first.
267 code(value: number, count: number): void {
268 let reversed = 0
269 for (let i = 0; i < count; i++) reversed |= ((value >> i) & 1) << (count - 1 - i)
270 this.write(reversed, count)
271 }
272
273 finish(): Uint8Array {
274 if (this.bit > 0) this.write(0, 8 - this.bit)
275
276 return this.bytes.subarray(0, this.at)
277 }
278}
279
280const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
281const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
282
283// The fixed Huffman code of a literal or length symbol (RFC 1951, 3.2.6).
284function symbol(out: BitWriter, n: number): void {
285 if (n < 144) out.code(0x30 + n, 8)
286 else if (n < 256) out.code(0x190 + n - 144, 9)
287 else if (n < 280) out.code(n - 256, 7)
288 else out.code(0xc0 + n - 280, 8)
289}
290
291// One fixed-Huffman deflate block whose only matches are runs: a byte, then
292// "the same again, n times" at distance 1. Crude, and plenty for a sky.
293function deflate(data: Uint8Array): Uint8Array {
294 const out = new BitWriter(Math.max(1024, data.length >> 3))
295 out.write(1, 1)
296 out.write(1, 2)
297 let i = 0
298 while (i < data.length) {
299 const b = data[i]!
300 let run = 0
301 if (i > 0 && data[i - 1] === b) {
302 while (run < 258 && i + run < data.length && data[i + run] === b) run++
303 }
304 if (run >= 3) {
305 let code = 0
306 while (code < 28 && LENGTH_BASE[code + 1]! <= run) code++
307 symbol(out, 257 + code)
308 out.write(run - LENGTH_BASE[code]!, LENGTH_EXTRA[code]!)
309 out.code(0, 5)
310 i += run
311 } else {
312 symbol(out, b)
313 i++
314 }
315 }
316 symbol(out, 256)
317
318 return out.finish()
319}
320
321function adler32(data: Uint8Array): number {
322 let a = 1
323 let d = 0
324 for (let i = 0; i < data.length; i++) {
325 a += data[i]!
326 d += a
327 // Reduce now and then, not every byte: a and d stay exact below 2^53.
328 if ((i & 4095) === 4095) {
329 a %= 65521
330 d %= 65521
331 }
332 }
333
334 return (((d % 65521) << 16) | (a % 65521)) >>> 0
335}
336
337// The PNG around filled rows.
338export function pngOf(width: number, height: number, rows: Uint8Array): Uint8Array {
339 const body = deflate(rows)
340 const zlib = new Uint8Array(2 + body.length + 4)
341 zlib[0] = 0x78
342 zlib[1] = 0x01
343 zlib.set(body, 2)
344 new DataView(zlib.buffer).setUint32(2 + body.length, adler32(rows))
345
346 const header = new Uint8Array(13)
347 const view = new DataView(header.buffer)
348 view.setUint32(0, width)
349 view.setUint32(4, height)
350 header.set([8, 2, 0, 0, 0], 8)
351
352 const chunks = [chunk('IHDR', header), chunk('IDAT', zlib), chunk('IEND', new Uint8Array(0))]
353 const out = new Uint8Array(8 + chunks.reduce((n, c) => n + c.length, 0))
354 out.set([137, 80, 78, 71, 13, 10, 26, 10])
355 let offset = 8
356 for (const c of chunks) {
357 out.set(c, offset)
358 offset += c.length
359 }
360
361 return out
362}
363
364export function png(canvas: Canvas, color: (c: number) => number = c => c): Uint8Array {
365 const rows = pngRows(canvas)
366 fillPngRows(canvas, rows, 0, canvas.h, color)
367
368 return pngOf(canvas.w, canvas.h, rows)
369}
370
371type Base64Decoder = { fromBase64?: (text: string) => Uint8Array }
372
373export function fromBase64(text: string): Uint8Array {
374 const native = (Uint8Array as unknown as Base64Decoder).fromBase64
375 if (typeof native === 'function') return native(text)
376
377 const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
378 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
379 let bits = 0
380 let value = 0
381 let at = 0
382 for (const ch of clean) {
383 value = (value << 6) | B64.indexOf(ch)
384 bits += 6
385 if (bits >= 8) {
386 bits -= 8
387 out[at++] = (value >> bits) & 0xff
388 }
389 }
390
391 return out
392}
393
394// --- Risograph ---------------------------------------------------------------
395
396// The inks of a risograph, as the visual folder prints with them.
397export const INKS = {
398 blue: 0x0078bf,
399 pink: 0xff48b0,
400 yellow: 0xffe800,
401 green: 0x00a95c,
402 orange: 0xff6c2f,
403 violet: 0x765ba7,
404 indigo: 0x2e3192,
405} as const
406
407// Each ink is screened at its own angle, as on the drum.
408export const SCREEN_ANGLES = {
409 blue: 15,
410 pink: 75,
411 yellow: 0,
412 green: 45,
413 orange: 45,
414 violet: 30,
415 indigo: 60,
416} as const
417
418// How much of the point (x, y) a halftone dot covers, 0 to 1: dots on a
419// lattice `pitch` apart, turned to `angle` degrees, each as large as `tone`
420// asks (0 none, 1 solid), their edges antialiased.
421export function screen(x: number, y: number, tone: number, pitch: number, angle: number): number {
422 if (tone <= 0.01) return 0
423 if (tone >= 0.99) return 1
424 const a = (angle * Math.PI) / 180
425 const u = (x * Math.cos(a) + y * Math.sin(a)) / pitch
426 const v = (-x * Math.sin(a) + y * Math.cos(a)) / pitch
427 const du = u - Math.round(u)
428 const dv = v - Math.round(v)
429 const distance = Math.sqrt(du * du + dv * dv) * pitch
430 const radius = pitch * Math.sqrt(tone / Math.PI) * 1.1
431
432 return clamp(radius - distance + 0.5)
433}
434hooks/shader.ts 426 lines1// The sky as a Ghostty custom shader: the wallpaper of sky.ts drawn on the
2// GPU every frame, so the clouds glide and nothing is written per frame.
3// Ghostty gives a shader no date (`iTime` only, and it runs on across a
4// reload), so the scene is baked in as constants and the shader is written
5// again, and Ghostty told to reload, when they change.
6
7import { horizontalAt, localMidnight, localTime, moonAt, sunAt } from './astro'
8import { activityOn } from './clawd'
9import { chartAt, tileLines, tileStars } from './chart'
10import type { ChartStar } from './chart'
11import { domePoint } from './dome'
12import type { DomePoint } from './dome'
13import type { ActiveEvent, EventKind } from './events'
14import { auroraThreshold, bodyAt, greyness, RARE_SHAPES, skyOf } from './sky'
15import type { SkyInput } from './sky'
16import { clamp, INKS, rand } from './paint'
17import { COMMON } from './glsl/common'
18import { SKY } from './glsl/sky'
19import { CLOUDS } from './glsl/clouds'
20import { SEA } from './glsl/sea'
21import { EVENTS } from './glsl/events'
22import { KEEPER } from './glsl/keeper'
23import { SURF } from './glsl/surf'
24import { NIGHT } from './glsl/night'
25import { ROUTINE } from './glsl/routine'
26import { UFO } from './glsl/ufo'
27import { BASKET } from './glsl/basket'
28import { MAIN } from './glsl/main'
29
30// Where the sky stands at one moment.
31export type Scene = {
32 // The sun's elevation in degrees.
33 elevation: number
34 // The sun's (by day) or the moon's (by night) way along the arc, 0 to 1.
35 progress: number
36 isDay: boolean
37}
38
39export type ShaderParams = {
40 scene: Scene
41 // The real stars and constellation lines on the dome; empty by day.
42 chart: { stars: ChartStar[]; lines: [DomePoint, DomePoint][] }
43 // The lighthouse: lit while Claude waits (orange asking, white done),
44 // hurrying once a question has waited.
45 beacon: { mode: 'asking' | 'done'; isUrgent: boolean } | null
46 // The wastebasket in the bottom right corner: balls of paper, a tenth of
47 // the context window each, and a fire while the conversation compacts
48 // (embers just after); null where the band keeps its own.
49 basket: { balls: number; fire: 'burn' | 'embers' | null } | null
50 // The ISS on a visible pass, where it is and was 20 s before; null otherwise.
51 iss: { at: DomePoint; trail: DomePoint } | null
52 // Where a comet up now hangs on the dome, and the angle its tails point
53 // (away from the sun, in the window's own axes); null with no comet.
54 comet: { at: DomePoint; away: number } | null
55 // Where the moon is on the dome, null while it is below the horizon.
56 moon: { illumination: number; isWaxing: boolean; at: DomePoint | null }
57 // A whole day for /solar demo: scenes evenly through it, played in `seconds`.
58 demo: { scenes: Scene[]; seconds: number } | null
59 // How grey the weather makes the sky, cloud cover and wind (m/s).
60 cloudiness: number
61 cover: number
62 wind: number
63 precipitation: 'none' | 'drizzle' | 'rain' | 'thunder' | 'snow'
64 // Aurora as strong as the Kp index and the clear sky allow, before the
65 // night does; `auroraFloor` keeps a forced preview visible at dusk.
66 aurora: number
67 auroraFloor: number
68 // Events up now, each looped over its length (Ghostty's iTime cannot say
69 // when one began), and shooting stars an hour.
70 events: { kind: EventKind; seed: number; seconds: number }[]
71 shootingRate: number
72 // New Year's fireworks: how much of the sky they fill, 0 to 1.
73 fireworks: number
74 // The keeper at the lighthouse: asleep when Clawd is, and a preview forced.
75 keeper: { isAsleep: boolean; force: 'none' | 'ufo' | 'surf' }
76 // Ghostty's fragCoord has y up (OpenGL, Linux) rather than down (Metal).
77 isYUp: boolean
78 // The width of the strip at the right where the lighthouse stands, in
79 // pixels; 0 while it is not known.
80 stagePx: number
81}
82
83// A tab's mark, palette colour 16 as the session sets it (register.tsx,
84// markTab): blue one step off black says Claude runs there, green counts
85// the balls in its wastebasket and red says what burns (1 a fire, 2
86// embers). Per tab, where the shader's text is one for all of them.
87export function tabMark(basket: ShaderParams['basket']): [number, number, number] {
88 const balls = Math.max(0, Math.min(10, Math.round(basket?.balls ?? 0)))
89 const fire = basket?.fire === 'burn' ? 1 : basket?.fire === 'embers' ? 2 : 0
90
91 return [fire, balls, 1]
92}
93
94// The shader's number for each event.
95const EVENT_INDEX: Record<EventKind, number> = {
96 satellite: 1,
97 balloon: 2,
98 birds: 3,
99 plane: 4,
100 rainbow: 5,
101 kite: 6,
102 leaves: 7,
103 blossom: 8,
104 comet: 9,
105 constellation: 10,
106 milestone: 11,
107 shootingStar: 12,
108 witch: 13,
109 feathers: 14,
110 flag: 15,
111 maypole: 16,
112 luciaTrain: 17,
113 christmasStar: 18,
114 sleigh: 19,
115 fireworks: 20,
116 dolphin: 21,
117 pinkDolphin: 22,
118 // The shader schedules these itself; they are never drawn as events.
119 abduction: 23,
120 surf: 24,
121}
122
123// Scenes are worked out on this grid, so the shader's text (and with it a
124// reload) changes about every two minutes.
125export const SCENE_MS = 120_000
126// Scenes in a demo day.
127const DEMO_SCENES = 96
128
129function round(v: number, step: number): number {
130 return Math.round(v / step) * step
131}
132
133// A comet's place among the stars, from its seed: high in a northern sky.
134// Below the horizon it is held just above it, so a preview always shows it.
135function cometAt(seed: number, ms: number, place: SkyInput['place']): { at: DomePoint; away: number } {
136 const sky = horizontalAt(rand(seed) * 360, 45 + 30 * rand(seed + 1), ms, place.lat, place.lon)
137 const raw = domePoint({ elevation: Math.max(sky.elevation, 4), azimuth: sky.azimuth })
138 const at = { x: raw.x, y: Math.max(raw.y, 0.05) }
139 const sun = domePoint(sunAt(ms, place.lat, place.lon))
140
141 return { at: dome(at), away: round(Math.atan2(at.y - sun.y, at.x - sun.x), 0.01) }
142}
143
144// A dome point as the shader takes it: fine enough that nothing jumps.
145function dome(at: DomePoint): DomePoint {
146 return { x: round(at.x, 0.0005), y: round(at.y, 0.0005) }
147}
148
149export function sceneAt(ms: number, place: SkyInput['place']): Scene {
150 const sun = sunAt(ms, place.lat, place.lon)
151 const body = bodyAt(ms, place)
152
153 return { elevation: round(sun.elevation, 0.1), progress: round(body.progress, 0.001), isDay: body.isDay }
154}
155
156// The shader's constants for a moment, `demo` playing the place's whole day
157// from local midnight instead.
158export function shaderParams(
159 input: SkyInput & { offsetMs?: number },
160 options: {
161 demo?: { seconds: number; offsetMs?: number } | null
162 events?: readonly ActiveEvent[]
163 shootingRate?: number
164 beacon?: ShaderParams['beacon']
165 iss?: ShaderParams['iss']
166 basket?: ShaderParams['basket']
167 isYUp?: boolean
168 stagePx?: number
169 },
170): ShaderParams {
171 const { place, weather, kp, isAuroraForced } = input
172 const ms = round(input.ms, SCENE_MS)
173 const moon = moonAt(ms, place.lat, place.lon)
174 const sky = weather ? skyOf(weather.code) : 'clear'
175 const clear = 1 - greyness(weather)
176 const over = kp === null || kp === undefined ? -Infinity : kp - auroraThreshold(place.lat) + 1
177 let demo: ShaderParams['demo'] = null
178 if (options.demo) {
179 const midnight = localMidnight(ms, options.demo.offsetMs)
180 const scenes: Scene[] = []
181 for (let i = 0; i < DEMO_SCENES; i++) scenes.push(sceneAt(midnight + (i * 86_400_000) / DEMO_SCENES, place))
182 demo = { scenes, seconds: options.demo.seconds }
183 }
184
185 // The stars where they stand (in a demo, where they stood as it began);
186 // by day there are none to show, and the shader stays short.
187 const scene = demo ? demo.scenes[0]! : sceneAt(ms, place)
188 const chart = demo || scene.elevation < -4 ? chartAt(ms, place) : { stars: [], lines: [] }
189 const comet = options.events?.find(event => event.kind === 'comet')
190
191 return {
192 // In a demo the day plays from its table: a fixed scene keeps the text,
193 // and with it Ghostty, still for the demo's length.
194 scene,
195 beacon: options.beacon ?? null,
196 basket: options.basket ?? null,
197 isYUp: !!options.isYUp,
198 stagePx: Math.max(0, Math.round(options.stagePx ?? 0)),
199 iss: options.iss ? { at: dome(options.iss.at), trail: dome(options.iss.trail) } : null,
200 comet: comet ? cometAt(comet.seed, ms, place) : null,
201 chart: {
202 stars: chart.stars.map(star => ({ ...dome(star), mag: star.mag, bv: star.bv })),
203 lines: chart.lines.map(([a, b]) => [dome(a), dome(b)] as [DomePoint, DomePoint]),
204 },
205 moon: { illumination: round(moon.illumination, 0.01), isWaxing: moon.isWaxing, at: moon.elevation > 0 ? dome(domePoint(moon)) : null },
206 demo,
207 cloudiness: round(greyness(weather), 0.01),
208 cover: weather ? round(weather.cloudCover / 100, 0.01) : 0.3,
209 wind: weather ? round(weather.wind, 0.5) : 3,
210 precipitation: sky === 'drizzle' || sky === 'rain' || sky === 'thunder' || sky === 'snow' ? sky : 'none',
211 aurora: isAuroraForced ? 0.9 : round(clamp(over / 3) * clear, 0.01),
212 auroraFloor: isAuroraForced ? 0.4 : 0,
213 // A shooting star asked for is a shower: one every few seconds.
214 events: (options.events ?? [])
215 .filter(event => event.kind !== 'shootingStar' && event.kind !== 'abduction' && event.kind !== 'surf')
216 .slice(0, 4)
217 .map(event => ({ kind: event.kind, seed: event.seed, seconds: Math.round(event.durationMs / 1000) })),
218 shootingRate: options.events?.some(event => event.kind === 'shootingStar') ? 900 : options.shootingRate ?? 0,
219 keeper: {
220 isAsleep: activityOn(localTime(ms, input.offsetMs)) === 'sleep',
221 force: options.events?.some(event => event.kind === 'abduction') ? 'ufo' : options.events?.some(event => event.kind === 'surf') ? 'surf' : 'none',
222 },
223 fireworks: round(options.events?.find(event => event.kind === 'fireworks')?.progress ?? 0, 0.05),
224 }
225}
226
227// --- GLSL ---------------------------------------------------------------------
228
229function f(v: number): string {
230 const text = Number.isFinite(v) ? v.toFixed(4).replace(/0+$/, '').replace(/\.$/, '.0') : '0.0'
231
232 return text === '-0.0' ? '0.0' : text
233}
234
235function rgb(c: number): string {
236 return `vec3(${f(((c >> 16) & 0xff) / 255)}, ${f(((c >> 8) & 0xff) / 255)}, ${f((c & 0xff) / 255)})`
237}
238
239function floats(values: number[]): string {
240 return `float[${values.length}](${values.map(f).join(', ')})`
241}
242
243const PRECIPITATION = { none: 0, drizzle: 1, rain: 2, thunder: 3, snow: 4 } as const
244
245// The gradient's stops, as sky.ts has them: sun elevation, then top,
246// middle and horizon.
247const STOPS: readonly [number, number, number, number][] = [
248 [-90, 0x05070f, 0x0a0f22, 0x111a36],
249 [-18, 0x070b1c, 0x0f1733, 0x1a2550],
250 [-12, 0x0c1430, 0x1d2552, 0x3a3772],
251 [-6, 0x16205a, 0x433f86, 0x9b5b8e],
252 [-3, 0x223276, 0x6d4f95, 0xd9708a],
253 [0, 0x2f4a92, 0x9a6aa6, 0xf59a78],
254 [3, 0x3b66b0, 0xc48fb0, 0xffbf86],
255 [8, 0x3f7cc8, 0x8fb5e0, 0xf7d6b4],
256 [15, 0x3585d8, 0x79b4ea, 0xd6e8f6],
257 [30, 0x2a7ad8, 0x66aaf0, 0xbfe0fa],
258 [90, 0x1f6ed6, 0x58a2f0, 0xb0d8fb],
259]
260
261// The colours Claude Code fills its own fields with in its dark themes, as
262// 2.1.310's CLI has them (dark, then what dark-daltonized has of its own):
263// a diff's added and removed rows, dimmed and not, and the brighter
264// backgrounds of the words changed in them; the prompt's rows, hovered and
265// not; the shell's rows; the # of a memory; the composer's sidebar; and a
266// tool's diff as color-diff highlights it, which has rows and words of its
267// own in truecolor. Glass lets the sky through these and nothing else: the
268// band's pixel art, text and any other colour stay solid.
269export const FIELD_COLOURS: readonly number[] = [
270 0x225c2b, // diffAdded
271 0x7a2936, // diffRemoved
272 0x47584a, // diffAddedDimmed
273 0x69484d, // diffRemovedDimmed
274 0x38a660, // diffAddedWord
275 0xb3596b, // diffRemovedWord
276 0x373737, // userMessageBackground
277 0x464646, // userMessageBackgroundHover
278 0x413c41, // bashMessageBackgroundColor
279 0x374146, // memoryBackgroundColor
280 0x262626, // composerSidebarBackground
281 0x004466, // diffAdded, daltonized
282 0x660000, // diffRemoved, daltonized
283 0x3e515b, // diffAddedDimmed, daltonized
284 0x3e2c2c, // diffRemovedDimmed, daltonized
285 0x0077b3, // diffAddedWord, daltonized
286 0xb30000, // diffRemovedWord, daltonized
287 0x022800, // color-diff's addLine
288 0x044700, // color-diff's addWord
289 0x3d0100, // color-diff's deleteLine
290 0x5c0200, // color-diff's deleteWord
291 0x001b29, // color-diff's addLine, daltonized
292 0x003047, // color-diff's addWord, daltonized
293]
294
295// A colour as the shader reads the terminal's image: in linear light on
296// Linux (sRGB's own curve, as the GPU decodes it), as it is elsewhere.
297function seen(c: number, isLinear: boolean): string {
298 const channel = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
299 const [r, g, b] = [(c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff].map(v => (isLinear ? channel(v / 255) : v / 255))
300
301 return `vec3(${f(r!)}, ${f(g!)}, ${f(b!)})`
302}
303
304// The sRGB colour c in Display P3, as a Mac's Ghostty (its window-colorspace
305// srgb, the default) hands the shader the terminal's image: through linear
306// light, from sRGB's primaries to P3's, and back onto the same curve.
307function displayP3(c: number): number {
308 const decode = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)
309 const encode = (v: number) => Math.round(255 * Math.min(1, Math.max(0, v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055)))
310 const [r, g, b] = [(c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff].map(v => decode(v / 255)) as [number, number, number]
311 const p3 = [0.8225 * r + 0.1774 * g, 0.0332 * r + 0.9669 * g, 0.0171 * r + 0.0724 * g + 0.9108 * b].map(encode) as [number, number, number]
312 return (p3[0] << 16) | (p3[1] << 8) | p3[2]
313}
314
315// The field colours as the shader reads them: on Linux in linear light; on
316// a Mac both as they are (a window-colorspace of display-p3, and the
317// previews) and in Display P3, as Ghostty there gives them by default.
318// Greys are the same either way, and are given once.
319function fieldsSeen(isLinear: boolean): string[] {
320 if (isLinear) return FIELD_COLOURS.map(c => seen(c, true))
321 const converted = FIELD_COLOURS.map(displayP3).filter(c => !FIELD_COLOURS.includes(c))
322 return [...FIELD_COLOURS, ...converted].map(c => seen(c, false))
323}
324
325function ints(values: number[]): string {
326 return `int[${values.length}](${values.join(', ')})`
327}
328
329function vec2s(points: DomePoint[]): string {
330 return `vec2[${points.length}](${points.map(p => `vec2(${f(p.x)}, ${f(p.y)})`).join(', ')})`
331}
332
333// The chart as the shader reads it: stars and lines, and the tiles that say
334// which of them lie near each pixel. GLSL has no empty arrays: one
335// placeholder each when there is nothing to show.
336function chartConstants(chart: ShaderParams['chart']): string[] {
337 const nothing = { x: 0, y: 0 }
338 const stars = chart.stars.length > 0 ? chart.stars : [{ ...nothing, mag: 9, bv: 0 }]
339 const lines = chart.lines.length > 0 ? chart.lines : [[nothing, nothing] as [DomePoint, DomePoint]]
340 const starTiles = tileStars(chart.stars)
341 const lineTiles = tileLines(chart.lines)
342 const tiles = starTiles.start.length
343
344 return [
345 `const int STAR_COUNT = ${chart.stars.length};`,
346 `const vec2 STAR_AT[${stars.length}] = ${vec2s(stars)};`,
347 `const vec2 STAR_LOOK[${stars.length}] = vec2[${stars.length}](${stars.map(s => `vec2(${f(s.mag)}, ${f(s.bv)})`).join(', ')});`,
348 `const int TILE_START[${tiles}] = ${ints(starTiles.start)};`,
349 `const int TILE_COUNT[${tiles}] = ${ints(starTiles.count)};`,
350 `const int TILE_STAR[${Math.max(1, starTiles.index.length)}] = ${ints(starTiles.index.length > 0 ? starTiles.index : [0])};`,
351 `const int LINE_COUNT = ${chart.lines.length};`,
352 `const vec2 LINE_A[${lines.length}] = ${vec2s(lines.map(l => l[0]))};`,
353 `const vec2 LINE_B[${lines.length}] = ${vec2s(lines.map(l => l[1]))};`,
354 `const int LINE_TILE_START[${tiles}] = ${ints(lineTiles.start)};`,
355 `const int LINE_TILE_COUNT[${tiles}] = ${ints(lineTiles.count)};`,
356 `const int LINE_TILE_LINE[${Math.max(1, lineTiles.index.length)}] = ${ints(lineTiles.index.length > 0 ? lineTiles.index : [0])};`,
357 ]
358}
359
360// The whole shader for these constants: the same text for the same params.
361export function skyShader(params: ShaderParams): string {
362 const { scene, moon, demo } = params
363 const demoScenes = demo?.scenes ?? [scene]
364 // GLSL has no empty arrays: a placeholder when no event is up.
365 const events = params.events.length > 0 ? params.events : [{ kind: 'satellite' as const, seed: 0, seconds: 1 }]
366 const constants = [
367 `const float ELEVATION = ${f(scene.elevation)};`,
368 `const float PROGRESS = ${f(scene.progress)};`,
369 `const float IS_DAY = ${scene.isDay ? '1.0' : '0.0'};`,
370 `const float MOON_ILLUMINATION = ${f(moon.illumination)};`,
371 `const float MOON_WAXING = ${moon.isWaxing ? '1.0' : '0.0'};`,
372 `const vec2 MOON_AT = vec2(${f(moon.at?.x ?? 0)}, ${f(moon.at?.y ?? 0)});`,
373 `const float MOON_UP = ${moon.at ? '1.0' : '0.0'};`,
374 `const float CLOUDINESS = ${f(params.cloudiness)};`,
375 `const float COVER = ${f(params.cover)};`,
376 `const float WIND = ${f(params.wind)};`,
377 `const int PRECIPITATION = ${PRECIPITATION[params.precipitation]};`,
378 `const float AURORA = ${f(params.aurora)};`,
379 `const float AURORA_FLOOR = ${f(params.auroraFloor)};`,
380 `const int DEMO_COUNT = ${demo ? demoScenes.length : 0};`,
381 `const float DEMO_SECONDS = ${f(demo?.seconds ?? 1)};`,
382 `const float DEMO_ELEVATION[${demoScenes.length}] = ${floats(demoScenes.map(s => s.elevation))};`,
383 `const float DEMO_PROGRESS[${demoScenes.length}] = ${floats(demoScenes.map(s => s.progress))};`,
384 `const float DEMO_DAY[${demoScenes.length}] = ${floats(demoScenes.map(s => (s.isDay ? 1 : 0)))};`,
385 `const float STOP_AT[${STOPS.length}] = ${floats(STOPS.map(s => s[0]))};`,
386 `const vec3 STOP_TOP[${STOPS.length}] = vec3[${STOPS.length}](${STOPS.map(s => rgb(s[1])).join(', ')});`,
387 `const vec3 STOP_MIDDLE[${STOPS.length}] = vec3[${STOPS.length}](${STOPS.map(s => rgb(s[2])).join(', ')});`,
388 `const vec3 STOP_HORIZON[${STOPS.length}] = vec3[${STOPS.length}](${STOPS.map(s => rgb(s[3])).join(', ')});`,
389 ...Object.entries(INKS).map(([name, c]) => `const vec3 INK_${name.toUpperCase()} = ${rgb(c)};`),
390 ...chartConstants(params.chart),
391 `const float ISS_UP = ${params.iss ? '1.0' : '0.0'};`,
392 `const vec2 ISS_AT = vec2(${f(params.iss?.at.x ?? 0)}, ${f(params.iss?.at.y ?? 0)});`,
393 `const vec2 ISS_TRAIL = vec2(${f(params.iss?.trail.x ?? 0)}, ${f(params.iss?.trail.y ?? 0)});`,
394 `const int BEACON = ${params.beacon ? (params.beacon.mode === 'asking' ? 1 : 2) : 0};`,
395 `const float BEACON_URGENT = ${params.beacon?.isUrgent ? '1.0' : '0.0'};`,
396 `const int BASKET = ${params.basket ? 1 : 0};`,
397 `const float STAGE_PX = ${f(params.stagePx)};`,
398 `const float Y_UP = ${params.isYUp ? '1.0' : '0.0'};`,
399 `const int FIELD_COUNT = ${fieldsSeen(params.isYUp).length};`,
400 `const vec3 FIELD_COLOURS[${fieldsSeen(params.isYUp).length}] = vec3[${fieldsSeen(params.isYUp).length}](${fieldsSeen(params.isYUp).join(', ')});`,
401 `const vec2 COMET_AT = vec2(${f(params.comet?.at.x ?? 0)}, ${f(params.comet?.at.y ?? 0)});`,
402 `const float COMET_AWAY = ${f(params.comet?.away ?? 0)};`,
403 `const float SHOOTING_RATE = ${f(params.shootingRate)};`,
404 `const float FIREWORKS = ${f(params.fireworks)};`,
405 `const float FLAG_DAY = ${params.events.some(e => e.kind === 'flag') ? '1.0' : '0.0'};`,
406 `const float KEEPER_ASLEEP = ${params.keeper.isAsleep ? '1.0' : '0.0'};`,
407 `const int KEEPER_FORCE = ${{ none: 0, ufo: 1, surf: 2 }[params.keeper.force]};`,
408 `const int EVENT_COUNT = ${params.events.length};`,
409 `const int EVENT_KIND[${events.length}] = int[${events.length}](${events.map(e => EVENT_INDEX[e.kind] ?? 0).join(', ')});`,
410 `const float EVENT_SEED[${events.length}] = ${floats(events.map(e => e.seed))};`,
411 `const float EVENT_SECONDS[${events.length}] = ${floats(events.map(e => Math.max(1, e.seconds)))};`,
412 // The rare clouds' shapes, a row per uint, bit 0 the leftmost puff.
413 ...Object.entries(RARE_SHAPES).map(([name, rows]) => {
414 const bits = rows.map(row => [...row].reduce((sum, ch, i) => (ch === 'x' ? sum + 2 ** i : sum), 0))
415 return `const uint ${name.toUpperCase()}[${bits.length}] = uint[${bits.length}](${bits.map(b => `${b}u`).join(', ')});`
416 }),
417 ]
418
419 return `// Solar Clawd's sky, written by hooks/shader.ts: edits here are overwritten.\n\n${constants.join('\n')}\n${BODY}`
420}
421
422// Everything but the constants. Coordinates are x right and y down from the
423// top left, as on sky.ts's Canvas: Ghostty's fragCoord on Metal (macOS),
424// flipped from OpenGL's y up (Linux) when Y_UP is set.
425const BODY = [COMMON, SKY, CLOUDS, SEA, EVENTS, KEEPER, SURF, NIGHT, ROUTINE, UFO, BASKET, MAIN].join('\n')
426