SLOPSHOPPER

lofi

KOZMOS Lofi: generative lo-fi music while Claude works. Four original synthesized loops (focus, deep, night, sunny: warm chord pads, an electric piano, soft…

newbandcommandtimeraudio
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/lofi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lofi
› 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 › /lofi ⎿ lofi: Lofi band hidden. Music is off: /lofi on. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Lofi

Generative lo-fi music while Claude works. Four original loops, synthesized from nothing by tools/make-loops.mjs (warm detuned chord pads, a two-operator FM electric piano, a round bass, a soft swung kick / rim / hat pattern with a gentle side-chain pump, a tape echo, wow and vinyl crackle), each written into a circular buffer so it loops without a seam:

moodtempolengthfeel
focus78 bpm12.3 sii–V–I–vi in C, keys forward
deep68 bpm14.1 sminor ninths, pads, low cutoff
night62 bpm15.5 sdark sevenths, heavy crackle, long echo
sunny86 bpm11.2 sbright majors, shaker hats, plucky keys
  • Opt-in: nothing plays until /lofi on (or the enabled setting). /lofi off stops it; both are remembered.
  • When: a loop starts with each main turn (turn.start) and loops ($.audio.play with shouldLoop and an AbortSignal) until the turn completes, then stops at once. A clock tick follows the mood when auto crosses an hour boundary.
  • Mood: /lofi mood auto|focus|deep|night|sunny, or the mood setting. auto picks by local hour: sunny 06–11, focus 11–18, deep 18–22, night after. volume (0–100) sets the gain.
  • Band (only while playing): ♪ lofi · deep ▁▃▅▇▅▃ — on the terminal a Client surface module whose equalizer bumps on the loop's beat; on the desktop one SVG pill whose bars dance by CSS at the loop's tempo. ✕ or bare /lofi hides / shows the band; /lofi status reports.
  • Where the engine has no audio player (it says a Linux or Windows terminal plays nothing), the first loop ends at once; Lofi then stays quiet for that turn instead of retrying, and /lofi status says so.
  • Regenerate the loops: node tools/make-loops.mjs (deterministic; 22.05 kHz mono 16-bit WAV, about 2.3 MB in all).

Türkçe

Claude çalışırken üretken lo-fi müzik. tools/make-loops.mjs ile sıfırdan sentezlenen dört özgün döngü (sıcak akor pad'leri, FM elektrik piyano, yuvarlak bas, swing'li yumuşak davul, teyp yankısı ve plak cızırtısı); her biri dairesel bir tampona yazıldığı için dikişsiz döner.

  • İsteğe bağlı: /lofi on demeden hiçbir şey çalmaz; /lofi off durdurur.
  • Ne zaman: her ana turun başında başlar, tur bitene kadar döngüde çalar (shouldLoop + AbortSignal), Claude boşa düşünce hemen durur.
  • Ruh hali: /lofi mood auto|focus|deep|night|sunny; auto yerel saate göre seçer. volume (0–100) ses düzeyidir.
  • Bant (yalnızca çalarken): ♪ lofi · deep ve ritme göre zıplayan ekolayzer; terminalde Client modülü, masaüstünde CSS ile dans eden SVG. ✕ ya da yalın /lofi bandı gizler/gösterir.
  • Ses çalarının olmadığı yüzeylerde ilk döngü hemen biter; Lofi o tur boyunca sessiz kalır, /lofi status bunu söyler.
Source 5 files
hooks/register.tsx 213 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LofiMood, LofiSnap } from '../types'
5import { pxOf } from './lib/kz.ts'
6import { MOODS, assetOf, gainOf, isMood, lofiSvg, moodSettingOf, resolveMood } from './mood.ts'
7
8const snapAtom = atom({ plugin: 'lofi', key: 'snap' } as const, null)
9const hiddenAtom = atom({ plugin: 'lofi', key: 'isHidden' } as const, false)
10
11/** A loop that ends on its own this soon was not played (no player here). */
12const NO_PLAYER_MS = 2500
13
14// The player. One loop at a time; its AbortController stops it.
15let isEnabled = false
16let moodSetting = 'auto'
17let gain = 0.5
18let isWorking = false
19let player: { stop: AbortController; mood: LofiMood; since: number } | undefined
20/** Set when the engine could not play a loop; cleared at the next turn. */
21let isMuted = false
22let lastKey = ''
23
24async function currentMood($: EngineInterface): Promise<LofiMood> {
25  return resolveMood(moodSetting, new Date(await $.clock.now()).getHours())
26}
27
28async function publish($: EngineInterface): Promise<void> {
29  const fallback = await currentMood($)
30  // Read the player once, after the await: a loop may have started meanwhile.
31  const p = player
32  const snap: LofiSnap = { isPlaying: p !== undefined, mood: p?.mood ?? fallback, isEnabled, since: p?.since ?? 0 }
33  const key = JSON.stringify(snap)
34  if (key === lastKey) return
35  await update($, snapAtom, () => snap)
36  // Only once it is written, so a refused write is tried again next time.
37  lastKey = key
38}
39
40function stop(): void {
41  const p = player
42  player = undefined
43  p?.stop.abort()
44}
45
46/** Plays one loop until it is stopped; `p` is already the current player. */
47async function play($: EngineInterface, p: NonNullable<typeof player>): Promise<void> {
48  try {
49    await publish($)
50  } catch {
51    // The band catches up at the next publish; the music plays regardless.
52  }
53  try {
54    await $.audio.play({ asset: assetOf(p.mood) }, { shouldLoop: true, gain, signal: p.stop.signal })
55  } catch {
56    // The engine could not play it: treated as no player below.
57  }
58  // The loop ended. If we did not stop it, the engine could not play it.
59  if (player === p) {
60    player = undefined
61    if ((await $.clock.now()) - p.since < NO_PLAYER_MS) isMuted = true
62    await publish($)
63  }
64}
65
66/** Plays while enabled and working, in the mood of the moment; stops otherwise. */
67async function reconcile($: EngineInterface): Promise<void> {
68  const now = await $.clock.now()
69  const mood = resolveMood(moodSetting, new Date(now).getHours())
70  // From here on no await until the player is settled, so two ticks never start two loops.
71  const want = isEnabled && isWorking && !isMuted
72  if (player && (!want || player.mood !== mood)) stop()
73  if (want && !player) {
74    player = { stop: new AbortController(), mood, since: now }
75    void play($, player).catch(() => undefined)
76  }
77  await publish($)
78}
79
80async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
81  await update($, hiddenAtom, () => isHidden)
82  try {
83    await $.store.set('hidden', isHidden)
84  } catch {
85    // Not persisted; this session still honours it.
86  }
87}
88
89async function remember($: EngineInterface, key: string, value: unknown): Promise<void> {
90  try {
91    await $.store.set(key, value)
92  } catch {
93    // Not persisted; this session still honours it.
94  }
95}
96
97async function command($: EngineInterface, args: string): Promise<string> {
98  const [verb, arg = ''] = args.trim().toLowerCase().split(/\s+/)
99  if (verb === 'on' || verb === 'off') {
100    isEnabled = verb === 'on'
101    isMuted = false
102    await remember($, 'enabled', isEnabled)
103    $.clock.after(10, () => void reconcile($).catch(() => undefined))
104    if (!isEnabled) stop()
105    await publish($)
106    return isEnabled ? `♪ Lofi on: ${await currentMood($)} plays while Claude works.` : 'Lofi off.'
107  }
108  if (verb === 'mood') {
109    if (arg !== 'auto' && !isMood(arg)) return `Moods: auto, ${MOODS.join(', ')}.`
110    moodSetting = arg
111    await remember($, 'mood', arg)
112    $.clock.after(10, () => void reconcile($).catch(() => undefined))
113    return `Lofi mood: ${arg}${arg === 'auto' ? ` (now ${await currentMood($)})` : ''}.`
114  }
115  if (verb === 'status') {
116    const mood = await currentMood($)
117    return `Lofi is ${isEnabled ? 'on' : 'off'}; mood ${moodSetting}${moodSetting === 'auto' ? ` (${mood})` : ''}; ${player ? `playing ${player.mood}` : isMuted ? 'no audio player on this surface' : 'silent until Claude works'}.`
118  }
119  // Bare /lofi: show or hide the band.
120  const isHidden = !(await read($, hiddenAtom))
121  await setHidden($, isHidden)
122  return `${isHidden ? 'Lofi band hidden.' : 'Lofi band shown.'}${isEnabled ? '' : ' Music is off: /lofi on.'}`
123}
124
125export const register: Register = (on, options) => {
126  on('session.start', async ($, e, next) => {
127    const started = await next(e)
128    stop()
129    isWorking = false
130    isMuted = false
131    lastKey = ''
132    isEnabled = options.enabled === true
133    moodSetting = moodSettingOf(options.mood)
134    gain = gainOf(options.volume)
135    await $.command.register({ name: 'lofi', description: 'KOZMOS: lo-fi music while Claude works (on, off, mood <m>, status; bare toggles the band)', argumentHint: '[on|off|mood <auto|focus|deep|night|sunny>|status]', immediate: true })
136    try {
137      const enabled = await $.store.get('enabled')
138      if (typeof enabled === 'boolean') isEnabled = enabled
139      const mood = await $.store.get('mood')
140      if (typeof mood === 'string' && (mood === 'auto' || isMood(mood))) moodSetting = mood
141      const hidden = await $.store.get('hidden')
142      if (typeof hidden === 'boolean') await update($, hiddenAtom, () => hidden)
143    } catch {
144      // A fresh store: the userConfig values stand.
145    }
146    await publish($).catch(() => undefined)
147    // The tick catches the hour turning (auto mood) and a loop that needs restarting.
148    $.clock.every(1000, () => void reconcile($).catch(() => undefined))
149    return started
150  })
151
152  on('command.run', { command: 'lofi' }, async ($, e) => ({ text: await command($, e.args) }))
153
154  on('turn.start', async ($, e, next) => {
155    isWorking = true
156    isMuted = false
157    // Started from a timer, not from this hook: the loop outlives the hook.
158    $.clock.after(10, () => void reconcile($).catch(() => undefined))
159    return next(e)
160  })
161
162  on('turn.complete', async ($, e, next) => {
163    if (e.agentId === undefined) {
164      isWorking = false
165      stop()
166      void publish($).catch(() => undefined)
167    }
168    return next(e)
169  })
170
171  on('session.end', async ($, e, next) => {
172    stop()
173    return next(e)
174  })
175
176  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
177    const drawn = await next(e)
178    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
179    const snap = await read($, snapAtom)
180    if (!snap || !snap.isPlaying) return drawn
181
182    const { Box, Button } = $.ui.resolve(e)
183    const hide = <Button key="lofi-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
184
185    if (e.surface === 'terminal') {
186      const { Client } = $.ui.resolve(e)
187      const bands = Math.max(6, Math.min(16, (e.props.bodyColumns || 80) - 24))
188      return (
189        <Box flexDirection="column">
190          {drawn}
191          <Box flexDirection="row">
192            <Client key="lofi" module="./eq.tsx" width={14 + snap.mood.length + bands} height={1} props={{ mood: snap.mood, bands }} />
193            {hide}
194          </Box>
195        </Box>
196      )
197    }
198
199    // Desktop, VS Code and mobile: one animated Svg.
200    const { Svg } = $.ui.resolve(e)
201    const W = Math.min(pxOf(e.props.bodyColumns) - 28, 260)
202    return (
203      <Box flexDirection="column">
204        {drawn}
205        <Box flexDirection="row" alignItems="center">
206          <Svg source={lofiSvg(snap.mood, W, await $.clock.now(), snap.since)} alt={`Lofi playing: ${snap.mood}`} width={W} height={30} />
207          {hide}
208        </Box>
209      </Box>
210    )
211  })
212}
213
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
hooks/mood.ts 99 lines
1// Lofi's pure parts: moods, the hour that picks one, the equalizer frame and
2// the desktop SVG. No `$` here.
3import type { LofiMood } from '../types'
4import { mix, svg, svgText } from './lib/kz.ts'
5
6export type { LofiMood }
7
8export const MOODS: readonly LofiMood[] = ['focus', 'deep', 'night', 'sunny']
9
10/** Tempo of each loop (as synthesized by tools/make-loops.mjs). */
11export const BPM: Record<LofiMood, number> = { focus: 78, deep: 68, night: 62, sunny: 86 }
12
13/** Two colors per mood: the low and the high end of the equalizer. */
14export const TINT: Record<LofiMood, [string, string]> = {
15  focus: ['#7c3aed', '#22d3ee'],
16  deep: ['#1e40af', '#818cf8'],
17  night: ['#6d28d9', '#f472b6'],
18  sunny: ['#f97316', '#fde047'],
19}
20
21export function isMood(x: unknown): x is LofiMood {
22  return typeof x === 'string' && (MOODS as readonly string[]).includes(x)
23}
24
25/** auto: sunny 06–11, focus 11–18, deep 18–22, night 22–06. */
26export function moodForHour(hour: number): LofiMood {
27  if (hour >= 6 && hour < 11) return 'sunny'
28  if (hour >= 11 && hour < 18) return 'focus'
29  if (hour >= 18 && hour < 22) return 'deep'
30  return 'night'
31}
32
33/** The `mood` option as a setting: `auto` unless it is text. */
34export function moodSettingOf(v: unknown): string {
35  return typeof v === 'string' ? v : 'auto'
36}
37
38export function resolveMood(setting: string, hour: number): LofiMood {
39  return isMood(setting) ? setting : moodForHour(hour)
40}
41
42export function assetOf(mood: LofiMood): string {
43  return `sounds/${mood}.wav`
44}
45
46/** 0..100 → the engine's linear gain (0..4); 40 → about half. */
47export function gainOf(volume: unknown): number {
48  const v = typeof volume === 'number' && Number.isFinite(volume) ? Math.max(0, Math.min(100, volume)) : 40
49  return Math.round((v / 100) * 1.25 * 100) / 100
50}
51
52const BARS = ' ▁▂▃▄▅▆▇█'
53
54/** Equalizer heights 0..1 for `n` bands at frame `t` (30 fps): a beat bump over drifting sines. */
55export function eqLevels(n: number, t: number, bpm: number): number[] {
56  const beat = (t / 30) * (bpm / 60)
57  const bump = Math.exp(-(beat % 1) * 5)
58  return Array.from({ length: n }, (_, i) => {
59    const low = 1 - i / Math.max(1, n - 1)
60    const wave = 0.35 + 0.25 * Math.sin(t * 0.11 + i * 1.7) + 0.2 * Math.sin(t * 0.23 + i * 0.6)
61    return Math.max(0.05, Math.min(1, wave + bump * (0.25 + 0.35 * low)))
62  })
63}
64
65export function eqBars(levels: readonly number[], mood: LofiMood): { s: string; c: string }[] {
66  const [lo, hi] = TINT[mood]
67  return levels.map(v => ({ s: BARS.charAt(Math.max(1, Math.min(8, Math.round(v * 8)))), c: mix(lo, hi, v) }))
68}
69
70/** One row: a pill with the note, `lofi · mood`, and bars dancing by CSS at the loop's tempo. */
71export function lofiSvg(mood: LofiMood, W: number, now: number, since: number): string {
72  const H = 30
73  const [lo, hi] = TINT[mood]
74  const beat = 60 / BPM[mood]
75  const n = 16
76  const label = `lofi · ${mood}`
77  const pillW = 128 + n * 6
78  const x0 = 116
79  const t = (now - since) / 1000
80  const bars: string[] = []
81  for (let i = 0; i < n; i++) {
82    const dur = beat * (i % 3 === 0 ? 1 : i % 3 === 1 ? 0.5 : 2)
83    const delay = -((t + i * 0.137) % dur)
84    bars.push(`<rect class="eq" x="${x0 + i * 6}" y="6" width="4" height="18" rx="2" fill="url(#lfG)" style="animation-duration:${dur.toFixed(3)}s;animation-delay:${delay.toFixed(3)}s"/>`)
85  }
86  const css = `
87.eq{transform-box:fill-box;transform-origin:50% 100%;animation:eq ease-in-out infinite alternate}
88@keyframes eq{0%{transform:scaleY(.18)}55%{transform:scaleY(1)}100%{transform:scaleY(.4)}}
89.nt{animation:nt ${(beat * 2).toFixed(2)}s ease-in-out infinite}@keyframes nt{50%{transform:translateY(-2px)}}
90.rim{stroke:${lo};stroke-opacity:.35}`
91  const body = `<defs><linearGradient id="lfG" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stop-color="${lo}"/><stop offset="1" stop-color="${hi}"/></linearGradient></defs>
92<rect class="p" x="0" y="1" width="${Math.min(W, pillW)}" height="28" rx="14"/>
93<rect class="rim" x=".5" y="1.5" width="${Math.min(W, pillW) - 1}" height="27" rx="13.5" fill="none"/>
94<g class="nt">${svgText(12, 20, '♪', { size: 15, weight: 700, fill: hi })}</g>
95${svgText(30, 19.5, label, { size: 12, weight: 650 })}
96${bars.join('')}`
97  return svg(W, H, body, css)
98}
99
hooks/eq.tsx 41 lines
1// Lofi's terminal band: one row, a bobbing note, `lofi · mood` and an
2// equalizer that bumps on the loop's beat. ~30 fps on the surface; no `$`.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import { BPM, TINT, eqBars, eqLevels, isMood } from './mood.ts'
6
7export type EqProps = { mood: string; bands: number }
8type S = { t: number }
9
10/** The first frame: state at 0 and a ~30 fps tick that counts frames. */
11function start(surface: ClientSurface<S>): S {
12  let frame = 0
13  surface.setState({ t: frame })
14  surface.every(33, () => surface.setState({ t: ++frame }))
15  return { t: frame }
16}
17
18const Eq: ClientModule<EqProps, S> = (props, surface) => {
19  const { t } = surface.state ?? start(surface)
20  const mood = isMood(props.mood) ? props.mood : 'focus'
21  const bpm = BPM[mood]
22  const beat = Math.floor((t / 30) * (bpm / 60))
23  const bars = eqBars(eqLevels(Math.max(4, props.bands), t, bpm), mood)
24  const [, hi] = TINT[mood]
25  const { Text } = surface.elements
26  return (
27    <Text wrap="truncate-end">
28      <Text color={hi} bold>{beat % 2 ? '♫' : '♪'}</Text>
29      <Text> lofi</Text>
30      <Text dimColor> · </Text>
31      <Text color={hi}>{mood}</Text>
32      <Text>  </Text>
33      {bars.map((b, i) => (
34        <Text key={`b${i}`} color={b.c}>{b.s}</Text>
35      ))}
36    </Text>
37  )
38}
39
40export default Eq
41
types/index.d.ts 19 lines
1export type LofiMood = 'focus' | 'deep' | 'night' | 'sunny'
2
3export type LofiSnap = {
4  /** True while a loop is playing. */
5  isPlaying: boolean
6  /** The mood playing, or the one that would. */
7  mood: LofiMood
8  /** Music is opt-in: `/lofi on`. */
9  isEnabled: boolean
10  /** The clock when the loop started (ms); the desktop bars keep their phase from it. */
11  since: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    lofi: { snap: LofiSnap | null; isHidden: boolean }
17  }
18}
19