SLOPSHOPPER

solar-clawd

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

newbandguardcommandtoastprompt
★ 1v0.4.0GPL-3.0updated 2026-10-25Fiddy134/solar-clawd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · solar-clawd
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ solar-clawd │ ⏺ Read(src/auth.ts) │ Solar: run /solar ghostty to show the sky │ ⎿ Read 6 lines │ behind the terminal. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /solar ⎿ solar-clawd: Solar: the sky is hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Solar Clawd

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.

A low sun over the sea, the lighthouse lit, a dolphin, and Clawd at work above the prompt

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

What's in the sky

Four skies: day, dusk, aurora at night, rain

  • The real sky — sun and moon positions and phases, bright stars and constellations, the ISS on its actual visible passes, aurora from NOAA's Kp index, and the weather from Open-Meteo (clouds, rain, snow, wind).
  • Small, rare events — hot-air balloons, now and then a flying saucer among the clouds, migrating birds, planes with contrails, kites on windy days, autumn leaves, cherry blossom, a comet every couple of months, a dolphin leaping by the lighthouse (now and then a pink one), rainbows after rain, shooting stars (more during the Perseids and Geminids).
  • Swedish days — Easter witches, the flag on National Day, a maypole at Midsummer, a Lucia procession, the Christmas star and Santa's sleigh, New Year's fireworks.
  • Claude's state — a lighthouse far off on the horizon lights up and sweeps when Claude is waiting for you (orange for a question, white when done), and confetti falls after a long piece of work. In the bottom right corner stands a wire wastebasket (in the band behind Clawd when there is no shader): he tosses a ball of paper into it for each tenth of the context window, and when the conversation is compacted he throws in a match and the context burns away; he reads a book while Claude only reads, and types while it edits or runs commands.
  • The keeper — in Ghostty the lighthouse is three times larger, and a small Clawd lives there: he fishes on the rocks, walks round the gallery, surfs a wave now and then, stargazes at night, and sleeps in a hammock from 03 to 06. When Claude waits for you he stands by the lit lantern, and every so often a UFO beams him up in a green light and kicks him out again a quarter of a minute later (/solar event ufo abduction and /solar event surf preview them).
  • Glass — in Ghostty, Claude Code's own dark fields (a diff's red and green rows, your prompt's row, a shell command's row) let the sky through, tinted, as coloured glass would; text and everything else stay solid. Only on a dark background.

Events are a pure function of the time and the sky, so every session shows the same sky without keeping any state.

Requirements

  • macOS, or Linux (see Linux)
  • Claude Code 2.1.275 or later
  • Ghostty 1.3 or later for the sky behind the terminal (iTerm2 gets it as a background image instead)

Install

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.

Developing

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.

Linux

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:

  • If Ghostty runs as its systemd user service, the mod runs systemctl --user reload app-com.mitchellh.ghostty.service, the way Ghostty's docs recommend.
  • Otherwise it sends 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:

  • Ghostty draws with OpenGL there, so the shader flips its coordinates and reads the terminal's colours in linear light as well.
  • Battery is read from /sys/class/power_supply (the sky stands still on battery).
  • There is no idle detection, so the sky keeps animating while you are away.
  • No now-playing music in the band, and no iTerm2 background.

Commands

Command
/solarHide or show the sky
/solar demoA whole day in 48 seconds
/solar HH:MMPin a time today
/solar YYYY-MM-DD HH:MMPin a day, e.g. /solar 2026-12-24 15:01
/solar nowFollow the real sun again
/solar place <city>Set the place (otherwise guessed from your IP, starting in Stockholm); auto goes back to guessing
/solar eventsList the events
/solar event <name>Play an event for 90 seconds
/solar auroraToggle a forced aurora
/solar stormToggle a storm
/solar tossClawd throws a ball of paper in the wastebasket
/solar burnThe wastebasket burns for a while, as on /compact (the conversation is not touched)
/solar ghosttyAdd the sky to Ghostty's config

Data sources

Open-Meteo (weather and geocoding), CelesTrak (ISS orbit), NOAA SWPC (Kp index), ipinfo.io / ipwho.is (place guess), the Yale Bright Star Catalog.

License

GPL-3.0. A fan project, not affiliated with Anthropic.

Source 31 files
hooks/register.tsx 802 lines
1import { 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}
802
hooks/astro.ts 256 lines
1// 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}
256
hooks/beacon.ts 42 lines
1// 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}
42
hooks/clawd.ts 819 lines
1// 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}
819
hooks/events.ts 310 lines
1// 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}
310
hooks/ghostty.ts 406 lines
1import 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}
406
hooks/host.ts 61 lines
1import 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}
61
hooks/iss.ts 138 lines
1// 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}
138
hooks/mac.ts 49 lines
1// 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']
49
hooks/net.ts 187 lines
1import 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}&current=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}
187
hooks/paint.ts 434 lines
1// 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}
434
hooks/shader.ts 426 lines
1// 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