SLOPSHOPPER

skies

KOZMOS Skies: the session's weather above the prompt. Tool failures, context and rate-limit pressure, stalls and the local hour make clear skies, clouds, rain…

newbandguardcommandprocesstimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/skies
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skies
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /skies ⎿ skies: Skies hidden. /skies brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Skies

The session's weather above the prompt, from the climate of the work:

WeatherWhen
⛈ Storm5+ of the last 20 tool calls failed, or the 5-hour window is 95 %+ used
🌫 Fogcontext over 85 %
🌧 Rain3–4 failures, 5-hour use 85 %+, or 10+ minutes without a working tool while busy
⛅ Cloudyany failure, context 65 %+, 5-hour use 70 %+, or 4+ minutes without a working tool
🌤 Faircontext 40 %+ or 5-hour use 50 %+
☀ Clearotherwise
🌙 Night22:00–06:00 local time while idle, over calm or cloudy skies
🌈 Rainbow30 s after a storm clears (also when it eases through rain first)

The forecast line reads like ⛈ Storm · 6/20 tools failed · ctx 91 % · 5h 88 %. The local hour comes from the machine's time zone, asked once per session (PowerShell on Windows, date +%z elsewhere), since the hooks' own clock may not know it.

Terminal: a Client scene of three rows of half-block pixels with glyphs on top (a turning sun, drifting clouds, falling rain, lightning, fog banks, twinkling stars and a crescent moon, a shimmering rainbow) and the forecast line; a short band gets the forecast line alone. Desktop: one animated SVG sky with the forecast on a frosted pill, in light and dark.

Shown once the session has worked (a turn or a tool call). /skies shows or hides it; ✕ hides it. The choice is kept across sessions.

Türkçe

İstemin üstünde oturumun hava durumu, işin ikliminden:

HavaNe zaman
⛈ Fırtınason 20 araç çağrısının 5+'ı başarısız ya da 5 saatlik pencere %95+ dolu
🌫 Sisbağlam %85 üstü
🌧 Yağmur3–4 hata, 5 saatlik kullanım %85+, ya da meşgulken 10+ dakikadır çalışan bir araç yok
⛅ Bulutluherhangi bir hata, bağlam %65+, 5 saatlik kullanım %70+, ya da 4+ dakikadır çalışan araç yok
🌤 Az bulutlubağlam %40+ ya da 5 saatlik kullanım %50+
☀ Açıkdiğer durumlar
🌙 Geceyerel saatle 22:00–06:00 arası, boştayken, sakin ya da bulutlu gökte
🌈 Gökkuşağıfırtına dindikten sonra 30 sn (önce yağmura dönse bile)

Tahmin satırı şöyle okunur: ⛈ Storm · 6/20 tools failed · ctx 91 % · 5h 88 %. Kancaların kendi saati saat dilimini bilmeyebileceği için yerel saat, oturum başına bir kez makinenin saat diliminden sorulur (Windows'ta PowerShell, diğerlerinde date +%z).

Terminal: üç satır yarım blok pikselin üstüne karakterlerle çizilmiş bir Client sahnesi (dönen güneş, sürüklenen bulutlar, yağan yağmur, şimşek, sis kümeleri, parıldayan yıldızlar ve hilal, ışıldayan gökkuşağı) ve tahmin satırı; kısa bantta yalnızca tahmin satırı. Masaüstü: tahmini buzlu bir hap üzerinde gösteren, açık ve koyu temada çalışan tek animasyonlu SVG gökyüzü.

Oturum çalışmaya başlayınca (bir tur ya da araç çağrısı) görünür. /skies gösterir veya gizler; ✕ gizler. Seçim oturumlar arasında saklanır.

Source 6 files
hooks/register.tsx 191 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SkiesSnap, Weather } from '../types'
5import { climate, forecast, localHour, nextRainbow, parseOffset, WINDOW } from './climate.ts'
6import type { Bow } from './climate.ts'
7import type { SceneProps } from './scene.tsx'
8import { skySvg, TONE } from './svg.ts'
9
10const snapAtom = atom({ plugin: 'skies', key: 'snap' } as const, null)
11const hiddenAtom = atom({ plugin: 'skies', key: 'isHidden' } as const, false)
12
13const TICK_MS = 2000
14
15// The climate's inputs. Module variables start over on a reload.
16let results: boolean[] = []
17let lastSuccessAt: number | undefined
18let ctx: number | null = null
19let fiveHour: number | null = null
20let isWorking = false
21let hasActivity = false
22let tzOffset: number | undefined
23let weather: Weather | undefined
24let base: Weather | undefined
25let bow: Bow = { until: 0, stormAt: undefined }
26let since = 0
27let publishedKey = ''
28
29/** Minutes east of UTC, once per session: the hooks' own clock may not know the zone. */
30async function detectOffset($: EngineInterface): Promise<void> {
31  try {
32    const isWindows = /windows/i.test((await $.env.get('OS')) ?? '')
33    const argv = isWindows
34      ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', '[int][TimeZoneInfo]::Local.GetUtcOffset([DateTime]::UtcNow).TotalMinutes']
35      : ['date', '+%z']
36    const r = await $.process.run(argv, { timeoutMs: 8000 })
37    if (r.exitCode === 0) tzOffset = parseOffset(r.stdout)
38  } catch {
39    // Fall back to the environment's own idea of the zone.
40  }
41  tzOffset ??= -new Date().getTimezoneOffset()
42}
43
44async function readUsage($: EngineInterface): Promise<void> {
45  try {
46    const u = await $.session.usage()
47    ctx = u.context.percent ?? null
48    fiveHour = u.rateLimits.find(l => l.kind === 'five_hour')?.percentUsed ?? null
49  } catch {
50    // Keep the last reading.
51  }
52}
53
54async function publish($: EngineInterface): Promise<void> {
55  if (!hasActivity) return
56  const now = await $.clock.now()
57  const staleMin = lastSuccessAt === undefined ? null : Math.floor((now - lastSuccessAt) / 60_000)
58  const sky = climate({ results, ctx, fiveHour, staleMin, hour: localHour(now, tzOffset ?? 0), isWorking })
59  bow = nextRainbow(base, sky, now, bow)
60  base = sky
61  const next: Weather = now < bow.until ? 'rainbow' : sky
62  if (next !== weather) since = now
63  weather = next
64  const snap: SkiesSnap = {
65    weather: next,
66    failed: results.filter(Boolean).length,
67    total: results.length,
68    ctx: ctx === null ? null : Math.round(ctx),
69    fiveHour: fiveHour === null ? null : Math.round(fiveHour),
70    staleMin,
71    isWorking,
72    since,
73  }
74  const key = JSON.stringify(snap)
75  if (key === publishedKey) return
76  publishedKey = key
77  await update($, snapAtom, () => snap)
78}
79
80async function tick($: EngineInterface): Promise<void> {
81  if (!hasActivity) return
82  await readUsage($)
83  await publish($)
84}
85
86async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
87  await update($, hiddenAtom, () => isHidden)
88  try {
89    await $.store.set('isHidden', isHidden)
90  } catch {
91    // Hidden for this session only.
92  }
93}
94
95async function loadHidden($: EngineInterface): Promise<void> {
96  try {
97    const v = await $.store.get('isHidden')
98    if (typeof v === 'boolean') await update($, hiddenAtom, () => v)
99  } catch {
100    // Nothing stored.
101  }
102}
103
104export const register: Register = on => {
105  on('session.start', async ($, e, next) => {
106    const started = await next(e)
107    results = []
108    lastSuccessAt = undefined
109    ctx = fiveHour = null
110    isWorking = false
111    hasActivity = false
112    weather = undefined
113    base = undefined
114    bow = { until: 0, stormAt: undefined }
115    publishedKey = ''
116    await $.command.register({ name: 'skies', description: 'KOZMOS: show or hide the Skies session weather above the prompt', immediate: true })
117    await loadHidden($)
118    // detectOffset catches its own failures and never rejects.
119    void detectOffset($)
120    $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
121    return started
122  })
123
124  on('command.run', { command: 'skies' }, async $ => {
125    const isHidden = !(await read($, hiddenAtom))
126    await setHidden($, isHidden)
127    return { text: isHidden ? 'Skies hidden. /skies brings it back.' : 'Skies shown: the session weather appears once work starts.' }
128  })
129
130  on('turn.start', async ($, e, next) => {
131    isWorking = true
132    hasActivity = true
133    await readUsage($)
134    await publish($)
135    return next(e)
136  })
137
138  on('turn.complete', async ($, e, next) => {
139    if (e.agentId === undefined) {
140      isWorking = false
141      await publish($)
142    }
143    return next(e)
144  })
145
146  on('tool.call', async ($, e, next) => {
147    const ran = await next(e)
148    const failed = ran.isError === true || ran.deny !== undefined
149    results = [...results, failed].slice(-WINDOW)
150    if (!failed) lastSuccessAt = await $.clock.now()
151    hasActivity = true
152    await publish($)
153    return ran
154  }).catch(($, e, next) => next(e))
155
156  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
157    const drawn = await next(e)
158    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
159    const snap = await read($, snapAtom)
160    if (!snap) return drawn
161    const cols = Math.max(20, (e.props.bodyColumns || 80) - 3)
162
163    if (e.surface === 'terminal') {
164      const { Box, Button, Client } = $.ui.resolve(e)
165      const props: SceneProps = { weather: snap.weather, forecast: forecast(snap), tone: TONE[snap.weather] }
166      return (
167        <Box flexDirection="column">
168          {drawn}
169          <Box key="skies" flexDirection="row">
170            <Client key="skies-scene" module="./scene.tsx" width={cols} height={e.props.maxRows >= 8 ? 4 : 1} props={props} />
171            <Button key="skies-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
172          </Box>
173        </Box>
174      )
175    }
176
177    // Every surface but the terminal draws an Svg.
178    const { Box, Button, Svg } = $.ui.resolve(e)
179    const pic = skySvg(snap, cols * 8 - 8)
180    return (
181      <Box flexDirection="column">
182        {drawn}
183        <Box key="skies" flexDirection="row">
184          <Svg source={pic.source} alt={pic.alt} width={pic.width} height={pic.height} />
185          <Button key="skies-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
186        </Box>
187      </Box>
188    )
189  })
190}
191
hooks/climate.ts 94 lines
1// Skies' climate: the session's weather from plain facts. No `$`.
2import type { SkiesSnap, Weather } from '../types'
3
4export const WINDOW = 20
5export const RAINBOW_MS = 30_000
6
7export type ClimateFacts = {
8  /** The last tool calls, oldest first: true where it failed. */
9  results: readonly boolean[]
10  ctx: number | null
11  fiveHour: number | null
12  /** Minutes since the last tool call that succeeded, null before the first. */
13  staleMin: number | null
14  /** The local hour, 0..23. */
15  hour: number
16  isWorking: boolean
17}
18
19export const isNightHour = (hour: number): boolean => hour >= 22 || hour < 6
20
21/**
22 * The weather before any rainbow: storms come from many failures or an
23 * exhausted 5-hour window, fog from a nearly full context, rain from a few
24 * failures, heavy limits or a long stall; clouds from any pressure; night
25 * when the hour is late and nothing runs.
26 */
27export function climate(f: ClimateFacts): Exclude<Weather, 'rainbow'> {
28  const recent = f.results.slice(-WINDOW)
29  const failed = recent.filter(Boolean).length
30  const ctx = f.ctx ?? 0
31  const limit = f.fiveHour ?? 0
32  const stale = f.isWorking && f.staleMin !== null ? f.staleMin : 0
33  if (failed >= 5 || limit >= 95) return 'storm'
34  if (ctx > 85) return 'fog'
35  if (failed >= 3 || limit >= 85 || stale >= 10) return 'rain'
36  const calm = !f.isWorking && isNightHour(f.hour)
37  if (failed >= 1 || ctx >= 65 || limit >= 70 || stale >= 4) return calm ? 'night' : 'cloudy'
38  if (calm) return 'night'
39  if (ctx >= 40 || limit >= 50) return 'fair'
40  return 'clear'
41}
42
43export type Bow = { until: number; stormAt: number | undefined }
44
45const WET: readonly Weather[] = ['storm', 'rain', 'fog']
46
47/**
48 * A storm that clears leaves a rainbow for thirty seconds. Failures slide out
49 * of the window one by one, so a storm usually eases into rain first: the bow
50 * appears when the sky turns dry again within ten minutes of the last storm.
51 */
52export function nextRainbow(prev: Weather | undefined, next: Weather, now: number, bow: Bow): Bow {
53  if (next === 'storm') return { until: 0, stormAt: now }
54  if (WET.includes(next)) return { until: 0, stormAt: bow.stormAt }
55  const wasWet = prev !== undefined && WET.includes(prev)
56  if (wasWet && bow.stormAt !== undefined && now - bow.stormAt < 10 * 60_000) return { until: now + RAINBOW_MS, stormAt: undefined }
57  return bow
58}
59
60export const WEATHER: Record<Weather, { icon: string; name: string }> = {
61  clear: { icon: '☀', name: 'Clear' },
62  fair: { icon: '🌤', name: 'Fair' },
63  cloudy: { icon: '⛅', name: 'Cloudy' },
64  rain: { icon: '🌧', name: 'Rain' },
65  storm: { icon: '⛈', name: 'Storm' },
66  fog: { icon: '🌫', name: 'Fog' },
67  night: { icon: '🌙', name: 'Night' },
68  rainbow: { icon: '🌈', name: 'Rainbow' },
69}
70
71/** "⛈ Storm · 6/20 tools failed · ctx 91 % · 5h 88 %" */
72export function forecast(s: SkiesSnap): string {
73  const w = WEATHER[s.weather]
74  const bits = [`${w.icon} ${w.name}`]
75  bits.push(s.total ? `${s.failed}/${s.total} tools failed` : 'no tools yet')
76  if (s.ctx !== null) bits.push(`ctx ${Math.round(s.ctx)} %`)
77  if (s.fiveHour !== null) bits.push(`5h ${Math.round(s.fiveHour)} %`)
78  if (s.isWorking && s.staleMin !== null && s.staleMin >= 4) bits.push(`${Math.round(s.staleMin)} min since a tool worked`)
79  return bits.join(' · ')
80}
81
82/** "+0300" or "-0530" (date +%z), or a number of minutes, as minutes east of UTC. */
83export function parseOffset(text: string): number | undefined {
84  const t = text.trim()
85  const z = /^([+-])(\d{2}):?(\d{2})$/.exec(t)
86  if (z) return (z[1] === '-' ? -1 : 1) * (Number(z[2]) * 60 + Number(z[3]))
87  const n = Number(t)
88  return t !== '' && Number.isFinite(n) && Math.abs(n) <= 14 * 60 ? Math.round(n) : undefined
89}
90
91export function localHour(now: number, offsetMin: number): number {
92  return new Date(now + offsetMin * 60_000).getUTCHours()
93}
94
hooks/scene.tsx 217 lines
1// Skies' terminal scene: a Client surface module. Three rows of sky drawn as
2// half-block pixels (6 pixels tall) with glyphs on top (rain, stars, bolts,
3// fog), animated at ~25 fps, and the forecast line under it.
4import type { ClientModule, RenderElement } from 'claude-code'
5
6import type { Weather } from '../types'
7import { mix, noise } from './lib/kz.ts'
8
9export type SceneProps = { weather: Weather; forecast: string; tone: string }
10
11type SceneState = { t: number }
12type Glyph = { ch: string; color: string }
13
14const ROWS = 3
15const PX = ROWS * 2
16const RAINBOW = ['#f87171', '#fb923c', '#facc15', '#4ade80', '#60a5fa', '#a78bfa']
17
18/** The sky as pixels and glyphs: pure, so the tests can look at a frame. */
19export function frame(weather: Weather, w: number, t: number): { px: (string | undefined)[]; glyphs: (Glyph | undefined)[] } {
20  const px: (string | undefined)[] = new Array(w * PX).fill(undefined)
21  const glyphs: (Glyph | undefined)[] = new Array(w * ROWS).fill(undefined)
22  const setPx = (x: number, y: number, c: string | undefined) => {
23    const xi = Math.round(x)
24    const yi = Math.round(y)
25    if (xi >= 0 && xi < w && yi >= 0 && yi < PX) px[yi * w + xi] = c
26  }
27  const disc = (cx: number, cy: number, r: number, c: string | undefined) => {
28    for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
29      for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) setPx(x, y, c)
30    }
31  }
32  const glyph = (x: number, row: number, ch: string, color: string) => {
33    const xi = Math.round(x)
34    if (xi >= 0 && xi < w && row >= 0 && row < ROWS) glyphs[row * w + xi] = { ch, color }
35  }
36  const cloud = (x: number, y: number, c: string) => {
37    disc(x, y + 1, 1.5, c)
38    disc(x + 2.4, y, 2.1, c)
39    disc(x + 5, y + 1, 1.7, c)
40    for (let i = -1; i <= 6; i++) setPx(x + i, y + 2, c)
41  }
42  const clouds = (n: number, c: string, speed: number, top = 0) => {
43    for (let i = 0; i < n; i++) {
44      const span = w + 14
45      const x = ((((i + noise(i, 7) * 0.6) / n) * span + t * speed) % span) - 8
46      cloud(x, top + (i % 2) * 0.8, c)
47    }
48  }
49  const sun = (cx: number, cy: number) => {
50    const spin = t * 0.035
51    for (let k = 0; k < 8; k++) {
52      const a = spin + (k * Math.PI) / 4
53      const flick = 0.5 + 0.5 * Math.sin(t * 0.2 + k)
54      for (const d of [3.3, 4.1]) setPx(cx + Math.cos(a) * d, cy + Math.sin(a) * d * 0.9, mix('#fb923c', '#fde68a', flick))
55    }
56    disc(cx, cy, 2.1, '#facc15')
57    setPx(cx - 0.6, cy - 0.8, '#fef9c3')
58  }
59  const rain = (density: number, color: string) => {
60    for (let x = 0; x < w; x++) {
61      if (noise(x, 11) > density) continue
62      const speed = 0.18 + noise(x, 5) * 0.12
63      const pos = (t * speed + noise(x, 9) * 4) % 2
64      const row = Math.floor(pos) + 1
65      // Drops fall below the clouds, never through them.
66      if (row < ROWS && !px[row * 2 * w + x] && !px[(row * 2 + 1) * w + x]) glyph(x, row, pos % 1 < 0.5 ? '╵' : '│', color)
67    }
68  }
69
70  switch (weather) {
71    case 'clear': {
72      sun(5, 2.6)
73      for (let i = 0; i < Math.max(1, Math.floor(w / 40)); i++) {
74        const x = (noise(i, 2) * w + t * 0.12) % (w + 4)
75        glyph(x, i % 2, Math.floor(t / 6 + i) % 2 ? 'v' : '˅', '#9ca3af')
76      }
77      break
78    }
79    case 'fair':
80      sun(5, 2.6)
81      clouds(Math.max(2, Math.floor(w / 30)), '#cbd5e1', 0.05)
82      break
83    case 'cloudy':
84      clouds(Math.max(4, Math.floor(w / 14)), '#94a3b8', 0.06)
85      clouds(Math.max(2, Math.floor(w / 26)), '#cbd5e1', 0.09, 1)
86      break
87    case 'rain':
88      clouds(Math.max(6, Math.floor(w / 9)), '#64748b', 0.05)
89      rain(0.22, '#60a5fa')
90      break
91    case 'storm': {
92      const strike = t % 70 < 5 && noise(Math.floor(t / 70), 1) > 0.25
93      const flash = strike && t % 70 < 2
94      clouds(Math.max(8, Math.floor(w / 7)), flash ? '#e5e7eb' : '#475569', 0.09)
95      rain(0.36, '#93c5fd')
96      if (strike) {
97        const x = 6 + Math.floor(noise(Math.floor(t / 70), 4) * Math.max(1, w - 12))
98        glyph(x, 1, '╲', '#fde047')
99        glyph(x + 1, 1, '_', '#fde047')
100        glyph(x + 1, 2, '╱', '#fde047')
101      }
102      break
103    }
104    case 'fog':
105      for (let row = 0; row < ROWS; row++) {
106        for (let x = 0; x < w; x++) {
107          const n = noise(Math.floor((x + t * (0.08 + row * 0.05)) / 3), row + 20)
108          if (n > 0.35) glyph(x, row, n > 0.75 ? '▒' : '░', mix('#9ca3af', '#e5e7eb', n))
109        }
110      }
111      break
112    case 'night': {
113      for (let row = 0; row < ROWS; row++) {
114        for (let x = 0; x < w; x++) {
115          const n = noise(x, row + 40)
116          if (n < 0.93) continue
117          const tw = 0.5 + 0.5 * Math.sin(t * 0.08 + n * 50)
118          glyph(x, row, n > 0.985 ? '✦' : tw > 0.7 ? '+' : '·', mix('#475569', '#fef9c3', tw))
119        }
120      }
121      const mx = Math.max(10, w - 9)
122      disc(mx, 2.6, 2.3, '#fef3c7')
123      disc(mx + 1.2, 1.9, 2.0, undefined)
124      break
125    }
126    case 'rainbow': {
127      const cx = w * 0.5
128      const rx = Math.max(8, w * 0.36)
129      for (let x = 0; x < w; x++) {
130        const u = (x - cx) / rx
131        if (Math.abs(u) >= 1) continue
132        const h = PX * Math.sqrt(1 - u * u)
133        for (const [b, band] of RAINBOW.entries()) {
134          const y = Math.floor(PX - h) + b
135          const shimmer = 0.25 * (0.5 + 0.5 * Math.sin(t * 0.15 - x * 0.18))
136          if (y >= 0 && y < PX) setPx(x, y, mix(band, '#ffffff', shimmer))
137        }
138      }
139      clouds(2, '#cbd5e1', 0.03)
140      sun(4, 2.6)
141      for (let i = 0; i < 4; i++) if (Math.floor(t / 4 + i * 3) % 7 === 0) glyph(cx + (noise(i, Math.floor(t / 28)) - 0.5) * rx * 1.6, i % 2, '✧', '#fef9c3')
142      break
143    }
144  }
145  return { px, glyphs }
146}
147
148const Scene: ClientModule<SceneProps, SceneState> = (props, surface) => {
149  if (surface.state === undefined) {
150    surface.setState({ t: 0 })
151    surface.every(40, () => surface.setState({ t: (surface.state?.t ?? 0) + 1 }))
152  }
153  const { Box, Text } = surface.elements
154  const w = Math.max(10, surface.columns || 80)
155  const t = surface.state?.t ?? 0
156  // A short band gets the forecast line alone.
157  const isCompact = surface.rows > 0 && surface.rows < ROWS + 1
158  const { px, glyphs } = frame(props.weather, w, t)
159  const rows: RenderElement[] = []
160  for (let row = 0; row < (isCompact ? 0 : ROWS); row++) {
161    const parts: RenderElement[] = []
162    let run = ''
163    let style = ''
164    let fg: string | undefined
165    let bg: string | undefined
166    const flush = (k: string) => {
167      if (run) parts.push(<Text key={k} color={fg} backgroundColor={bg}>{run}</Text>)
168      run = ''
169    }
170    for (let x = 0; x < w; x++) {
171      const g = glyphs[row * w + x]
172      const top = px[row * 2 * w + x]
173      const bot = px[(row * 2 + 1) * w + x]
174      let ch = ' '
175      let f: string | undefined
176      let b: string | undefined
177      if (g) {
178        ch = g.ch
179        f = g.color
180        b = top && bot ? bot : undefined
181      } else if (top && bot) {
182        ch = top === bot ? '█' : '▀'
183        f = top
184        b = top === bot ? undefined : bot
185      } else if (top) {
186        ch = '▀'
187        f = top
188      } else if (bot) {
189        ch = '▄'
190        f = bot
191      }
192      const s = `${f ?? ''}|${b ?? ''}`
193      if (s !== style) {
194        flush(`c${x}`)
195        style = s
196        fg = f
197        bg = b
198      }
199      run += ch
200    }
201    flush('end')
202    rows.push(<Text key={`r${row}`} wrap="truncate-end">{parts}</Text>)
203  }
204  const cut = props.forecast.indexOf(' · ')
205  const head = cut < 0 ? props.forecast : props.forecast.slice(0, cut)
206  const rest = cut < 0 ? '' : props.forecast.slice(cut)
207  rows.push(
208    <Text key="forecast" wrap="truncate-end">
209      <Text key="h" color={props.tone} bold>{head}</Text>
210      <Text key="r" dimColor>{rest}</Text>
211    </Text>,
212  )
213  return <Box flexDirection="column">{rows}</Box>
214}
215
216export default Scene
217
hooks/svg.ts 154 lines
1// Skies' desktop scene: one SVG, a little animated sky behind a forecast pill.
2// Every motion (drifting clouds, falling rain, lightning, twinkling stars, a
3// turning sun, a rainbow drawing itself) is CSS, so nothing redraws for it.
4import type { SkiesSnap, Weather } from '../types'
5import { fitText, rng, svg, svgText, textWidth } from './lib/kz.ts'
6import { forecast, WEATHER } from './climate.ts'
7
8const H = 64
9
10const SKY: Record<Weather, [string, string, number]> = {
11  clear: ['#7dd3fc', '#fef3c7', 0.45],
12  fair: ['#93c5fd', '#e0f2fe', 0.4],
13  cloudy: ['#94a3b8', '#cbd5e1', 0.4],
14  rain: ['#475569', '#64748b', 0.45],
15  storm: ['#0f172a', '#334155', 0.7],
16  fog: ['#9ca3af', '#e5e7eb', 0.45],
17  night: ['#0b1026', '#312e81', 0.75],
18  rainbow: ['#bae6fd', '#fef9c3', 0.45],
19}
20
21export const TONE: Record<Weather, string> = {
22  clear: '#f59e0b',
23  fair: '#38bdf8',
24  cloudy: '#7c8ba1',
25  rain: '#60a5fa',
26  storm: '#a78bfa',
27  fog: '#8b929c',
28  night: '#8b7cf6',
29  rainbow: '#f472b6',
30}
31
32const CSS = `
33.pill{fill:#ffffff;opacity:.9}@media (prefers-color-scheme: dark){.pill{fill:#1c1c1b;opacity:.85}}
34.sun{transform-box:fill-box;transform-origin:center;animation:sksun 14s linear infinite}@keyframes sksun{to{transform:rotate(360deg)}}
35.glow{animation:skglow 3s ease-in-out infinite}@keyframes skglow{50%{opacity:.45}}
36.drift{animation:skdrift linear infinite}@keyframes skdrift{from{transform:translateX(-140px)}to{transform:translateX(var(--w))}}
37.fall{animation:skfall linear infinite}@keyframes skfall{from{transform:translate(0,-14px)}to{transform:translate(-6px,${H + 10}px)}}
38.bolt{opacity:0;animation:skbolt 4.6s linear infinite}@keyframes skbolt{0%,90%{opacity:0}91%{opacity:1}92%{opacity:.2}93%{opacity:1}96%,100%{opacity:0}}
39.flash{opacity:0;animation:skflash 4.6s linear infinite}@keyframes skflash{0%,90%{opacity:0}91%{opacity:.55}93%{opacity:.35}97%,100%{opacity:0}}
40.tw{animation:sktw 2.4s ease-in-out infinite}@keyframes sktw{50%{opacity:.15}}
41.fog{animation:skfog ease-in-out infinite alternate}@keyframes skfog{from{transform:translateX(-40px)}to{transform:translateX(40px)}}
42.arc{stroke-dasharray:420;stroke-dashoffset:420;animation:skarc 2.2s cubic-bezier(.3,.7,.2,1) forwards,skglow 3s ease-in-out 2.2s infinite}@keyframes skarc{to{stroke-dashoffset:0}}
43.bird{animation:skbird 26s linear infinite}@keyframes skbird{from{transform:translateX(-30px)}to{transform:translateX(var(--w))}}
44.flap{transform-box:fill-box;transform-origin:center;animation:skflap .5s ease-in-out infinite alternate}@keyframes skflap{to{transform:scaleY(.4)}}
45`
46
47function cloud(x: number, y: number, s: number, fill: string, opacity: number): string {
48  return `<g fill="${fill}" opacity="${opacity}"><circle cx="${x}" cy="${y + 4 * s}" r="${7 * s}"/><circle cx="${x + 11 * s}" cy="${y}" r="${10 * s}"/><circle cx="${x + 23 * s}" cy="${y + 4 * s}" r="${8 * s}"/><rect x="${x - 6 * s}" y="${y + 4 * s}" width="${36 * s}" height="${8 * s}" rx="${4 * s}"/></g>`
49}
50
51function sun(cx: number, cy: number): string {
52  const rays: string[] = []
53  for (let i = 0; i < 12; i++) {
54    const a = (i * Math.PI) / 6
55    rays.push(`<line x1="${(cx + Math.cos(a) * 17).toFixed(1)}" y1="${(cy + Math.sin(a) * 17).toFixed(1)}" x2="${(cx + Math.cos(a) * (i % 2 ? 22 : 25)).toFixed(1)}" y2="${(cy + Math.sin(a) * (i % 2 ? 22 : 25)).toFixed(1)}" stroke="#fbbf24" stroke-width="2.2" stroke-linecap="round"/>`)
56  }
57  return `<circle cx="${cx}" cy="${cy}" r="22" fill="#fde68a" opacity=".55" filter="url(#skb)" class="glow"/><g class="sun">${rays.join('')}</g><circle cx="${cx}" cy="${cy}" r="12" fill="#facc15"/><circle cx="${cx - 3.5}" cy="${cy - 3.5}" r="3.5" fill="#fef9c3" opacity=".8"/>`
58}
59
60export function skySvg(s: SkiesSnap, width: number): { source: string; width: number; height: number; alt: string } {
61  const W = Math.max(280, width)
62  const r = rng(W * 31 + s.weather.length)
63  const p: string[] = []
64  const [top, bottom, alpha] = SKY[s.weather]
65  p.push(`<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${top}"/><stop offset="1" stop-color="${bottom}"/></linearGradient>`)
66  p.push(`<filter id="skb" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="4"/></filter>`)
67  p.push(`<filter id="skf" x="-20%" y="-100%" width="140%" height="300%"><feGaussianBlur stdDeviation="6"/></filter>`)
68  p.push(`<clipPath id="skc"><rect x="0" y="0" width="${W}" height="${H}" rx="12"/></clipPath></defs>`)
69  p.push(`<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`)
70  p.push(`<g clip-path="url(#skc)"><rect x="0" y="0" width="${W}" height="${H}" fill="url(#sky)" opacity="${alpha}"/>`)
71
72  const drift = (body: string, dur: number, delay: number) => `<g class="drift" style="--w:${W + 20}px;animation-duration:${dur.toFixed(1)}s;animation-delay:-${delay.toFixed(1)}s">${body}</g>`
73  const clouds = (n: number, fill: string, opacity: number, size: number, speed: number) => {
74    for (let i = 0; i < n; i++) {
75      const dur = (W + 160) / (speed * (0.7 + r() * 0.6))
76      p.push(drift(cloud(0, 10 + r() * 22, size * (0.8 + r() * 0.5), fill, opacity), dur, r() * dur))
77    }
78  }
79  const rain = (n: number, color: string) => {
80    for (let i = 0; i < n; i++) {
81      const x = r() * (W + 40)
82      const dur = 0.55 + r() * 0.35
83      p.push(`<line class="fall" x1="${x.toFixed(1)}" y1="0" x2="${(x - 2.5).toFixed(1)}" y2="9" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity=".8" style="animation-duration:${dur.toFixed(2)}s;animation-delay:-${(r() * dur).toFixed(2)}s"/>`)
84    }
85  }
86  const sx = W - 64
87
88  switch (s.weather) {
89    case 'clear':
90      p.push(sun(sx, 30))
91      for (let i = 0; i < 2; i++) p.push(`<g class="bird" style="--w:${W + 30}px;animation-delay:-${(i * 11 + 4).toFixed(0)}s"><path class="flap" d="M0 ${16 + i * 9}q4-4 8 0q4-4 8 0" stroke="#64748b" stroke-width="1.5" fill="none"/></g>`)
92      break
93    case 'fair':
94      p.push(sun(sx, 28))
95      clouds(Math.max(2, Math.floor(W / 260)), '#ffffff', 0.9, 1, 9)
96      break
97    case 'cloudy':
98      clouds(Math.max(4, Math.floor(W / 120)), '#94a3b8', 0.85, 1.1, 8)
99      clouds(Math.max(3, Math.floor(W / 180)), '#e2e8f0', 0.95, 0.9, 12)
100      break
101    case 'rain':
102      clouds(Math.max(6, Math.floor(W / 80)), '#64748b', 0.95, 1.2, 7)
103      rain(Math.floor(W / 9), '#93c5fd')
104      break
105    case 'storm': {
106      p.push(`<rect class="flash" x="0" y="0" width="${W}" height="${H}" fill="#e0e7ff"/>`)
107      clouds(Math.max(8, Math.floor(W / 60)), '#334155', 0.97, 1.3, 14)
108      rain(Math.floor(W / 5), '#bfdbfe')
109      const bx = W * (0.45 + r() * 0.35)
110      p.push(`<path class="bolt" d="M${bx} 8l-9 22h8l-6 24 18-30h-9l7-16z" fill="#fde047" stroke="#fef9c3" stroke-width="1"/>`)
111      p.push(`<path class="bolt" d="M${bx} 8l-9 22h8l-6 24 18-30h-9l7-16z" fill="#fde047" filter="url(#skb)"/>`)
112      break
113    }
114    case 'fog':
115      for (let i = 0; i < 5; i++) {
116        p.push(`<rect class="fog" x="${(i * W) / 5 - 60}" y="${8 + (i % 3) * 16}" width="${W / 2.2}" height="14" rx="7" fill="#e5e7eb" opacity=".85" filter="url(#skf)" style="animation-duration:${(6 + i * 1.7).toFixed(1)}s"/>`)
117      }
118      break
119    case 'night': {
120      for (let i = 0; i < Math.floor(W / 14); i++) {
121        const x = r() * W
122        const y = 4 + r() * (H - 10)
123        p.push(`<circle class="tw" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(0.6 + r() * 1.1).toFixed(2)}" fill="#fef9c3" style="animation-delay:-${(r() * 2.4).toFixed(2)}s;animation-duration:${(1.8 + r() * 2).toFixed(2)}s"/>`)
124      }
125      p.push(`<defs><mask id="moon"><rect x="0" y="0" width="${W}" height="${H}" fill="#fff"/><circle cx="${sx + 7}" cy="24" r="13" fill="#000"/></mask></defs>`)
126      p.push(`<circle cx="${sx}" cy="30" r="20" fill="#fef3c7" opacity=".25" filter="url(#skb)"/><circle cx="${sx}" cy="30" r="14" fill="#fef3c7" mask="url(#moon)"/>`)
127      break
128    }
129    case 'rainbow': {
130      const colors = ['#f87171', '#fb923c', '#facc15', '#4ade80', '#60a5fa', '#a78bfa']
131      const cx = W * 0.62
132      colors.forEach((c, i) => {
133        const rr = 74 - i * 5
134        p.push(`<path class="arc" d="M${cx - rr} ${H + 22}A${rr} ${rr} 0 0 1 ${cx + rr} ${H + 22}" stroke="${c}" stroke-width="5" fill="none" stroke-linecap="round" opacity=".9" style="animation-delay:${(i * 0.08).toFixed(2)}s,${(2.2 + i * 0.08).toFixed(2)}s"/>`)
135      })
136      p.push(sun(sx + 26, 22))
137      clouds(2, '#ffffff', 0.9, 0.9, 6)
138      break
139    }
140  }
141  p.push(`</g>`)
142
143  // The forecast pill.
144  const w = WEATHER[s.weather]
145  const line = forecast(s)
146  const rest = line.slice(line.indexOf(' · ') + 3)
147  const pillW = Math.min(W - 16, Math.max(textWidth(w.name, 14) + 70, textWidth(rest, 10.5) + 66))
148  p.push(`<rect class="pill" x="8" y="9" width="${pillW.toFixed(1)}" height="${H - 18}" rx="10"/>`)
149  p.push(`<text x="18" y="42" font-size="24">${w.icon}</text>`)
150  p.push(svgText(52, 29, w.name, { size: 14, weight: 750, fill: TONE[s.weather] }))
151  p.push(svgText(52, 45, fitText(rest, 10.5, pillW - 52), { cls: 's', size: 10.5 }))
152  return { source: svg(W, H, p.join(''), CSS), width: W, height: H, alt: `Skies: ${line}` }
153}
154
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
types/index.d.ts 23 lines
1export type Weather = 'clear' | 'fair' | 'cloudy' | 'rain' | 'storm' | 'fog' | 'night' | 'rainbow'
2
3export type SkiesSnap = {
4  weather: Weather
5  /** Failed tool calls among the last `total` (at most 20). */
6  failed: number
7  total: number
8  /** Context fill and 5-hour use in percent, when known. */
9  ctx: number | null
10  fiveHour: number | null
11  /** Minutes since the last tool call that succeeded, when there was one. */
12  staleMin: number | null
13  isWorking: boolean
14  /** When this weather set in (ms). */
15  since: number
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    skies: { snap: SkiesSnap | null; isHidden: boolean }
21  }
22}
23