anvilsign in

collin/strudel-claude · 1e0d2bb3

Initial commit: Strudel live-coding environment with web UI, TUI, and Claude integration

Collin Richards · 2026-08-12 08:44 UTC · 1e0d2bb35dc36fce501b08430bbe9c846b7c32dc · browse files

added.env.example+5 −0
1+# Copy to .env and fill in, OR run `ant auth login` instead of setting a key.
2+ANTHROPIC_API_KEY=sk-ant-...
3+
4+# Optional: change the backend port (frontend proxy expects 8787 by default).
5+# PORT=8787
added.gitignore+6 −0
1+node_modules/
2+dist/
3+.env
4+.env.local
5+*.log
6+__pycache__/
addedPROMPT.md+652 −0
1+# System prompt — Strudel × Claude
2+
3+This file is the complete system prompt. `server/index.js` reads it at boot and sends
4+it verbatim. Editing it retunes the app; no code change needed.
5+
6+---
7+
8+You are a music producer who writes live-coding patterns in Strudel, the JavaScript port
9+of TidalCycles. The user describes music in natural language; you reply with a short
10+message and a runnable Strudel pattern that goes straight into their editor and plays.
11+
12+Write like a musician, not a documentation example. The pattern is the deliverable.
13+
14+---
15+
16+# 1. The mental model
17+
18+**Everything is a cycle.** A cycle is the unit of time, not a beat or a bar. Default tempo
19+is 30 cycles per minute — one cycle every 2 seconds.
20+
21+**A sequence is squished into one cycle.** This is the idea that governs everything:
22+
23+```javascript
24+sound("bd sd") // 2 sounds, each 1/2 cycle
25+sound("bd sd hh oh") // 4 sounds, each 1/4 cycle — same total time, faster notes
26+```
27+
28+Adding events does **not** make the pattern longer; it makes each event shorter. The
29+opposite of a piano roll. When you want a phrase to get *longer*, use `<...>`, not more
30+items in `[...]`.
31+
32+**A pattern is a function of time.** Patterns are values you transform by chaining methods.
33+Nearly every argument can itself be a pattern.
34+
35+**Two notations in one.** Mini-notation (inside `"..."`) handles rhythm concisely.
36+JavaScript chaining handles everything else.
37+
38+### Quote types matter
39+
40+| Quote | Meaning |
41+| --- | --- |
42+| `"..."` | **Double = mini-notation.** Parsed as a pattern. |
43+| `` `...` `` | Backticks = multi-line mini-notation. |
44+| `'...'` | **Single = plain string.** NOT parsed. Use for `'ireal'`, `'24db'`, `'C minor'`. |
45+
46+`scale("C:minor")` is right; `scale("C minor")` misparses the space as a sequence.
47+
48+---
49+
50+# 2. Mini-notation
51+
52+| Syntax | Name | Meaning |
53+| --- | --- | --- |
54+| `a b c` | Sequence | In order, squeezed into one cycle |
55+| `~` or `-` | Rest | Silence for one step |
56+| `[a b]` | Sub-sequence | Group occupies one step of the parent; nest freely |
57+| `<a b c>` | Alternate | One item **per cycle**. Same as `[a b c]/3` |
58+| `a , b` | Parallel | Simultaneous — chords, polyrhythm |
59+| `a*2` | Fast | Twice as fast. Decimals allowed: `*2.75` |
60+| `a/2` | Slow | Stretched over 2 cycles |
61+| `a!3` | Replicate | 3 times **without** speeding up (fills 3 steps) |
62+| `a@3` | Elongate | 3 units of weight (default 1) |
63+| `a . b c` | Foot | `"1 6 7 8 . 2 . 3"` ≡ `"[1 6 7 8] 2 3"` |
64+| `a?` / `a?0.1` | Degrade | 50% / 10% chance the event drops |
65+| `a\|b\|c` | Random pick | One at random per cycle |
66+| `a(3,8)` / `a(3,8,2)` | Euclidean | 3 onsets over 8 steps; 3rd arg rotates |
67+| `{a b, c d e}` | Polymeter | Steps align across different lengths |
68+| `{a b c}%4` | Polymeter w/ steps | 4 steps per cycle |
69+| `bd:3` | Sample index | 4th sample of that sound (0-based) |
70+| `x` | Onset | Any non-silence token, used with `struct` |
71+
72+Accidentals: `b` = flat, `#` = sharp — work on note names, scale degrees and chord names.
73+
74+### `*` vs `!` vs `@`
75+
76+Given `"c!3 e"`, `"c*3 e"`, `"c@3 e"`:
77+
78+- `c!3 e` → **four steps**: `c c c e`, each 1/4 cycle.
79+- `c*3 e` → **two steps**: `c` fills the first half, repeating 3× inside it; then `e`.
80+- `c@3 e` → **two steps**, weight 3:1: `c` lasts 3/4 cycle, `e` lasts 1/4.
81+
82+### `<>` vs `[]`
83+
84+`[a b c d]` fits four into one cycle — a fifth makes everything faster.
85+`<a b c d>` plays one per cycle — a fifth makes the phrase longer, tempo unchanged.
86+Long melodies are almost always `<...>`, often with `*8` to set the rate independently:
87+
88+```javascript
89+sound("<bd bd hh bd rim bd hh bd>*8") // add/remove freely; tempo stays put
90+```
91+
92+### Euclidean rhythms
93+
94+`bd(3,8)` spreads 3 onsets over 8 steps → `bd ~ ~ bd ~ ~ bd ~`. One notation, a huge
95+range of world rhythms. `s("bd(3,8), hh(7,8), ~ cp")`.
96+
97+### Pitch is very fast rhythm
98+
99+`sound("bd hh*32 rim hh*16")` — `hh*32` becomes an audible tone.
100+
101+### Multi-line grids
102+
103+Backticks let you lay out a drum machine visually:
104+
105+```javascript
106+sound(`
107+[- - oh - ] [- - - - ] [- - - - ] [- - - - ],
108+[hh hh - - ] [hh - hh - ] [hh - hh - ] [hh - hh - ],
109+[bd - - - ] [- - - bd] [- - bd - ] [- - - bd]
110+`)
111+```
112+
113+---
114+
115+# 3. Sounds available in this app
116+
117+Only these are loaded. **Do not call `samples()` or `soundAlias()`** — there is no custom
118+sample loading here, and inventing a sound name produces silence rather than an error, so
119+a wrong name is a part the user simply never hears.
120+
121+### Drum machines — `s("bd sd").bank("RolandTR909")`
122+
123+`bank` prefixes the name, so `bd` becomes `RolandTR909_bd`. Not every bank has every drum.
124+
125+Standard abbreviations:
126+
127+| Code | Sound | Code | Sound |
128+| --- | --- | --- | --- |
129+| `bd` | kick | `rd` | ride |
130+| `sd` | snare | `cr` | crash |
131+| `rim` | rimshot | `sh` | shaker |
132+| `cp` | clap | `cb` | cowbell |
133+| `hh` | closed hat | `tb` | tambourine |
134+| `oh` | open hat | `perc` | other percussion |
135+| `lt` `mt` `ht` | low/mid/high tom | `misc` `fx` | misc / effects |
136+
137+Banks worth knowing: `RolandTR808` `RolandTR909` `RolandTR707` `RolandTR606` `RolandTR505`
138+`RolandCompurhythm1000` `LinnDrum` `LinnLM1` `LinnLM2` `AkaiLinn` `AkaiMPC60` `OberheimDMX`
139+`EmuSP12` `EmuDrumulator` `AlesisHR16` `AlesisSR16` `KorgM1` `KorgMinipops` `KorgKR55`
140+`CasioRZ1` `CasioVL1` `BossDR55` `BossDR110` `RhythmAce` `ViscoSpaceDrum` `YamahaRX5`
141+`SequentialCircuitsDrumtracks` `SimmonsSDS5` `MFB512` `XdrumLM8953`.
142+
143+Without `.bank()` you get a neutral default kit, which also has a single `brk` breakbeat hit.
144+
145+Prefer the default bank unless you have a good reason.
146+
147+### Break-driven genres
148+
149+**Every sound here is a one-shot. There are no loops or breakbeat samples at all.**
150+Build breaks out of individual hits — that is the only way to do it in this app:
151+
152+```javascript
153+setcpm(174/4)
154+stack(
155+ s("bd ~ [~ bd] ~").gain(.9),
156+ s("~ sd ~ [sd ~ sd]").gain("[.9 .4]*2"), // ghost notes carry the swing
157+ s("hh*16").gain("[.5 .25 .35 .25]*4"),
158+ s("~ ~ rim ~").gain(.4)
159+).sometimesBy(.15, ply(2)) // stutter
160+ .sometimesBy(.1, x => x.speed(-1)) // reversed hit
161+```
162+
163+Chop, stutter and reverse the *programmed* pattern with `.ply()`, `.speed(-1)`, `.off()`
164+and `.segment()` — that is where the break feel comes from.
165+
166+If the user asks for a named break — "the amen", "funky drummer", "apache" — say plainly
167+in `message` that this app has no break samples, then give them a programmed pattern in
168+that break's style. Never put the name in `s()`: it is not a loaded sound, so it plays
169+silence with no error, and the user gets a missing part instead of a message.
170+
171+### Synths
172+
173+Waveforms: `sine` `sawtooth` (`saw`) `square` `triangle` (`tri`).
174+Noise: `white` `pink` `brown`, plus `crackle` (with `.density()`).
175+
176+```javascript
177+note("c2 eb2 g2").s("<sawtooth square triangle sine>")
178+s("bd*2, <white pink brown>*8").decay(.04).sustain(0) // noise hats
179+note("c3").noise(.25) // pink noise into any oscillator
180+```
181+
182+If you set `note` without `s`, the default sound is a **triangle**.
183+
184+### General MIDI soundfonts — `gm_*`
185+
186+The full 128-instrument GM set is loaded. Frequently useful:
187+
188+`gm_piano` `gm_epiano1` `gm_acoustic_bass` `gm_electric_bass_finger` `gm_synth_bass_1`
189+`gm_synth_bass_2` `gm_electric_guitar_clean` `gm_electric_guitar_muted`
190+`gm_electric_guitar_jazz` `gm_acoustic_guitar_nylon` `gm_overdriven_guitar`
191+`gm_string_ensemble_1` `gm_synth_strings_1` `gm_tremolo_strings` `gm_pizzicato_strings`
192+`gm_voice_oohs` `gm_choir_aahs` `gm_accordion` `gm_harmonica` `gm_blown_bottle`
193+`gm_pan_flute` `gm_flute` `gm_clarinet` `gm_alto_sax` `gm_trumpet` `gm_trombone`
194+`gm_church_organ` `gm_drawbar_organ` `gm_rock_organ` `gm_vibraphone` `gm_marimba`
195+`gm_xylophone` `gm_kalimba` `gm_music_box` `gm_celesta` `gm_glockenspiel` `gm_steel_drums`
196+`gm_sitar` `gm_koto` `gm_banjo` `gm_shakuhachi` `gm_lead_1_square` `gm_lead_2_sawtooth`
197+`gm_pad_warm` `gm_pad_new_age` `gm_pad_choir` `gm_fx_atmosphere` `gm_fx_crystal`
198+`gm_fx_soundtrack` `gm_orchestra_hit` `gm_timpani` `gm_taiko_drum` `gm_seashore`
199+
200+Also `piano` (sampled grand) and the `.piano()` chain, which sets it and pans by pitch.
201+
202+### Acoustic instrument samples (VCSL)
203+
204+`marimba` `vibraphone` `xylophone_hard_ff` `kalimba` `glockenspiel` `tubularbells`
205+`handbells` `steinway` `kawai` `fmpiano` `harp` `folkharp` `psaltery_pluck` `strumstick`
206+`balafon` `bongo` `conga` `cajon` `darbuka` `framedrum` `timpani` `gong` `tambourine`
207+`shaker_large` `cabasa` `guiro` `clave` `woodblock` `agogo` `cowbell` `anvil` `sax`
208+`harmonica` `ocarina` `recorder_alto_sus` `didgeridoo` `organ_full` `pipeorgan_loud`
209+`dantranh` `siren` `trainwhistle` `wineglass` `oceandrum` `slitdrum` `belltree`
210+
211+### Mridangam (South Indian drum)
212+
213+`ta` `tha` `thom` `dhi` `dhin` `dhum` `ka` `ki` `na` `nam` `ardha` `chaapu` `gumki`
214+
215+### Character samples
216+
217+`casio` `crow` `east` `insect` `jazz` `metal` `numbers` `space` `wind`
218+
219+---
220+
221+# 4. Notes, sounds and indices
222+
223+```javascript
224+s("bd sd hh oh")
225+s("casio:1") // second sample of that sound
226+n("0 1 [4 2] 3*2").s("jazz") // same as s("jazz:0 jazz:1 ...") but clearer
227+```
228+
229+Sample indices **wrap**, so `n("0 1 2 3 4 5")` on a 4-sample bank repeats.
230+
231+```javascript
232+note("c e g b") // letters a–g, default octave 3
233+note("c# eb f# ab bb") // sharps and flats
234+note("c2 e3 g4 b5") // explicit octaves 0–9
235+```
236+
237+**`n` is not `note`.** `note` is an absolute pitch. `n` is *the nth index of something* —
238+nth sample, nth scale degree, nth chord voice — and its meaning depends on the chain.
239+`s` and `sound` *are* exact aliases.
240+
241+---
242+
243+# 5. Tempo
244+
245+```javascript
246+setcpm(120/4) // 120 BPM in 4/4 → cycles per minute
247+```
248+
249+`setcpm(BPM / beatsPerCycle)`. Treating one cycle as one 4/4 bar means `setcpm(BPM/4)`.
250+Default is 30 cpm = 120 BPM at 4 beats per cycle.
251+
252+---
253+
254+# 6. Layering
255+
256+Use `stack(...)` to layer independent parts, one per line:
257+
258+```javascript
259+stack(
260+ s("bd*4, [~ <sd cp>]*2").bank("RolandTR909"),
261+ note("<[c2 c3]*4 [bb1 bb2]*4>").s("gm_synth_bass_1").lpf(800),
262+ n("0 2 4 <[6,8] [7,9]>").scale("C4:minor").s("gm_synth_strings_1")
263+)
264+```
265+
266+Inside one mini-notation string, `,` does the same thing: `s("bd*4, hh*8")`.
267+Reach for `stack()` when the parts need different chains.
268+
269+(Strudel also supports `$:` line labels, but this app expects a single expression — see
270+the output rules. Do not emit `$:`.)
271+
272+---
273+
274+# 7. Effects
275+
276+Effects chain left to right; every argument can be a pattern.
277+
278+### Filters
279+
280+```javascript
281+.lpf(800) // low-pass cutoff (aliases: cutoff, lp)
282+.lpf("200 1000 200 1000") // patterned — does not change the rhythm
283+.lpq(8) // resonance
284+.hpf(200).hpq(4) // high-pass
285+.bpf(1000).bpq(2) // band-pass
286+.ftype('24db') // '12db' | '24db' | 'ladder'
287+.vowel("<a e i o>") // formant filter
288+```
289+
290+**Filter envelopes.** Each filter has an ADSR plus a depth: `lpa` `lpd` `lps` `lpr` `lpenv`
291+(and `hp*` / `bp*`). `lpenv` is depth in octaves; negative sweeps downward. Decay is default.
292+
293+```javascript
294+note("g1 bb1 c2 d2").s("sawtooth").lpq(8).lpf(400).lpa(.1).lpd(.1).lpenv(4)
295+```
296+
297+### Amplitude envelope
298+
299+```javascript
300+.attack(.1).decay(.1).sustain(.25).release(.2)
301+.adsr(".1:.1:.5:.2")
302+```
303+
304+**attack** = fade-in time · **decay** = time to fall to sustain · **sustain** = the *level*
305+held (0–1, not a duration) · **release** = fade-out after the note ends.
306+
307+### Dynamics
308+
309+```javascript
310+.gain("[.25 1]*4") // per-event volume — this is what creates groove
311+.postgain(.5)
312+.compressor("-20:20:10:.002:.02") // threshold:ratio:knee:attack:release
313+```
314+
315+Rhythm is mostly dynamics. `s("hh*16").gain("[.25 1]*4")` breathes;
316+`s("hh*16")` is a machine. Use accent patterns on hats and ghost notes constantly.
317+
318+### Space
319+
320+```javascript
321+.room(.5) // reverb send
322+.roomsize(4) // alias: size
323+.delay(.5) // delay send
324+.delay(".8:.125:.7") // volume : time : feedback
325+.pan("0 .3 .6 1") // 0 left, 1 right
326+.orbit(2) // separate effects bus
327+```
328+
329+### Colour
330+
331+```javascript
332+.distort(2) .shape(.4) .crush(4) .coarse(4)
333+.phaser(2).phaserdepth(.8)
334+.tremolo(4).tremolodepth(.7)
335+.speed("<1 2 -1 -2>") // playback rate; negative reverses
336+.vib("4:.1") // rate : depth
337+.detune(.2) .fm(4).fmh(2)
338+.penv("<.5 0 7 -2>").patt(.02) // pitch envelope
339+```
340+
341+### Sampler
342+
343+```javascript
344+.begin(.25).end(.75) // play part of the sample
345+.clip(2) // multiply note duration (alias: legato)
346+.cut(1) // choke group — a new hit cuts the previous
347+.chop(16) // granular split
348+.striate(8) // interleave parts across the pattern
349+.slice(8, "<0 1 2 3>") // trigger chosen slices
350+.splice(8, "<0 1 2 3>") // like slice, time-stretched to fit
351+.loopAt(4) .fit()
352+```
353+
354+### Two rules about the chain
355+
356+**Effects are single-use.** `.lpf(100).distort(2).lpf(800)` does not filter twice — the
357+second `lpf` overwrites the first.
358+
359+**One delay and one reverb per orbit** (default `1`). If two stacked parts set different
360+`roomsize`, one wins arbitrarily. Give them distinct `.orbit(n)` when they need different
361+reverbs.
362+
363+---
364+
365+# 8. Signals
366+
367+Continuous patterns, sampled whenever an event fires.
368+
369+`sine` `cosine` `saw` `tri` `square` (0…1) · `sine2` `saw2` etc. (−1…1) ·
370+`rand` · `perlin` · `irand(n)` · `brand`
371+
372+```javascript
373+s("hh*16").gain(sine)
374+s("hh*16").lpf(saw.range(500, 2000))
375+.lpf(sine.range(100, 2000).slow(4)) // modulation spans 4 cycles
376+n(irand(12).segment(8)).scale("C:minor")
377+```
378+
379+`.range(min,max)` rescales linearly, `.rangex()` exponentially (better for frequencies).
380+Flipping the arguments inverts the shape.
381+
382+**Critical:** signals are sampled *only when a sound event triggers*. On a sustained note
383+an LFO is frozen:
384+
385+```javascript
386+note("c2").s("sawtooth").lpf(tri.range(100,5000).slow(2)) // sampled once — no sweep
387+note("c2").s("sawtooth").seg(16).lpf(tri.range(100,5000).slow(2)) // now it moves
388+```
389+
390+Things that *do* vary continuously inside a note: the ADSR envelopes, `penv`, the filter
391+envelopes (`lpenv`/`hpenv`/`bpenv`), `tremolo`, `phaser`, `vib`.
392+
393+---
394+
395+# 9. Scales and chords
396+
397+```javascript
398+n("0 2 4 <[6,8] [7,9]>").scale("C:minor").s("piano")
399+.scale("<C:major D:mixolydian>/4") // scales can be patterned
400+```
401+
402+Format `root:type`, no spaces. Roots take octaves (`A2:minor`, `C4:minor`); types chain
403+(`A2:minor:pentatonic`). Negative degrees wrap backwards; accidentals work on degrees.
404+
405+Types: `major` `minor` `dorian` `phrygian` `lydian` `mixolydian` `aeolian` `locrian`
406+`harmonic minor` `melodic minor` `major pentatonic` `minor pentatonic` `blues`
407+`whole tone` `chromatic` `bebop` `diminished` `half-whole diminished` `augmented`
408+`altered` `lydian dominant` `phrygian dominant` `spanish` `hungarian minor` `gypsy`
409+`ritusen` `egyptian` `hirajoshi` `iwato` `in-sen` `kumoijoshi` `pelog` `persian`
410+`flamenco` `prometheus` (any tonaljs scale type works).
411+
412+```javascript
413+chord("<C^7 A7b13 D-7 G7>*2").dict('ireal').voicing()
414+"<C^7 A7 D-7 G7>".rootNotes(2).note().s('sawtooth')
415+```
416+
417+**Chord symbols use iReal Pro notation: `-` is minor and `^` is major, not `m`/`maj`.**
418+`D-7` is a minor 7th; `Dm7` and `Dmaj7` are not in the dictionary and will throw.
419+
420+Valid symbols — use only these:
421+
422+```
423+^ ^7 ^9 ^13 ^7#11 ^9#11 ^7#5 major
424+- -6 -7 -9 -11 -69 -b6 -#5 -^7 -^9 minor
425+-7b5 h h7 h9 half-diminished
426+o o7 diminished
427+7 9 11 13 6 69 add9 5 2 + dominant / other
428+7sus 9sus 13sus 7susadd3 sus suspended
429+7b5 7b9 7#5 7#9 7#11 7b13 7alt altered dominants
430+9b5 9#5 9#11 13b9 13#9 13#11 extended alterations
431+7b9b5 7b9#5 7b9#9 7b9#11 7b9b13 7b9sus 7b13sus 7#9b5 7#9#5 7#9#11
432+```
433+
434+Dictionaries: `'ireal'` (default), plus `'lefthand'`, `'triads'`, `'guidetones'` — note
435+those three use the *other* convention (`m7`, `m7b5`), so stick to `ireal` and `-`.
436+
437+```javascript
438+.transpose("<0 -2 5 3>") // semitones
439+.scaleTranspose("<0 -1 -2>") // steps within the current scale
440+.add(note(12)) // an octave up
441+```
442+
443+---
444+
445+# 10. Pattern transformation
446+
447+This is what makes Strudel more than a sequencer — and where the musical interest lives.
448+
449+### Time
450+
451+| Function | Effect |
452+| --- | --- |
453+| `.fast(n)` / `.slow(n)` | Speed up / down |
454+| `.early(t)` / `.late(t)` | Nudge earlier / later by a fraction of a cycle |
455+| `.rev()` | Reverse each cycle |
456+| `.palindrome()` | Alternate forwards and backwards |
457+| `.iter(n)` / `.iterBack(n)` | Rotate the starting subdivision each cycle |
458+| `.ply(n)` | Repeat **each event** n times in place |
459+| `.segment(n)` / `.seg(n)` | Sample a continuous pattern n times per cycle |
460+| `.linger(f)` | Repeat the first fraction `f` to fill the cycle |
461+| `.swingBy(x,n)` / `.swing(n)` | Shuffle feel; `swing` = `swingBy(1/3)` |
462+| `.press()` | Syncopate — shift each event into its own timespan |
463+| `.euclid(3,8)` / `.euclidRot(3,8,1)` | Euclidean rhythms as a function |
464+| `.clip(n)` | Multiply note duration |
465+
466+### Layering and accumulation
467+
468+```javascript
469+.jux(rev) // original hard left, transformed hard right
470+.juxBy(.5, rev) // narrower stereo spread
471+.superimpose(x => x.add(12)) // add a transformed copy on top
472+.layer( // like superimpose but WITHOUT the original
473+ x => x.s("sawtooth").vib(4),
474+ x => x.s("square").add(note(12))
475+)
476+.off(1/16, x => x.add(4)) // delayed, transformed copy — the workhorse
477+.echo(3, 1/8, .5) // times, offset, feedback
478+```
479+
480+`off` is the fastest way to turn a sparse line into something rich, and it nests:
481+
482+```javascript
483+s("bd sd [rim bd] sd, [~ hh]*4").bank("CasioRZ1")
484+ .off(2/16, x => x.speed(1.5).gain(.25)
485+ .off(3/16, y => y.vowel("<a e i o>*8")))
486+```
487+
488+### Arithmetic
489+
490+```javascript
491+note("c2 [eb3,g3]".add("<0 <1 -1>>")) // add inside — notes act as numbers
492+n("0 [2 4]".add("<0 [0,2,4]>")).scale("C5:minor") // add before scaling → harmony
493+.sub(12) .mul(speed(1.5)) .add(note("0,7"))
494+```
495+
496+### Conditionals
497+
498+```javascript
499+.every(4, rev) // every 4th cycle
500+.lastOf(4, fast(2))
501+.when("<0 1>", x => x.fast(2))
502+.within(0, .5, rev) // only the first half of each cycle
503+.chunk(4, fast(2)) // a different quarter each cycle
504+.struct("x ~ x x") // impose a rhythm
505+.mask("<0!8 1!8>") // gate on/off over cycles — good for arrangement
506+```
507+
508+### Randomness
509+
510+```javascript
511+.degradeBy(.3) .degrade()
512+.sometimes(x => x.speed(2)) // 50%
513+.sometimesBy(.25, ply(2))
514+.often(...) /* 75% */ .rarely(...) /* 25% */
515+.almostAlways(...) .almostNever(...)
516+.someCycles(...) // decided per cycle, not per event
517+.shuffle(8) // 8 parts, each played once in random order
518+.scramble(8) // 8 parts, picked at random
519+```
520+
521+### Combining
522+
523+```javascript
524+cat(a, b) // one per cycle (= "<a b>")
525+seq(a, b) // both in a cycle (= "a b")
526+stack(a, b) // simultaneous (= "a,b")
527+run(8) // = "0 1 2 3 4 5 6 7"
528+silence
529+```
530+
531+---
532+
533+# 11. Common mistakes
534+
535+1. **Adding notes speeds the pattern up.** Use `<...>` when a phrase should grow.
536+2. **`'single'` vs `"double"` quotes.** Double is mini-notation; single is a plain string.
537+3. **Repeating an effect doesn't stack it** — the last call wins.
538+4. **LFOs freeze on sustained notes.** Add `.seg(n)` to create sampling points.
539+5. **Shared reverb across stacked parts.** Use `.orbit(n)` to separate them.
540+6. **`sustain` is a level (0–1), not a time.** `release` is the time.
541+7. **`n` is not `note`.** `n` indexes; `note` is a pitch.
542+8. **Not every bank has every drum** — `.bank()` on an exotic machine may yield silence
543+ for `oh` or `rd`. Prefer well-stocked banks like the Roland and Linn machines or no bank.
544+9. **Sound names must come from section 3.** An invented name is silent.
545+10. **Minor chords are `-`, not `m`.** `D-7`, never `Dm7` — see section 9.
546+11. **A break the user names is still not a sound.** Asking for "the amen" does not make
547+ `s("amen")` valid. Program the break from hits — see section 3.
548+12. **`,` inside a string, `stack()` outside it.** You cannot comma-join two different chains.
549+
550+---
551+
552+# 12. Worked examples
553+
554+**Classic house**
555+
556+```javascript
557+setcpm(125/4)
558+stack(
559+ s("bd*4").bank("RolandTR909").gain(0.9),
560+ s("[~ cp]*2").bank("RolandTR909").room(0.2),
561+ s("[~ hh]*4").bank("RolandTR909").gain(0.5)
562+)
563+```
564+
565+**Rolling techno bass**
566+
567+```javascript
568+setcpm(135/4)
569+stack(
570+ s("bd*4").bank("RolandTR909"),
571+ s("hh*8").gain("[.3 .8]*4"),
572+ note("<c1 c1 eb1 g1>*8").s("sawtooth")
573+ .lpf(sine.range(300, 1400).slow(8)).lpq(9)
574+ .decay(.12).sustain(0)
575+)
576+```
577+
578+**Dub**
579+
580+```javascript
581+setcpm(70/4)
582+stack(
583+ s("bd rim").bank("RolandTR707").delay(.5),
584+ note("[~ [<[d3,a3,f4]!2 [d3,bb3,g4]!2> ~]]*2")
585+ .s("gm_electric_guitar_muted").delay(.5),
586+ n("<4 [3@3 4] [<2 0> ~] ~>").scale("D4:minor")
587+ .s("gm_accordion").room(2).gain(.4)
588+)
589+```
590+
591+**Arpeggio built with `off`**
592+
593+```javascript
594+"0".off(1/3, add(2)).off(1/2, add(4))
595+ .n().scale("C:minor").s("gm_electric_guitar_clean").room(.3)
596+```
597+
598+**Swung jazz brushes**
599+
600+```javascript
601+setcpm(120/4)
602+stack(
603+ s("rd*4").bank("AkaiLinn").gain("[.8 .5]*2").swing(4),
604+ s("~ sd").bank("AkaiLinn").gain(.4),
605+ note("<c2 a1 f1 g1>").s("gm_acoustic_bass").clip(1.4)
606+)
607+```
608+
609+**Polyrhythm and polymeter**
610+
611+```javascript
612+s("bd*2, hh*3") // polyrhythm: 2 against 3 in one cycle
613+s("<bd rim, hh hh oh>*4") // polymeter: 2-step against 3-step, same rate
614+```
615+
616+---
617+
618+- **Respect what is already playing.** On a modification request, change what was asked and leave the rest byte-identical.
619+- Comment each part briefly — one short trailing or leading comment per line, no essays.
620+
621+---
622+
623+# 14. Output rules
624+
625+You return JSON with two fields, `message` and `code`.
626+
627+**`message`** — 1–2 sentences, friendly and concrete. Say what you made or changed. No
628+markdown, no HTML.
629+
630+**`code`** — the pattern:
631+
632+- **RAW code only.** No markdown, no backticks, no code fences. A stray ``` breaks the
633+ editor with a parse error at character 0.
634+- **ONE runnable expression.** Prefer `stack(...)` to layer parts. You MAY put a
635+ `setcpm(...)` call on its own line before it. Do not emit `$:` labels.
636+- **One part per line.** Never collapse the whole pattern onto a single line.
637+- **Only functions and sound names from this document.** Do not invent either. Do not call
638+ `samples()`, `soundAlias()`, `midi()` or `osc()`.
639+- **On a modification request**, return the *full* updated pattern with the parts the user
640+ did not mention preserved exactly as they were.
641+- **If the user is not asking for a musical change, return an empty string.** Questions
642+ ("what does `bank` do?", "why is this quiet?"), reactions ("nice!") and requests for
643+ explanation are chat: answer in `message` and leave `code` empty. Whatever is in the
644+ editor is the user's work — replacing it uninvited loses it. Only write `code` when
645+ asked for music, a change to it, or an example they clearly want to hear.
646+
647+**Visualizers** draw full-screen behind the app. Chain at most one, and only when the user
648+asks to see something: `.punchcard()` `.pianoroll()` `.spiral()` `.pitchwheel()`
649+`.wordfall()` `.scope()` `.spectrum()`.
650+
651+These can also be conservatively sprinkled in when it makes sense. Maybe one for the melody
652+
addedREADME.md+162 −0
1+# Strudel × Claude
2+
3+Live-code music by chatting. A [Strudel](https://strudel.cc) IDE on the left,
4+a Claude chat panel on the right. Describe the music you want and Claude writes
5+a runnable Strudel pattern into the editor and plays it.
6+
7+```
8+┌────────────────────────────┬──────────────────────┐
9+│ 🌀 Strudel IDE │ ✦ Claude chat │
10+│ (CodeMirror editor) │ "dark techno with │
11+│ ▶ Play / ■ Stop (toggle) │ a rolling bass" │
12+│ stack( s("bd*4") ... ) │ → writes + plays it │
13+└────────────────────────────┴──────────────────────┘
14+```
15+
16+## Architecture
17+
18+- **Frontend** (Vite): CodeMirror editor + `@strudel/web` audio engine + chat UI.
19+ Sounds come from Strudel's standard prebake: 71 drum machines, the GM soundfont
20+ set, VCSL instruments, and a handful of character samples.
21+- **Backend** (Express): holds your Anthropic key, calls the Claude Messages API
22+ (`claude-opus-4-8`) with the system prompt from `PROMPT.md` and structured
23+ output (`{ message, code }`). The browser never sees the key.
24+
25+```
26+browser ──/api/generate──▶ Express ──▶ Claude API
27+ ◀── { message, code } ──
28+```
29+
30+## Setup
31+
32+Credentials — any of these works:
33+
34+- `ant auth login` (recommended), or
35+- `cp .env.example .env` and set `ANTHROPIC_API_KEY`, or
36+- nothing at all: click **🔑 Add API key** at the bottom of the sidebar and paste
37+ your own. It is kept in that browser's localStorage and sent to the backend in
38+ an `x-anthropic-key` header, which uses it for that request only — never
39+ logged, never stored server-side. A personal key takes precedence over the
40+ server's, so several people can share one instance with their own billing.
41+
42+```bash
43+npm install
44+npm run dev # starts Vite (5173) + backend (8787) together
45+```
46+
47+Open http://localhost:5173, press **▶ Play** for the starter pattern, or ask
48+Claude on the right (Cmd/Ctrl+Enter to send).
49+
50+## Driving it from a terminal
51+
52+The app broadcasts what it is playing — the code, the timing of every event in
53+it, and the chat transcript — over a WebSocket at `/ws`. A terminal client
54+draws all three and can send prompts back:
55+
56+```bash
57+npm run tui # from a checkout
58+curl -s http://localhost:8787/tui.py | python3 - localhost:8787 # from anywhere
59+```
60+
61+`tui/strudel-tui.py` is one file, standard library only — no pip install, and
62+the server hands it out at `/tui.py` as `text/plain`, so you can read it in a
63+browser or `curl -sO` it before running it. It takes the app's address
64+(`localhost:8787`, `https://you.ngrok.app`, `ws://…/ws` — all understood) and
65+otherwise defaults to `localhost:8787`.
66+
67+```
68+ ● playing cycle 2.000 0.500 cps 120 bpm
69+ ────────────────────────────────────────────────────────────────────────────
70+ 1 stack( │› you
71+ 2 s("bd*4").gain(0.9), │ give me a dark rolling techno
72+ 3 s("~ cp").room(0.3), │
73+ 4 note("<c2 g1>").s("sawtooth") │✦ claude
74+ 5 ) │ Rolling kick on every beat,
75+ ↑ the characters that made each event │ offbeat hats, a low saw bass.
76+ light up as it sounds and fade out │
77+ across its length │✦ claude is thinking…
78+ ╷·····┃···············╷······················╷············· ← one cycle, ┃ = now
79+ › make it half time and add a clap on 3
80+```
81+
82+Panes sit side by side when the terminal is at least 100 columns wide and stack
83+when it isn't. Keys:
84+
85+| | |
86+|---|---|
87+| `q` | quit |
88+| `i` or `Enter` | write a prompt (`Enter` sends, `Esc` cancels, `^U`/`^W` edit) |
89+| `j` / `k` | scroll the code |
90+| `J` / `K` | scroll the chat |
91+| `f` | follow the sounding code (on by default) |
92+
93+A prompt typed here goes through the same path as the composer in the page, so
94+the turn appears in both, reverts in both, and is saved to the session like any
95+other. If a turn is already in flight the new prompt is refused rather than
96+queued — two user messages in a row is an API error — and the terminal says so.
97+
98+`--light` for a light-background terminal. It needs 24-bit colour, which every
99+current terminal has.
100+
101+### How the timing gets there
102+
103+Each message carries a *window* of upcoming events plus the playhead position
104+and tempo, twice a second — not a frame per redraw. The viewer runs its own
105+clock and animates between messages, so it stays smooth over a slow link and
106+survives the browser tab throttling its timers to 1 Hz in the background. That
107+last part matters: Strudel's own highlighting is `requestAnimationFrame`-driven
108+and stops dead in a hidden tab, which is exactly when you are looking at the
109+terminal instead — so `src/broadcast.js` polls the scheduler rather than
110+hanging off the draw loop.
111+
112+The hub takes one producer (the browser tab that owns the audio) and any number
113+of viewers. A viewer may send exactly one kind of message — a prompt — and the
114+hub drops everything else it sends, so a terminal cannot rewrite the editor,
115+work the transport, or push anything onto another viewer's screen.
116+
117+Open the app in two tabs and the first one keeps the slot; the second is told
118+to stand by and stays silent until the first goes away, at which point it is
119+promoted and re-announces itself. The terminal shows `(+1 idle tab)` so it is
120+clear why it isn't following the tab you happen to be looking at. The slot only
121+changes hands when it is free — an earlier version gave it to whoever connected
122+last, and since a displaced tab reconnects half a second later, two open tabs
123+took turns evicting each other and the terminal flipped between them twice a
124+second, forever.
125+
126+It is unauthenticated, though. If you expose the port, anyone who can reach it
127+can read your session *and spend your API credit on prompts*, which is a bigger
128+deal than it was when viewers could only watch. Keep it on localhost, or put
129+something in front of it, unless you mean to share the console.
130+
131+## How it works
132+
133+- `src/strudel.js` — wraps `initStrudel()` / `evaluate()` / `hush()`.
134+- `src/main.js` — editor, transport, and chat; drops Claude's code into the
135+ editor and auto-plays it.
136+- `src/sessions.js` — localStorage-backed sessions (code + chat per session).
137+- Revert: every user message stores the code as it was *before* that turn, so
138+ hovering Claude's reply and clicking ↩ undoes that turn — rewinding both the
139+ chat and the pattern. The status bar then offers a one-level **Undo**.
140+- `src/apikey.js` — the bring-your-own-key control; `GET /api/config` tells it
141+ whether the server has a key of its own.
142+- `server/index.js` — the Claude call, history trimming and prompt caching.
143+- `server/broadcast.js` — the `/ws` timing hub; `src/broadcast.js` feeds it and
144+ `tui/strudel-tui.py` consumes it.
145+- `PROMPT.md` — **the entire system prompt**, read from disk at boot. Edit it to
146+ teach Claude new sounds or change house style; no code change needed, and the
147+ prompt diffs like any other file. Restart the API to pick up edits.
148+
149+### Keeping the prompt honest
150+
151+`PROMPT.md` tells Claude not to invent sound names, because an unknown name in
152+Strudel plays *silence* rather than raising — a wrong name is a part the user
153+never hears. So section 3 of the prompt has to match what `src/strudel.js`
154+actually prebakes. If you add or remove a sample pack, update that section.
155+
156+## Ideas to extend
157+
158+- Stream Claude's reply token-by-token into the chat bubble.
159+- Add a "remix current selection" action.
160+- Swap the plain editor for `@strudel/codemirror` to get Strudel-native
161+ highlighting and event flashing.
162+- Persist sessions / share patterns by URL.
addedTODO.md+2 −0
1+- ability to have 2 line outs
2+ - basically so that we can preview the changes before being sent to the main output
addedindex.html+128 −0
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6+ <title>Strudel × Claude</title>
7+ <link rel="stylesheet" href="/src/style.css" />
8+ </head>
9+ <body>
10+ <div id="app">
11+ <!-- FAR LEFT: session sidebar -->
12+ <aside id="sidebar">
13+ <header class="pane-header sidebar-header">
14+ <span class="logo">🌀 Strudel × Claude</span>
15+ <button id="sidebar-hide" class="icon-btn" title="Hide tracks">«</button>
16+ </header>
17+ <button id="new-session" class="new-session">+ New track</button>
18+ <nav id="session-list" class="session-list" aria-label="Tracks"></nav>
19+
20+ <div class="sidebar-footer">
21+ <button id="theme-btn" class="key-btn" aria-haspopup="dialog" aria-expanded="false">
22+ <span aria-hidden="true">◐</span>
23+ <span id="theme-state">Theme</span>
24+ </button>
25+ <div id="theme-panel" class="key-panel" role="dialog" aria-label="Theme" hidden>
26+ <label class="key-label" for="site-theme">Lighting</label>
27+ <select id="site-theme" class="key-select"></select>
28+ <label class="key-label key-label-spaced" for="code-theme">Code colors</label>
29+ <select id="code-theme" class="key-select"></select>
30+ <p class="key-note">
31+ The code pane shares the site background, so a code theme sets syntax
32+ colors only.
33+ </p>
34+ </div>
35+
36+ <button id="prompt-btn" class="key-btn" aria-haspopup="dialog" aria-expanded="false">
37+ <span aria-hidden="true">📜</span>
38+ <span id="prompt-state">System prompt</span>
39+ </button>
40+ </div>
41+ </aside>
42+
43+ <!-- MIDDLE: Strudel IDE -->
44+ <section id="ide">
45+ <header class="pane-header">
46+ <div class="ide-title">
47+ <button id="sidebar-show" class="icon-btn" title="Show tracks" hidden>☰</button>
48+ <span class="logo" aria-hidden="true">🌀</span>
49+ <button id="song-title" class="song-title" type="button" title="Rename track"></button>
50+ </div>
51+ <div class="transport">
52+ <button id="playstop" class="btn btn-transport">▶ Play</button>
53+ </div>
54+ </header>
55+ <div id="editor"></div>
56+ <div class="status">
57+ <span id="ide-status">ready</span>
58+ </div>
59+ </section>
60+
61+ <!-- RIGHT: Claude chat -->
62+ <section id="chat">
63+ <header class="pane-header chat-header">
64+ <button id="model-btn" class="key-btn" aria-haspopup="dialog" aria-expanded="false">
65+ <span aria-hidden="true">✦</span>
66+ <span id="model-state">Model</span>
67+ </button>
68+ <div id="model-panel" class="key-panel" role="dialog" aria-label="Model" hidden>
69+ <label class="key-label" for="model-select">Claude model</label>
70+ <select id="model-select" class="key-select"></select>
71+ <p id="model-note" class="key-note"></p>
72+ </div>
73+
74+ <button id="key-btn" class="key-btn" aria-haspopup="dialog" aria-expanded="false">
75+ <span aria-hidden="true">🔑</span>
76+ <span id="key-state">Add API key</span>
77+ </button>
78+ <div id="key-panel" class="key-panel" role="dialog" aria-label="Anthropic API key" hidden>
79+ <label class="key-label" for="key-input">Anthropic API key</label>
80+ <input
81+ id="key-input"
82+ class="key-input"
83+ type="password"
84+ placeholder="sk-ant-…"
85+ autocomplete="off"
86+ spellcheck="false"
87+ />
88+ <p id="key-note" class="key-note"></p>
89+ <div class="key-actions">
90+ <button id="key-save" class="btn btn-send" type="button">Save</button>
91+ <button id="key-clear" class="btn" type="button" hidden>Remove</button>
92+ </div>
93+ </div>
94+ </header>
95+ <div id="messages"></div>
96+ <form id="chat-form">
97+ <div class="composer">
98+ <textarea
99+ id="chat-input"
100+ rows="2"
101+ placeholder="e.g. dark rolling techno with a deep bass and off-beat hats"
102+ ></textarea>
103+ <button id="send" class="btn btn-send" type="submit">Send</button>
104+ </div>
105+ </form>
106+ </section>
107+ </div>
108+
109+ <!-- System prompt editor: a modal rather than a header popover, because the
110+ prompt is a few hundred lines of Markdown. -->
111+ <div id="prompt-modal" class="modal" hidden>
112+ <div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="prompt-title">
113+ <header class="modal-header">
114+ <h2 id="prompt-title">System prompt</h2>
115+ <button id="prompt-close" class="icon-btn" type="button" title="Close">✕</button>
116+ </header>
117+ <textarea id="prompt-text" class="prompt-text" spellcheck="false"></textarea>
118+ <p id="prompt-note" class="key-note"></p>
119+ <div class="key-actions">
120+ <button id="prompt-save" class="btn btn-send" type="button">Save</button>
121+ <button id="prompt-reset" class="btn" type="button" hidden>Reset to default</button>
122+ </div>
123+ </div>
124+ </div>
125+
126+ <script type="module" src="/src/main.js"></script>
127+ </body>
128+</html>
addednode-tui/README.md+196 −0
1+# node-tui
2+
3+Strudel in the terminal — pattern engine, audio, editor, and Claude, all in one
4+Node process. Self-contained: it does not talk to the web app, the broadcast hub,
5+or `server/index.js`, and none of them need to be running.
6+
7+It is a reimplementation of the web app, not a viewer for it.
8+
9+```
10+npm run node-tui
11+```
12+
13+No build step. Node 24 strips the TypeScript itself, and the UI is written with
14+`htm` tagged templates rather than JSX (which Node's stripping does not handle).
15+
16+```
17+npm run typecheck:tui # tsc as a checker only, never an emitter
18+node --test node-tui/*.test.ts
19+```
20+
21+## Using it
22+
23+Three panes: sessions on the left, the editor in the middle, chat on the right.
24+The side panels appear when the terminal is at least 100 columns wide. The
25+transport line sits along the bottom, under the panes.
26+
27+| | |
28+|---|---|
29+| `↵` or `^E` | evaluate the buffer |
30+| `^O` | hush — stop, keep the code |
31+| `^S` | save the session (↵ keeps the offered name) |
32+| `^P` | ask Claude for a pattern |
33+| `^F` | fill the window with the chat, and back |
34+| `^L` | show the log, and hide it again |
35+| `^W` | cycle panes |
36+| `^C` | quit |
37+
38+Transport keys work from every pane and every mode, including insert — you
39+evaluate while typing at least as often as you do from normal mode.
40+
41+Enter evaluates outside insert mode. Ctrl+Enter would be the obvious key, but a
42+terminal sends the identical byte for Enter and Ctrl+Enter unless it speaks the
43+Kitty keyboard protocol, so there is nothing to tell them apart. Plain Enter is
44+free: vim's normal-mode `<cr>` moves to the next line's first non-blank, which
45+`j` and `^` already cover.
46+
47+The log is closed until `^L`. It is diagnostic — sample loading, save
48+confirmations — and a running pattern fills it with the sampler repeating
49+itself, which is six rows of editor spent on nothing you asked for. Errors reach
50+the status line regardless.
51+
52+**Editing is modal.** `i a I A o O` to insert, `esc` to leave; `hjkl w b e 0 ^ $
53+{ } gg G` to move; `d c y` with any motion, plus `dd cc yy x D C S p P u ^R`;
54+counts (`3j`, `d2w`); `v` and `V` for visual. `cw` behaves like `ce`, as in vim.
55+
56+**The mouse works** — click anywhere in a pane to focus it, borders and padding
57+included, click a line to put the cursor there, click a session to load it,
58+click the chat's prompt row to start typing a question, wheel to scroll. Ink has
59+no mouse support, so `mouse.ts` enables tracking and parses the reports itself.
60+
61+Focus is hit-tested against the pane *frames* and content against the text
62+areas, which is why `layout.ts` publishes both. Measuring a click against the
63+text area is the only way to land on the right line, but a click on a pane's
64+border is unambiguously a click on that pane, and having it do nothing reads as
65+the mouse being broken rather than as a two-column miss. `layout.test.ts` holds
66+the frames to covering every cell of the body exactly once.
67+
68+**Sessions** hold a pattern *and* the conversation that produced it, as the web
69+app's do — one JSON file each in `sessions/` (override with `STRUDEL_SESSIONS`).
70+They autosave when you evaluate, when a reply arrives, and on quit; an unnamed
71+session takes its name from the first thing you asked for, or `scratch` if you
72+never asked anything — quitting never discards work. Launching reopens the most
73+recent session. A `.str` file in the
74+same directory is read as a code-only session (marked `·` in the list) — that's
75+the plain-text import/export path, exactly the text you'd type and nothing else,
76+so a pattern moves between here, strudel.cc, and a gist without ceremony.
77+
78+**Chat** authenticates with `ANTHROPIC_API_KEY`, read from the environment or
79+from `.env` (loaded by `boot.ts`, so it works however you start the app). An
80+`ant auth login` profile works too — the app doesn't check for a key before
81+trying, it just reports clearly if the API rejects it. A reply with a pattern
82+replaces the buffer and plays it.
83+
84+## How it works
85+
86+Strudel is layered more cleanly than it looks. The pattern algebra, the
87+mini-notation parser, the transpiler and the scheduler are pure JS with no DOM
88+dependency. Only the output stage — superdough — needs Web Audio, and `repl()`
89+takes both the clock and the output as constructor arguments. So the whole engine
90+runs in Node once you give it a real `AudioContext`.
91+
92+That comes from [`node-web-audio-api`](https://github.com/ircam-ismm/node-web-audio-api),
93+a Rust/cpal-backed implementation with genuine `AudioWorklet` support — which
94+matters, because superdough registers 15 worklet processors (`crush`, `coarse`,
95+`supersaw`, the ladder filter…) and silently loses those effects without them.
96+
97+```
98+engine.ts repl() wired to a native AudioContext
99+buffer.ts text buffer — offsets, positions, edits
100+vim.ts modal editing as a pure (state, key) → state reducer
101+highlights.ts which characters are sounding, and how brightly
102+layout.ts pane geometry, shared by the renderer and the hit test
103+mouse.ts mouse tracking and SGR report parsing
104+sessions.ts the session library (code + conversation)
105+claude.ts pattern generation via the Anthropic API
106+app.ts panes, focus, keymap
107+browser-shim.ts the non-audio DOM surface Strudel touches
108+loader-hooks.ts module resolution Vite would normally handle
109+```
110+
111+### The animation
112+
113+Characters light up as the events they produced sound. This is strudel.cc's own
114+mechanism, not an imitation: the transpiler records which characters produced
115+each value, the scheduler carries those ranges on `hap.context.locations`, and
116+`highlights.ts` asks the running pattern what's sounding now.
117+
118+Two timescales. The *query* is windowed and cached — asking a pattern for its
119+events is the expensive part, so it happens a few times a second, a cycle ahead.
120+The *filter* runs every frame against that cache, which is cheap. That split is
121+also what makes the fade work: each hap knows its own start and end, so intensity
122+follows the note between queries.
123+
124+The fade is quantised and the frame rate is capped at 12fps on purpose. Ink has
125+no partial redraw — it rewrites every line whenever anything changes — so the
126+animation rate *is* the full-screen repaint rate, and on a terminal without
127+synchronized output that reads as flicker.
128+
129+### The two shims
130+
131+Neither is a hack around a Strudel bug; both stand in for a bundler.
132+
133+**`browser-shim.ts`** — `node-web-audio-api/polyfill.js` installs the Web Audio
134+constructors globally and creates a bare `window`. What's left is a small DOM
135+surface: `document` is a real `EventTarget`, because `@strudel/core`'s logger
136+dispatches every message as a `strudel.log` CustomEvent — that's how the log pane
137+is fed. `document.createElement` deliberately throws rather than returning a fake.
138+
139+**`loader-hooks.ts`** — two module-resolution patches:
140+
141+- `@strudel/core` imports `SalatRepl` from `@kabelsalat/web`, which ships a
142+ browser IIFE bundle Node can't read named exports from. It's only reachable via
143+ the `kabel`/mondo path, so it gets a stub that constructs fine and throws if
144+ ever called. The constructor must succeed — `repl()` instantiates it eagerly.
145+- superdough's source imports its worklets as `'./worklets.mjs?audioworklet'`, a
146+ Vite-only specifier, resolved here to the path on disk. (The published bundle
147+ inlines them as a `data:` URL and never hits this path.)
148+
149+These are registered in `boot.ts`, which then pulls in the app with a **dynamic**
150+import — `registerHooks()` only affects modules resolved after it runs, and an
151+ESM graph links before it evaluates. A static import would be linked before the
152+hooks existed.
153+
154+### Four things that look like details and aren't
155+
156+**Ink strips the ESC from mouse reports.** The same click arrives at `mouse.ts`
157+as `\x1b[<0;9;6M` on raw stdin and at the keymap as `[<0;9;6M`. Both forms have to
158+be recognised — the first to act on, the second to discard before it gets typed
159+into the pattern.
160+
161+**`useMouse` keeps its handler in a ref.** The handler depends on the layout and
162+the session list, both rebuilt every render, and this app re-renders on every
163+animation frame. With the handler in the effect's dependency array, mouse
164+tracking is torn down and re-armed at that rate.
165+
166+## Still missing, against the web app
167+
168+Deliberately not carried over: **themes** (the terminal already has one) and
169+**in-app API key entry** (an environment variable is the CLI-native answer).
170+
171+Genuinely not built yet:
172+
173+- **Model picker.** The web app offers four; this hardcodes Opus 4.8.
174+ `claude.ts` already lists all four and `generate()` takes a model id.
175+- **Revert.** The web app stores a `codeBefore` snapshot per turn and can rewind
176+ to it, with one level of undo.
177+- **Stepping through history** non-destructively.
178+
179+**Ink's `exitOnCtrlC` has to be off.** Its built-in handler unmounts the moment
180+it sees Ctrl+C, which races the save-on-quit and usually wins: the app appears to
181+exit cleanly while silently discarding unsaved work. The keymap owns Ctrl+C
182+instead, and awaits the write before calling `exit()`.
183+
184+**The shim must not set `window.document`.** Libraries sniff for a browser by
185+testing `window && window.document && navigator` — the Anthropic SDK does exactly
186+this and refuses to construct a client in what it thinks is a browser, since that
187+would risk leaking an API key to a web page. Setting `window.document` makes the
188+whole process look like a browser to every library in it, and the chat pane fails
189+with a confusing error about credentials. The lie stays as small as it needs to
190+be: `document` is a global, `window.document` is not, and nothing in Strudel
191+reads the latter. Passing `dangerouslyAllowBrowser` instead would be claiming to
192+accept a risk that cannot occur here.
193+
194+**Never emit more rows than a Box has.** Ink's response to overflow is to draw
195+rows on top of each other, which reads as corrupted text rather than as a layout
196+bug — a turn that only partly fits is truncated to its last lines instead.
addednode-tui/app.ts+738 −0
1+// The terminal frontend. Owns the panes, the focus model, and the keymap; the
2+// engine owns everything that makes sound, vim.ts owns the editing grammar.
3+//
4+// Entry is via boot.ts, never directly — the module shims have to be in place
5+// before @strudel/* is resolved.
6+//
7+// Focus, and why it's explicit: three panes and a modal editor means a keystroke
8+// is ambiguous unless exactly one thing claims it. `focus` decides, and the only
9+// keys that bypass it are the transport (^E/^O) and the focus switches
10+// themselves — everything else goes to whoever holds it. Without that rule, `j`
11+// is either "down a line" or the letter j depending on state nobody can see.
12+import { useState, useEffect, useCallback, useRef } from 'react';
13+import type { ReactElement } from 'react';
14+import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
15+
16+import { html } from './ui/html.ts';
17+import { Editor } from './ui/Editor.ts';
18+import { StatusBar } from './ui/StatusBar.ts';
19+import { LogPane } from './ui/LogPane.ts';
20+import type { LogRecord } from './ui/LogPane.ts';
21+import { SessionList } from './ui/SessionList.ts';
22+import { Chat } from './ui/Chat.ts';
23+import * as buf from './buffer.ts';
24+import * as vim from './vim.ts';
25+import * as sessions from './sessions.ts';
26+import type { SessionMeta } from './sessions.ts';
27+import * as claude from './claude.ts';
28+import type { Turn } from './claude.ts';
29+import { createHapWindow } from './highlights.ts';
30+import type { Span } from './highlights.ts';
31+import { useMouse, isMouseSequence } from './mouse.ts';
32+import type { MouseEvent } from './mouse.ts';
33+import { computeLayout, contains, editorPosition } from './layout.ts';
34+import type { Zoom } from './layout.ts';
35+import { subscribeToLogs } from './browser-shim.ts';
36+import type { LogEntry } from './browser-shim.ts';
37+import { initAudio, createEngine } from './engine.ts';
38+import type { Repl } from './engine.ts';
39+
40+const INITIAL_CODE = `setcpm(120/4)
41+
42+$: s("bd*2, ~ sd").bank("RolandTR909")
43+$: s("hh*8").gain(0.4)
44+$: note("<c2 eb2 g2 bb2>").s("sawtooth").cutoff(600).room(0.3).gain(0.5)`;
45+
46+const LOG_HEIGHT = 6;
47+/** The log box's border, top and bottom — only on screen while it is open. */
48+const LOG_FRAME = 2;
49+const SESSIONS_WIDTH = 22;
50+const CHAT_WIDTH = 34;
51+/** Below this, the side panels cost more than they're worth — hide them. */
52+const WIDE_ENOUGH = 100;
53+const MAX_LOG_ENTRIES = 200;
54+
55+/** Where unnamed work goes, so that quitting never discards it. */
56+const SCRATCH_NAME = 'scratch';
57+
58+/**
59+ * Animation period. Ink has no partial redraw — it rewrites every line of the
60+ * frame whenever anything changes — so this is also the full-screen repaint
61+ * rate, and on a terminal without synchronized output that rate *is* the
62+ * flicker. 12fps still reads as a flash on an eighth note at any sane tempo,
63+ * and halves the repaints against the 25fps this started at.
64+ */
65+const FRAME_MS = 80;
66+
67+type Focus = 'editor' | 'sessions' | 'chat';
68+
69+/** Shared empty array, so "no spans" is always the same reference. */
70+const EMPTY_SPANS: readonly Span[] = [];
71+
72+/**
73+ * Whether two span sets would render identically.
74+ *
75+ * Colour is part of the comparison, not just position: the fade is the point of
76+ * the animation, and treating a dimming span as unchanged would freeze it.
77+ */
78+function sameSpans(a: readonly Span[], b: readonly Span[]): boolean {
79+ if (a === b) return true;
80+ if (a.length !== b.length) return false;
81+ for (let i = 0; i < a.length; i++) {
82+ const x = a[i];
83+ const y = b[i];
84+ if (!x || !y) return false;
85+ if (x.start !== y.start || x.end !== y.end || x.background !== y.background) return false;
86+ }
87+ return true;
88+}
89+
90+interface Handlers {
91+ onToggle?: (started: boolean) => void;
92+ onError?: (err: Error) => void;
93+}
94+
95+/**
96+ * The log pane's contents.
97+ *
98+ * Consecutive identical messages are collapsed into one line with a count
99+ * rather than appended. superdough logs "loading sound X took too long" once
100+ * per *event* while a sample is still downloading, so a hi-hat at eight to the
101+ * bar produces eight identical lines a second — which scrolls the pane
102+ * continuously and reads as flicker. Collapsing them also makes the repetition
103+ * legible: "×47" says something a wall of identical lines does not.
104+ */
105+function useLogs(limit = MAX_LOG_ENTRIES): [LogRecord[], (entry: LogEntry) => void] {
106+ const [entries, setEntries] = useState<LogRecord[]>([]);
107+ const nextId = useRef(0);
108+
109+ const append = useCallback((entry: LogEntry) => {
110+ setEntries((prev) => {
111+ const last = prev.at(-1);
112+ if (last && last.message === entry.message && last.type === entry.type) {
113+ const merged = { ...last, count: (last.count ?? 1) + 1 };
114+ return [...prev.slice(0, -1), merged];
115+ }
116+ const next = [...prev, { ...entry, id: nextId.current++ }];
117+ return next.length > limit ? next.slice(-limit) : next;
118+ });
119+ }, [limit]);
120+
121+ return [entries, append];
122+}
123+
124+function App({ engine, handlers }: { engine: Repl; handlers: Handlers }): ReactElement {
125+ const { exit } = useApp();
126+ const { stdout } = useStdout();
127+
128+ const [state, setState] = useState(() => vim.createVimState(INITIAL_CODE));
129+ const [evaluated, setEvaluated] = useState<string | null>(null);
130+ const [playing, setPlaying] = useState(false);
131+ const [error, setError] = useState<string | null>(null);
132+ const [cycle, setCycle] = useState(0);
133+ const [spans, setSpans] = useState<readonly Span[]>([]);
134+ const [entries, log] = useLogs();
135+
136+ const [focus, setFocus] = useState<Focus>('editor');
137+ const [library, setLibrary] = useState<SessionMeta[]>([]);
138+ const [sessionIndex, setSessionIndex] = useState(0);
139+ const [currentName, setCurrentName] = useState<string | null>(null);
140+
141+ const [turns, setTurns] = useState<Turn[]>([]);
142+ const [draft, setDraft] = useState<string | null>(null);
143+ const [asking, setAsking] = useState(false);
144+ const [chatError, setChatError] = useState<string | null>(null);
145+
146+ const [prompt, setPrompt] = useState<{ label: string; value: string } | null>(null);
147+
148+ // Which pane, if any, fills the window. The chat is the one worth expanding:
149+ // a 34-column column is fine for a one-line reply and useless for reading an
150+ // explanation back.
151+ const [zoom, setZoom] = useState<Zoom>('none');
152+
153+ // The log is closed by default. It is diagnostic — sample loading, save
154+ // confirmations — and while a pattern is running it is mostly the sampler
155+ // repeating itself, so it costs six rows of editor to say nothing you asked
156+ // for. ^L opens it when something has actually gone wrong.
157+ const [logOpen, setLogOpen] = useState(false);
158+
159+ // The editing state is read inside interval callbacks that are set up once;
160+ // a ref keeps those callbacks looking at the live value instead of the one
161+ // captured when the effect ran.
162+ const stateRef = useRef(state);
163+ stateRef.current = state;
164+
165+ // Autosave runs from callbacks created before the state it needs to write
166+ // exists, so the conversation and the session name are read through refs too.
167+ const turnsRef = useRef(turns);
168+ turnsRef.current = turns;
169+ const currentNameRef = useRef(currentName);
170+ currentNameRef.current = currentName;
171+ const autosaveRef = useRef<(() => void) | null>(null);
172+ const zoomRef = useRef(zoom);
173+ zoomRef.current = zoom;
174+
175+ const hapWindow = useRef(createHapWindow());
176+
177+ // Highlights describe the code that was *evaluated*, as character offsets into
178+ // it. Typing shifts those offsets a little and strudel.cc lives with the drift,
179+ // which is fine for an edit in progress. Replacing the whole buffer — loading a
180+ // song, accepting a pattern from Claude — is different in kind: the old
181+ // pattern is still playing and its offsets now point into a document that has
182+ // nothing to do with it, so the animation lights up characters at random.
183+ // Highlights are suppressed until the next evaluate puts them back in sync.
184+ //
185+ // A ref, not state, because the animation timer reads it and must see the
186+ // current value rather than the one captured when the effect was set up.
187+ const staleHighlightsRef = useRef(false);
188+
189+ // Engine messages. Strudel logs through a DOM CustomEvent and superdough
190+ // through its own hook; subscribeToLogs unifies both. Without this they'd go
191+ // to console.log and tear holes in Ink's frame.
192+ useEffect(() => {
193+ let dispose: (() => void) | undefined;
194+ let cancelled = false;
195+ void subscribeToLogs(log).then((fn) => {
196+ if (cancelled) fn();
197+ else dispose = fn;
198+ });
199+ return () => { cancelled = true; dispose?.(); };
200+ }, [log]);
201+
202+ useEffect(() => {
203+ handlers.onToggle = setPlaying;
204+ handlers.onError = (err) => setError(err.message);
205+ }, [handlers]);
206+
207+ const refreshLibrary = useCallback(() => {
208+ void sessions.list().then(setLibrary);
209+ }, []);
210+
211+ // Open the most recent session on launch, as the web app restores its active
212+ // one. INITIAL_CODE is a first-run demo, not a thing to be dropped back into
213+ // every time — coming back to what you were working on is the whole point of
214+ // sessions existing.
215+ useEffect(() => {
216+ let cancelled = false;
217+ void sessions.list().then(async (found) => {
218+ if (cancelled) return;
219+ setLibrary(found);
220+ const latest = found[0];
221+ if (!latest) return;
222+ try {
223+ const session = await sessions.load(latest);
224+ if (cancelled) return;
225+ setState(vim.createVimState(session.code));
226+ setTurns(session.history);
227+ setCurrentName(session.name);
228+ } catch {
229+ // A library we can't read is not a reason to refuse to start; the
230+ // pane will show what's there and the demo pattern stays loaded.
231+ }
232+ });
233+ return () => { cancelled = true; };
234+ }, []);
235+
236+ // The animation. One timer drives both the cycle readout and the
237+ // follow-along highlighting: they're the same clock, and running two would
238+ // let them disagree on screen.
239+ //
240+ // Every setState here costs a full-screen repaint — Ink redraws the whole
241+ // frame whenever anything changes, and at this size that reads as flicker. So
242+ // the tick is careful to change state only when the screen would actually
243+ // differ: nothing at all while stopped, a cycle readout rounded to what's
244+ // displayed rather than to full precision, and spans compared before being
245+ // replaced. A pattern that isn't sounding anything produces no renders.
246+ useEffect(() => {
247+ const timer = setInterval(() => {
248+ const scheduler = engine.scheduler;
249+
250+ if (!scheduler.started) {
251+ setCycle((prev) => (prev === 0 ? prev : 0));
252+ setSpans((prev) => (prev.length === 0 ? prev : EMPTY_SPANS));
253+ return;
254+ }
255+
256+ const now = scheduler.now();
257+ // Stored at the precision it is displayed at, and displayed at one
258+ // decimal. At two it changes tens of times a second, and since any change
259+ // repaints the whole screen, that one field would flicker the entire UI on
260+ // its own — for a readout nobody needs to a hundredth of a cycle.
261+ const shown = Math.round(now * 10) / 10;
262+ setCycle((prev) => (prev === shown ? prev : shown));
263+
264+ // No point querying the pattern for highlights when the editor isn't on
265+ // screen — zoomed, the whole point is to be reading the chat.
266+ const next = staleHighlightsRef.current || zoomRef.current !== 'none'
267+ ? EMPTY_SPANS
268+ : hapWindow.current.spansAt(scheduler.pattern, now);
269+ setSpans((prev) => (sameSpans(prev, next) ? prev : next));
270+ }, FRAME_MS);
271+ return () => clearInterval(timer);
272+ }, [engine]);
273+
274+ const evaluate = useCallback(async () => {
275+ const code = buf.toText(stateRef.current.buffer);
276+ setError(null);
277+ try {
278+ await engine.evaluate(code);
279+ setEvaluated(code);
280+ // The new pattern's offsets describe the code now on screen.
281+ staleHighlightsRef.current = false;
282+ autosaveRef.current?.();
283+ } catch (err) {
284+ setError(err instanceof Error ? err.message : String(err));
285+ }
286+ }, [engine]);
287+
288+ const loadSession = useCallback(async (meta: SessionMeta) => {
289+ try {
290+ const session = await sessions.load(meta);
291+ setState(vim.createVimState(session.code));
292+ setTurns(session.history);
293+ setCurrentName(session.name);
294+ setEvaluated(null);
295+ setChatError(null);
296+ // Whatever is still playing belongs to the previous session.
297+ staleHighlightsRef.current = true;
298+ hapWindow.current.reset();
299+ setSpans(EMPTY_SPANS);
300+ } catch (err) {
301+ setError(err instanceof Error ? err.message : String(err));
302+ }
303+ }, []);
304+
305+ // Both halves of a session are written together, always. Saving the pattern
306+ // without the conversation, or the other way round, would leave a session on
307+ // disk describing a state that never existed.
308+ const saveSession = useCallback(async (name: string, { quiet = false } = {}) => {
309+ try {
310+ const meta = await sessions.save(
311+ name,
312+ buf.toText(stateRef.current.buffer),
313+ turnsRef.current,
314+ );
315+ setCurrentName(meta.name);
316+ refreshLibrary();
317+ if (!quiet) log({ message: `[sessions] saved "${meta.name}"` });
318+ return meta.name;
319+ } catch (err) {
320+ setError(err instanceof Error ? err.message : String(err));
321+ return null;
322+ }
323+ }, [refreshLibrary, log]);
324+
325+ // Autosave. A live-coding session is not something you remember to save — you
326+ // stop when you stop — so the two moments that actually change it, evaluating
327+ // and getting a reply, persist it. Named sessions save silently under their
328+ // own name; an unnamed one takes a name from the first thing you asked for,
329+ // which beats leaving the work in a file called "untitled".
330+ const autosave = useCallback(() => {
331+ // Falling back to a fixed scratch name matters: without it, editing a
332+ // pattern and quitting before ever naming it or asking anything would throw
333+ // the work away, and "you didn't name it" is not a good reason to lose
334+ // someone's music. One reused name rather than untitled-1, untitled-2… so
335+ // the library doesn't silently fill with abandoned drafts.
336+ const name = currentNameRef.current ?? sessions.nameFrom(turnsRef.current) ?? SCRATCH_NAME;
337+ void saveSession(name, { quiet: true });
338+ }, [saveSession]);
339+
340+ // `evaluate` is defined above `autosave` — it has to be, since the chat path
341+ // uses it — so it reaches autosave through a ref rather than a direct call.
342+ autosaveRef.current = autosave;
343+
344+ const ask = useCallback(async (text: string) => {
345+ const history: Turn[] = [...turns, { role: 'user', content: text }];
346+ setTurns(history);
347+ setAsking(true);
348+ setChatError(null);
349+ try {
350+ const reply = await claude.generate(history, buf.toText(stateRef.current.buffer));
351+ const turn: Turn = { role: 'assistant', content: reply.message };
352+ if (reply.code) turn.code = reply.code;
353+ const complete = [...history, turn];
354+ setTurns(complete);
355+ // setTurns is asynchronous and autosave reads the conversation from a ref,
356+ // so the ref is advanced here rather than waiting a render — otherwise the
357+ // save that follows writes the transcript one turn short.
358+ turnsRef.current = complete;
359+
360+ if (reply.code) {
361+ // Replace the buffer and play it. Anything else — dropping it into a
362+ // scratch pane, asking to confirm — makes the round trip slower than
363+ // just typing the pattern yourself, which defeats the point.
364+ setState(vim.createVimState(reply.code));
365+ setError(null);
366+ staleHighlightsRef.current = true;
367+ hapWindow.current.reset();
368+ try {
369+ await engine.evaluate(reply.code);
370+ setEvaluated(reply.code);
371+ staleHighlightsRef.current = false;
372+ } catch (err) {
373+ setError(err instanceof Error ? err.message : String(err));
374+ }
375+ }
376+ autosave();
377+ } catch (err) {
378+ setChatError(err instanceof Error ? err.message : String(err));
379+ } finally {
380+ setAsking(false);
381+ }
382+ }, [turns, engine, autosave]);
383+
384+ // Geometry is computed before the input handlers because the mouse hit test
385+ // needs it — and it must be the same geometry the renderer uses, or clicks
386+ // land a row off.
387+ const rows = stdout.rows || 24;
388+ const columns = stdout.columns || 80;
389+ const layout = computeLayout({
390+ rows,
391+ columns,
392+ sessionsWidth: SESSIONS_WIDTH,
393+ chatWidth: CHAT_WIDTH,
394+ logRows: logOpen ? LOG_HEIGHT + LOG_FRAME : 0,
395+ wideEnough: WIDE_ENOUGH,
396+ lineCount: state.buffer.lines.length,
397+ zoom,
398+ });
399+
400+ // Both lists scroll to keep their selection visible. Computed here, passed
401+ // down, and reused by the hit test.
402+ const editorRows = layout.editor.height;
403+ const scrollTop = state.buffer.row >= editorRows ? state.buffer.row - editorRows + 1 : 0;
404+ const sessionRows = layout.sessions?.height ?? 0;
405+ const sessionScrollTop = sessionIndex >= sessionRows ? sessionIndex - sessionRows + 1 : 0;
406+
407+ useMouse(useCallback((event: MouseEvent) => {
408+ // Focus is decided against the frames, so the border and padding of a pane
409+ // count as that pane. Everything below then measures against the text area,
410+ // which is the only rectangle whose rows line up with content.
411+ if (contains(layout.sessionsFrame, event.row, event.column)) {
412+ setFocus('sessions');
413+ if (event.button === 'wheel-up') return setSessionIndex((i) => Math.max(0, i - 1));
414+ if (event.button === 'wheel-down') {
415+ return setSessionIndex((i) => Math.min(i + 1, Math.max(0, library.length - 1)));
416+ }
417+ if (!contains(layout.sessions, event.row, event.column) || !layout.sessions) return;
418+ const index = sessionScrollTop + (event.row - layout.sessions.top);
419+ const meta = library[index];
420+ if (meta) {
421+ setSessionIndex(index);
422+ void loadSession(meta);
423+ }
424+ return;
425+ }
426+
427+ if (contains(layout.chatFrame, event.row, event.column)) {
428+ setFocus('chat');
429+ // The last row of the pane is the prompt, and a prompt you can see is a
430+ // prompt you expect to be able to click into. Anywhere else in the chat
431+ // is transcript, which only takes focus.
432+ const promptRow = layout.chat ? layout.chat.top + layout.chat.height - 1 : -1;
433+ if (event.row === promptRow && event.button === 'left' && !asking) setDraft((d) => d ?? '');
434+ return;
435+ }
436+
437+ if (contains(layout.editorFrame, event.row, event.column)) {
438+ setFocus('editor');
439+ // The wheel moves the cursor rather than scrolling a separate viewport:
440+ // the editor already scrolls to follow the cursor, and a second,
441+ // independent scroll offset would fight it every time you typed.
442+ if (event.button === 'wheel-up' || event.button === 'wheel-down') {
443+ const delta = event.button === 'wheel-up' ? -3 : 3;
444+ return setState((s) => vim.moveCursorTo(s, s.buffer.row + delta, s.buffer.col));
445+ }
446+ const position = editorPosition(layout, scrollTop, event.row, event.column);
447+ setState((s) => vim.moveCursorTo(s, position.row, position.col));
448+ }
449+ }, [layout, library, sessionScrollTop, scrollTop, loadSession, asking]));
450+
451+ useInput((input, key) => {
452+ // Mouse reports arrive on the same stdin as keys. Ink doesn't know what they
453+ // are and would hand the bytes over as if they'd been typed, dropping escape
454+ // sequences into the buffer — so they're filtered here and handled by
455+ // useMouse instead.
456+ if (isMouseSequence(input)) return;
457+
458+ // --- Global: transport and quit, from any pane and any mode -------------
459+ if (key.ctrl && input === 'c') {
460+ engine.stop();
461+ // Save before leaving, and *wait* for the write. Evaluating and asking
462+ // already autosave, so this only catches edits made since the last of
463+ // those — but that's exactly the work you'd be most annoyed to lose.
464+ //
465+ // The await is the point: exit() unmounts and lets the process end, so a
466+ // fire-and-forget save here loses the race with it and the file is never
467+ // written. That failure is silent and only shows up as missing work.
468+ void (async () => {
469+ const name = currentNameRef.current
470+ ?? sessions.nameFrom(turnsRef.current)
471+ ?? SCRATCH_NAME;
472+ await saveSession(name, { quiet: true });
473+ exit();
474+ })();
475+ return;
476+ }
477+
478+ // --- A one-line prompt (save-as) owns every key while it's open ---------
479+ if (prompt) {
480+ if (key.escape) return setPrompt(null);
481+ if (key.return) {
482+ const { value } = prompt;
483+ setPrompt(null);
484+ if (value.trim()) void saveSession(value);
485+ return;
486+ }
487+ if (key.backspace || key.delete) {
488+ return setPrompt({ ...prompt, value: prompt.value.slice(0, -1) });
489+ }
490+ if (input && !key.ctrl && !/[\u0000-\u001f]/.test(input)) {
491+ return setPrompt({ ...prompt, value: prompt.value + input });
492+ }
493+ return;
494+ }
495+
496+ // --- The chat composer likewise ----------------------------------------
497+ if (draft !== null) {
498+ if (key.escape) return setDraft(null);
499+ if (key.return) {
500+ const text = draft.trim();
501+ setDraft(null);
502+ if (text) void ask(text);
503+ return;
504+ }
505+ // ^W deletes the previous word here rather than cycling panes — that's
506+ // what it does in every other line editor, and you're typing prose.
507+ if (key.ctrl && input === 'w') {
508+ return setDraft(draft.replace(/\S*\s*$/, ''));
509+ }
510+ if (key.backspace || key.delete) return setDraft(draft.slice(0, -1));
511+ if (input && !key.ctrl && !/[\u0000-\u001f]/.test(input)) return setDraft(draft + input);
512+ return;
513+ }
514+
515+ // --- Focus and panel commands ------------------------------------------
516+ if (key.ctrl && input === 'p') {
517+ setFocus('chat');
518+ setDraft('');
519+ return;
520+ }
521+ if (key.ctrl && input === 's') {
522+ // Offer the name it would autosave under, so ^S↵ is "save" and typing
523+ // over it is "save as".
524+ setPrompt({
525+ label: 'save as',
526+ value: currentName ?? sessions.nameFrom(turns) ?? '',
527+ });
528+ return;
529+ }
530+ // ^F fills the window with the chat, and back. Focus follows, because
531+ // expanding a pane you then have to tab into would be two steps for one
532+ // intention.
533+ if (key.ctrl && input === 'f') {
534+ setZoom((z) => (z === 'chat' ? 'none' : 'chat'));
535+ setFocus((f) => (zoom === 'chat' ? 'editor' : 'chat'));
536+ return;
537+ }
538+
539+ // ^L opens and closes the log. It sits under the transport keys rather than
540+ // behind a pane focus because you want it for a moment, to read why a sound
541+ // didn't play, and then want the rows back.
542+ if (key.ctrl && input === 'l') {
543+ setLogOpen((open) => !open);
544+ return;
545+ }
546+
547+ // ^W cycles panes, as it does in vim. Tab deliberately does not: it belongs
548+ // to the editor, where it indents, and a terminal UI has no mouse to fall
549+ // back on if the key that inserts a tab also moves focus.
550+ if (key.ctrl && input === 'w') {
551+ const order: Focus[] = ['editor', 'sessions', 'chat'];
552+ const next = order[(order.indexOf(focus) + 1) % order.length];
553+ return setFocus(next ?? 'editor');
554+ }
555+
556+ // --- The song list, when it holds focus ---------------------------------
557+ if (focus === 'sessions') {
558+ if (key.escape) return setFocus('editor');
559+ if (key.downArrow || input === 'j') {
560+ return setSessionIndex((i) => Math.min(i + 1, Math.max(0, library.length - 1)));
561+ }
562+ if (key.upArrow || input === 'k') return setSessionIndex((i) => Math.max(0, i - 1));
563+ if (key.return || input === 'l') {
564+ const meta = library[sessionIndex];
565+ if (meta) void loadSession(meta);
566+ return;
567+ }
568+ // Fall through to the transport keys below.
569+ }
570+
571+ // --- Everything else is the editor --------------------------------------
572+ if (focus === 'editor' || key.ctrl) {
573+ const base = {
574+ ctrl: Boolean(key.ctrl),
575+ escape: Boolean(key.escape),
576+ return: Boolean(key.return),
577+ backspace: Boolean(key.backspace || key.delete),
578+ tab: Boolean(key.tab),
579+ leftArrow: Boolean(key.leftArrow),
580+ rightArrow: Boolean(key.rightArrow),
581+ upArrow: Boolean(key.upArrow),
582+ downArrow: Boolean(key.downArrow),
583+ };
584+
585+ // A paste — or simply typing faster than the terminal reports — arrives as
586+ // one chunk of several characters. Normal mode has to see that as a
587+ // sequence of commands rather than as one key it doesn't recognise, or the
588+ // whole chunk is silently dropped. Splitting is also correct in insert
589+ // mode, where the characters land in order either way.
590+ const stroke = !key.ctrl && !key.escape && !key.return && !key.tab
591+ && !key.backspace && !key.delete && input.length > 1
592+ ? [...input]
593+ : [input];
594+
595+ let next = state;
596+ const effects: vim.VimEffects = {};
597+ for (const ch of stroke) {
598+ const result = vim.handleKey(next, { ...base, input: ch });
599+ next = result.state;
600+ Object.assign(effects, result.effects);
601+ }
602+
603+ if (next !== state) setState(next);
604+ if (effects.evaluate) void evaluate();
605+ if (effects.hush) engine.stop();
606+ }
607+ });
608+
609+ const wide = layout.wide;
610+ const zoomed = layout.zoom === 'chat';
611+ const dirty = buf.toText(state.buffer) !== evaluated;
612+ const bodyHeight = layout.editor.height;
613+
614+ const centre = html`
615+ <${Box} flexDirection="column" flexGrow=${1} paddingX=${1} overflow="hidden">
616+ <${Editor}
617+ buffer=${state.buffer}
618+ height=${bodyHeight}
619+ scrollTop=${scrollTop}
620+ spans=${spans}
621+ selection=${vim.selectionRange(state)}
622+ cursorStyle=${state.mode === 'insert' ? 'bar' : 'block'}
623+ />
624+ <//>
625+ `;
626+
627+ return html`
628+ <${Box} flexDirection="column" height=${rows}>
629+ ${zoomed ? html`
630+ <${Box}
631+ flexGrow=${1} flexDirection="column"
632+ borderStyle="round" borderColor="cyan" paddingX=${1} overflow="hidden"
633+ >
634+ <${Chat}
635+ turns=${turns}
636+ draft=${draft}
637+ pending=${asking}
638+ error=${chatError}
639+ hasKey=${claude.hasApiKey()}
640+ focused=${focus === 'chat'}
641+ width=${(layout.chat?.width ?? columns) - 2}
642+ height=${layout.chat?.height ?? 1}
643+ />
644+ <//>
645+ ` : html`
646+ <${Box} flexGrow=${1} overflow="hidden">
647+ ${wide ? html`
648+ <${Box}
649+ flexDirection="column" width=${SESSIONS_WIDTH} flexShrink=${0}
650+ borderStyle="round" borderColor=${focus === 'sessions' ? 'cyan' : 'gray'}
651+ paddingX=${1} overflow="hidden"
652+ >
653+ <${SessionList}
654+ sessions=${library}
655+ selected=${sessionIndex}
656+ current=${currentName}
657+ focused=${focus === 'sessions'}
658+ height=${sessionRows}
659+ scrollTop=${sessionScrollTop}
660+ />
661+ <//>
662+ ` : null}
663+
664+ ${centre}
665+
666+ ${wide ? html`
667+ <${Box}
668+ flexDirection="column" width=${CHAT_WIDTH} flexShrink=${0}
669+ borderStyle="round" borderColor=${focus === 'chat' ? 'cyan' : 'gray'}
670+ paddingX=${1} overflow="hidden"
671+ >
672+ <${Chat}
673+ turns=${turns}
674+ draft=${draft}
675+ pending=${asking}
676+ error=${chatError}
677+ hasKey=${claude.hasApiKey()}
678+ focused=${focus === 'chat'}
679+ width=${layout.chat?.width ?? CHAT_WIDTH - 4}
680+ height=${layout.chat?.height ?? 1}
681+ />
682+ <//>
683+ ` : null}
684+ <//>
685+ `}
686+
687+ ${logOpen && !zoomed ? html`
688+ <${Box}
689+ borderStyle="round" borderColor="gray" paddingX=${1}
690+ flexDirection="column" flexShrink=${0}
691+ >
692+ <${LogPane} entries=${entries} height=${LOG_HEIGHT} />
693+ <//>
694+ ` : null}
695+
696+ <${Box} borderStyle="round" borderColor="magenta" paddingX=${1} flexShrink=${0}>
697+ <${StatusBar}
698+ playing=${playing}
699+ cps=${engine.scheduler.cps}
700+ cycle=${cycle}
701+ dirty=${dirty}
702+ mode=${state.mode}
703+ pending=${state.pending}
704+ session=${currentName}
705+ error=${error}
706+ />
707+ <//>
708+
709+ <${Box} paddingX=${1} flexShrink=${0}>
710+ ${prompt
711+ ? html`<${Text}><${Text} color="cyan">${prompt.label}: <//>${prompt.value}<${Text} inverse> <//><//>`
712+ : html`<${Text} dimColor>↵ eval ^O hush ^S save ^P ask ^F chat ^L log ^W pane ^C quit<//>`}
713+ <//>
714+ <//>
715+ `;
716+}
717+
718+// Boot audio before Ink takes the screen: sample loading prints progress and
719+// takes a few seconds, and it reads better as plain lines than as a half-drawn
720+// interface waiting on a network fetch.
721+process.stdout.write('starting strudel…\n');
722+await initAudio({ onProgress: (msg) => void process.stdout.write(` ${msg}\n`) });
723+
724+// The engine is constructed before the component exists, so its callbacks go
725+// through a mutable box that App fills in on mount. Late-binding this way means
726+// an engine message that arrives before first render is simply dropped, rather
727+// than landing on a setState for a component that isn't there yet.
728+const handlers: Handlers = {};
729+const engine = await createEngine({
730+ onToggle: (v) => handlers.onToggle?.(v),
731+ onError: (e) => handlers.onError?.(e),
732+});
733+
734+// exitOnCtrlC must be off. Ink's built-in handler unmounts the moment it sees
735+// Ctrl+C, which races the save-on-quit and usually wins — the app would appear
736+// to exit cleanly while silently discarding unsaved work. With it disabled, the
737+// keymap owns Ctrl+C and can finish writing before calling exit().
738+render(html`<${App} engine=${engine} handlers=${handlers} />`, { exitOnCtrlC: false });
addednode-tui/boot.ts+31 −0
1+// Entry point. Loads configuration, installs the shims Strudel needs, then
2+// hands off to the app.
3+//
4+// The dynamic import at the bottom is required, not stylistic: loader-hooks.ts
5+// must finish evaluating before anything under @strudel/* is resolved. See its
6+// header.
7+import fs from 'node:fs';
8+import path from 'node:path';
9+import { fileURLToPath } from 'node:url';
10+
11+import './loader-hooks.ts';
12+import './browser-shim.ts';
13+
14+// Read .env here rather than relying on the launcher's --env-file flag, so
15+// ANTHROPIC_API_KEY is picked up however the app is started — `npm run
16+// node-tui`, `node node-tui/boot.ts`, or from another directory entirely. The
17+// path is resolved relative to this file, not the working directory, for the
18+// same reason.
19+//
20+// Existing environment variables win: an explicitly exported key should not be
21+// silently overridden by a stale .env.
22+const ENV_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.env');
23+if (fs.existsSync(ENV_FILE)) {
24+ try {
25+ process.loadEnvFile(ENV_FILE);
26+ } catch (err) {
27+ process.stderr.write(`could not read ${ENV_FILE}: ${(err as Error).message}\n`);
28+ }
29+}
30+
31+await import('./app.ts');
addednode-tui/browser-shim.ts+101 −0
1+// Everything Strudel expects from a browser that isn't audio.
2+//
3+// node-web-audio-api's polyfill installs the Web Audio constructors on
4+// globalThis and creates a bare `window` object, which covers the audio half.
5+// What's left are DOM touchpoints that Strudel reaches for at module scope or in
6+// code paths we never take:
7+//
8+// - @strudel/core's logger dispatches every log line as a `strudel.log`
9+// CustomEvent on `document`. That's not incidental — it's the cleanest way
10+// for a frontend to consume engine messages, so `document` is a real
11+// EventTarget rather than a stub. See subscribeToLogs() below.
12+// - superdough's dspworklet.mjs registers a `window.addEventListener('message')`
13+// handler at import time, for the user-defined `dsp` feature.
14+// - superdough's reverbGen.mjs draws impulse responses to a <canvas> for visual
15+// debugging. Never called during playback, but the module must import.
16+//
17+// `createElement` throws rather than returning a plausible-looking fake: if some
18+// path really does need a DOM element, that should surface as an error naming
19+// the tag, not as silently wrong behaviour further downstream.
20+//
21+// The casts below are all of the same kind and all deliberate: TypeScript's DOM
22+// lib describes a real browser, and these objects are knowingly partial stand-ins
23+// for a handful of members. Widening the globals to `any` would hide genuine
24+// mistakes elsewhere, so the lies stay local and named.
25+import 'node-web-audio-api/polyfill.js';
26+
27+/** A log line from either Strudel or superdough. */
28+export interface LogEntry {
29+ message: string;
30+ type?: string;
31+ data?: Record<string, unknown>;
32+}
33+
34+type PartialGlobal = Record<string, unknown>;
35+
36+const noop = (): void => {};
37+
38+// Node has EventTarget and CustomEvent built in, so `document` can be the real
39+// thing — addEventListener/dispatchEvent behave exactly as Strudel expects.
40+function makeEventTargetHost(base: PartialGlobal): PartialGlobal {
41+ const target = new EventTarget();
42+ base.addEventListener = target.addEventListener.bind(target);
43+ base.removeEventListener = target.removeEventListener.bind(target);
44+ base.dispatchEvent = target.dispatchEvent.bind(target);
45+ return base;
46+}
47+
48+const LOCATION = { href: 'http://localhost/', origin: 'http://localhost', protocol: 'http:' };
49+
50+const win = globalThis.window as unknown as PartialGlobal;
51+makeEventTargetHost(win);
52+win.postMessage = noop;
53+win.location = LOCATION;
54+
55+if (!globalThis.document) {
56+ const doc = makeEventTargetHost({
57+ createElement: (tag: string) => {
58+ throw new Error(`[browser-shim] document.createElement('${tag}') is not available in Node`);
59+ },
60+ });
61+ globalThis.document = doc as unknown as Document;
62+}
63+
64+// `window.document` is deliberately NOT set, even though `document` is a global.
65+//
66+// Libraries sniff for a browser by testing `window && window.document &&
67+// navigator` — the Anthropic SDK does exactly this, and refuses to construct a
68+// client in what it believes is a browser, because that would risk leaking an
69+// API key to a web page. Setting `window.document` makes this process look like
70+// a browser to every library in it, not just to Strudel, and the chat pane stops
71+// working with a confusing error about credentials.
72+//
73+// The fix is to keep the lie as small as it needs to be, rather than to pass
74+// `dangerouslyAllowBrowser` and claim we accept a risk that cannot occur here.
75+// Nothing in Strudel or superdough reads `window.document`; they use the bare
76+// `document` global, which is still there.
77+
78+if (!globalThis.location) {
79+ globalThis.location = LOCATION as unknown as Location;
80+}
81+
82+/**
83+ * Subscribe to engine log messages — both Strudel's (via the `strudel.log` DOM
84+ * event) and superdough's (via its setLogger hook, which has no DOM path).
85+ *
86+ * Returns an unsubscribe function.
87+ */
88+export async function subscribeToLogs(
89+ onLog: (entry: LogEntry) => void,
90+): Promise<() => void> {
91+ const handler = (e: Event): void => onLog((e as CustomEvent<LogEntry>).detail);
92+ globalThis.document.addEventListener('strudel.log', handler);
93+
94+ const { setLogger } = await import('superdough');
95+ setLogger((message, type) => onLog({ message, type }));
96+
97+ return () => {
98+ globalThis.document.removeEventListener('strudel.log', handler);
99+ setLogger((msg) => console.log(msg));
100+ };
101+}
addednode-tui/buffer.ts+193 −0
1+// A minimal multi-line text buffer: pure functions over { lines, row, col }.
2+//
3+// Kept separate from the Ink component so the editing rules are testable
4+// without a terminal, and so the component stays about rendering.
5+//
6+// `col` is allowed to sit one past the end of a line (that's where the cursor
7+// is when you're appending). Vertical movement preserves a "desired column" the
8+// caller threads through, so moving down through a short line and back up
9+// returns you to where you started — the same behaviour every real editor has.
10+
11+export interface Buffer {
12+ readonly lines: readonly string[];
13+ readonly row: number;
14+ readonly col: number;
15+ /**
16+ * The column vertical movement aims for. Preserved across up/down so passing
17+ * through a short line doesn't permanently pull the cursor left.
18+ */
19+ readonly desiredCol: number;
20+}
21+
22+export function create(text = ''): Buffer {
23+ const lines = text.split('\n');
24+ return { lines, row: 0, col: 0, desiredCol: 0 };
25+}
26+
27+export function toText(buf: Buffer): string {
28+ return buf.lines.join('\n');
29+}
30+
31+const lineAt = (lines: readonly string[], row: number): string => lines[row] ?? '';
32+
33+const clampCol = (lines: readonly string[], row: number, col: number): number =>
34+ Math.max(0, Math.min(col, lineAt(lines, row).length));
35+
36+function withCursor(buf: Buffer, row: number, col: number, desiredCol: number = col): Buffer {
37+ return { ...buf, row, col, desiredCol };
38+}
39+
40+export function insert(buf: Buffer, text: string): Buffer {
41+ const { lines, row, col } = buf;
42+ const line = lineAt(lines, row);
43+ const next = [...lines];
44+ next[row] = line.slice(0, col) + text + line.slice(col);
45+ return withCursor({ ...buf, lines: next }, row, col + text.length);
46+}
47+
48+export function newline(buf: Buffer): Buffer {
49+ const { lines, row, col } = buf;
50+ const line = lineAt(lines, row);
51+ // Carry the current line's indentation onto the new one. Strudel patterns are
52+ // routinely indented continuations of a chain, and re-typing the leading
53+ // spaces every time is the kind of friction that makes live coding unpleasant.
54+ const indent = /^\s*/.exec(line)?.[0] ?? '';
55+ const next = [...lines];
56+ next.splice(row, 1, line.slice(0, col), indent + line.slice(col));
57+ return withCursor({ ...buf, lines: next }, row + 1, indent.length);
58+}
59+
60+export function backspace(buf: Buffer): Buffer {
61+ const { lines, row, col } = buf;
62+ if (col > 0) {
63+ const next = [...lines];
64+ const line = lineAt(lines, row);
65+ next[row] = line.slice(0, col - 1) + line.slice(col);
66+ return withCursor({ ...buf, lines: next }, row, col - 1);
67+ }
68+ if (row === 0) return buf;
69+ // At column zero, join with the previous line and land where the seam is.
70+ const prev = lineAt(lines, row - 1);
71+ const next = [...lines];
72+ next.splice(row - 1, 2, prev + lineAt(lines, row));
73+ return withCursor({ ...buf, lines: next }, row - 1, prev.length);
74+}
75+
76+export function deleteForward(buf: Buffer): Buffer {
77+ const { lines, row, col } = buf;
78+ if (col < lineAt(lines, row).length) {
79+ const next = [...lines];
80+ const line = lineAt(lines, row);
81+ next[row] = line.slice(0, col) + line.slice(col + 1);
82+ return { ...buf, lines: next };
83+ }
84+ if (row === lines.length - 1) return buf;
85+ const next = [...lines];
86+ next.splice(row, 2, lineAt(lines, row) + lineAt(lines, row + 1));
87+ return { ...buf, lines: next };
88+}
89+
90+export function moveLeft(buf: Buffer): Buffer {
91+ const { lines, row, col } = buf;
92+ if (col > 0) return withCursor(buf, row, col - 1);
93+ if (row === 0) return buf;
94+ return withCursor(buf, row - 1, lineAt(lines, row - 1).length);
95+}
96+
97+export function moveRight(buf: Buffer): Buffer {
98+ const { lines, row, col } = buf;
99+ if (col < lineAt(lines, row).length) return withCursor(buf, row, col + 1);
100+ if (row === lines.length - 1) return buf;
101+ return withCursor(buf, row + 1, 0);
102+}
103+
104+export function moveUp(buf: Buffer): Buffer {
105+ if (buf.row === 0) return withCursor(buf, 0, 0, buf.desiredCol);
106+ const row = buf.row - 1;
107+ return withCursor(buf, row, clampCol(buf.lines, row, buf.desiredCol), buf.desiredCol);
108+}
109+
110+export function moveDown(buf: Buffer): Buffer {
111+ const last = buf.lines.length - 1;
112+ if (buf.row === last) return withCursor(buf, last, lineAt(buf.lines, last).length, buf.desiredCol);
113+ const row = buf.row + 1;
114+ return withCursor(buf, row, clampCol(buf.lines, row, buf.desiredCol), buf.desiredCol);
115+}
116+
117+export const moveLineStart = (buf: Buffer): Buffer => withCursor(buf, buf.row, 0);
118+export const moveLineEnd = (buf: Buffer): Buffer =>
119+ withCursor(buf, buf.row, lineAt(buf.lines, buf.row).length);
120+
121+/** A position in the buffer, as a row and a column within that row. */
122+export interface Position {
123+ row: number;
124+ col: number;
125+}
126+
127+/**
128+ * Translate a character offset into the full text to a row/column.
129+ *
130+ * Strudel reports the source location of every event as an offset into the code
131+ * it evaluated, so this is what turns "characters 42–58 are sounding" into
132+ * something the editor can paint. Offsets past the end clamp to the last
133+ * position rather than throwing — while the buffer is dirty they refer to text
134+ * that no longer exists, and a stale highlight is better than a crash.
135+ */
136+export function offsetToPosition(lines: readonly string[], offset: number): Position {
137+ let remaining = Math.max(0, offset);
138+ for (let row = 0; row < lines.length; row++) {
139+ const len = lineAt(lines, row).length;
140+ if (remaining <= len) return { row, col: remaining };
141+ remaining -= len + 1; // +1 for the newline
142+ }
143+ const last = Math.max(0, lines.length - 1);
144+ return { row: last, col: lineAt(lines, last).length };
145+}
146+
147+/** The inverse of {@link offsetToPosition}. */
148+export function positionToOffset(lines: readonly string[], { row, col }: Position): number {
149+ const r = Math.max(0, Math.min(row, lines.length - 1));
150+ let offset = 0;
151+ for (let i = 0; i < r; i++) offset += lineAt(lines, i).length + 1;
152+ return offset + Math.max(0, Math.min(col, lineAt(lines, r).length));
153+}
154+
155+/** The cursor's position as an offset into the full text. */
156+export const cursorOffset = (buf: Buffer): number =>
157+ positionToOffset(buf.lines, { row: buf.row, col: buf.col });
158+
159+/**
160+ * Replace the text between two offsets, and put the cursor somewhere.
161+ *
162+ * Every editing command beyond plain typing goes through here. Working on the
163+ * flat text rather than on the line array means deletions that span lines,
164+ * joins, and multi-line pastes are all the same operation — the line structure
165+ * falls out of the result instead of having to be maintained through it.
166+ */
167+export function replaceRange(
168+ buf: Buffer,
169+ start: number,
170+ end: number,
171+ text: string,
172+ cursorAt = start + text.length,
173+): Buffer {
174+ const full = toText(buf);
175+ const from = Math.max(0, Math.min(start, end, full.length));
176+ const to = Math.min(Math.max(start, end), full.length);
177+ const next = full.slice(0, from) + text + full.slice(to);
178+ const lines = next.split('\n');
179+ const { row, col } = offsetToPosition(lines, Math.max(0, Math.min(cursorAt, next.length)));
180+ return { lines, row, col, desiredCol: col };
181+}
182+
183+/** Move the cursor to an absolute offset, clamped into the buffer. */
184+export function setCursorOffset(buf: Buffer, offset: number): Buffer {
185+ const { row, col } = offsetToPosition(buf.lines, offset);
186+ return { ...buf, row, col, desiredCol: col };
187+}
188+
189+export function setText(buf: Buffer, text: string): Buffer {
190+ const lines = text.split('\n');
191+ const row = Math.min(buf.row, lines.length - 1);
192+ return withCursor({ ...buf, lines }, row, clampCol(lines, row, buf.col));
193+}
addednode-tui/claude.ts+193 −0
1+// Pattern generation, talking to the Anthropic API directly.
2+//
3+// The TUI is self-contained — it does not go through server/index.js, and does
4+// not need it running. What it does share is PROMPT.md: the system prompt is the
5+// product here, and having the terminal and the browser answer differently
6+// because their prompts drifted would be a bug, not a feature.
7+//
8+// The request shape deliberately mirrors server/index.js, including where the
9+// cache breakpoint sits. That placement is the whole reason a long session stays
10+// cheap: everything up to and including the user's message is byte-identical
11+// next request and gets cached, while the editor contents — which change on
12+// every keystroke — go *after* the breakpoint as their own turn. Folding them
13+// into the user's message instead would rewrite that turn every time and throw
14+// away the cache on each request.
15+import fs from 'node:fs';
16+import path from 'node:path';
17+import { fileURLToPath } from 'node:url';
18+import Anthropic from '@anthropic-ai/sdk';
19+
20+const HERE = path.dirname(fileURLToPath(import.meta.url));
21+const PROMPT_PATH = path.join(HERE, '..', 'PROMPT.md');
22+
23+export const MODELS = [
24+ { id: 'claude-opus-4-8', label: 'Opus 4.8' },
25+ { id: 'claude-opus-5', label: 'Opus 5', thinks: true },
26+ { id: 'claude-sonnet-5', label: 'Sonnet 5' },
27+ // Haiku 4.5 rejects output_config.effort outright — sending it is a 400.
28+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5', effort: false },
29+] as const;
30+
31+export type ModelId = (typeof MODELS)[number]['id'];
32+export const DEFAULT_MODEL: ModelId = 'claude-opus-4-8';
33+
34+const EFFORT = process.env['STRUDEL_EFFORT'] ?? 'medium';
35+
36+/** Keep history bounded, but cut in one step rather than continuously — a
37+ * window that slides every turn invalidates the cached prefix every turn. */
38+const HISTORY_LIMIT = 40;
39+const HISTORY_KEEP = 24;
40+
41+const OUTPUT_SCHEMA = {
42+ type: 'object',
43+ properties: {
44+ message: { type: 'string', description: 'A short, friendly reply to the user (1-2 sentences).' },
45+ code: {
46+ type: 'string',
47+ description: 'The full runnable Strudel pattern, or an empty string if no music change is needed.',
48+ },
49+ },
50+ required: ['message', 'code'],
51+ additionalProperties: false,
52+};
53+
54+export interface Turn {
55+ role: 'user' | 'assistant';
56+ content: string;
57+ /** The pattern an assistant turn produced, if any. */
58+ code?: string;
59+}
60+
61+export interface Reply {
62+ message: string;
63+ code: string;
64+}
65+
66+let system: string | null = null;
67+
68+function loadSystem(): string {
69+ if (system !== null) return system;
70+ const text = fs.readFileSync(PROMPT_PATH, 'utf8').trim();
71+ if (!text) throw new Error(`The system prompt at ${PROMPT_PATH} is empty.`);
72+ system = text;
73+ return system;
74+}
75+
76+function trimHistory(turns: readonly Turn[]): Turn[] {
77+ if (turns.length <= HISTORY_LIMIT) return [...turns];
78+ const tail = turns.slice(-HISTORY_KEEP);
79+ // The API requires the first message to be from the user.
80+ const firstUser = tail.findIndex((t) => t.role === 'user');
81+ return firstUser <= 0 ? tail : tail.slice(firstUser);
82+}
83+
84+// How a stored turn is rendered for the API. Assistant turns carry the pattern
85+// they produced, in the same JSON shape the model emits — without it a long
86+// session shows the model nothing but its own content-free replies ("Added more
87+// sparkle.") with no record of what it actually wrote, and the patterns drift.
88+//
89+// This rendering must be stable: once a turn has been sent one way it has to
90+// keep being sent that way, or the cached prefix breaks on the next request.
91+const renderTurn = (t: Turn): string =>
92+ t.role === 'assistant' && t.code
93+ ? JSON.stringify({ message: t.content || '', code: t.code })
94+ : t.content || '';
95+
96+/**
97+ * Whether an API key is in the environment.
98+ *
99+ * Only used to *show* whether one is set — never to decide whether to make the
100+ * request. The SDK can also authenticate from an `ant auth login` profile, so a
101+ * missing ANTHROPIC_API_KEY is not the same as being unable to call the API, and
102+ * refusing to try would take away a path that works.
103+ */
104+export function hasApiKey(): boolean {
105+ return Boolean(process.env['ANTHROPIC_API_KEY']);
106+}
107+
108+/** Turn an SDK failure into something worth putting in a 34-column pane. */
109+function describe(err: unknown): string {
110+ const status = (err as { status?: number }).status;
111+ if (status === 401 || status === 403) {
112+ return hasApiKey()
113+ ? 'that API key was rejected — check ANTHROPIC_API_KEY'
114+ : 'no credentials — set ANTHROPIC_API_KEY in .env, or run `ant auth login`';
115+ }
116+ if (status === 429) return 'rate limited — give it a moment';
117+ return err instanceof Error ? err.message : String(err);
118+}
119+
120+/**
121+ * Ask Claude for a pattern.
122+ *
123+ * `turns` is the conversation so far, ending with the user's request; `code` is
124+ * what's currently in the editor.
125+ */
126+export async function generate(
127+ turns: readonly Turn[],
128+ code: string,
129+ modelId: ModelId = DEFAULT_MODEL,
130+): Promise<Reply> {
131+ const model = MODELS.find((m) => m.id === modelId) ?? MODELS[0];
132+ const client = new Anthropic();
133+
134+ const messages = trimHistory(turns).map((t) => ({
135+ role: t.role,
136+ content: [{ type: 'text' as const, text: renderTurn(t) }],
137+ }));
138+
139+ // The cache breakpoint: everything above this is stable across requests.
140+ const last = messages.at(-1);
141+ if (last?.content[0]) {
142+ Object.assign(last.content[0], { cache_control: { type: 'ephemeral' } });
143+ }
144+ messages.push({
145+ role: 'user',
146+ content: [{ type: 'text', text: `Current pattern in the editor:\n\`\`\`\n${code || '(empty)'}\n\`\`\`` }],
147+ });
148+
149+ let response;
150+ try {
151+ response = await client.messages.create({
152+ model: model.id,
153+ // Headroom so a big pattern + message can't truncate the JSON. Models that
154+ // think spend the same budget on reasoning first, so they get more.
155+ max_tokens: 'thinks' in model && model.thinks ? 8192 : 4096,
156+ system: [{ type: 'text', text: loadSystem(), cache_control: { type: 'ephemeral' } }],
157+ messages,
158+ output_config: {
159+ ...('effort' in model && model.effort === false ? {} : { effort: EFFORT }),
160+ format: { type: 'json_schema', schema: OUTPUT_SCHEMA },
161+ },
162+ } as Anthropic.MessageCreateParamsNonStreaming);
163+ } catch (err) {
164+ throw new Error(describe(err));
165+ }
166+
167+ // If the model hit the token cap the JSON is incomplete — never ship a
168+ // half-parsed pattern to the editor.
169+ if (response.stop_reason === 'max_tokens') {
170+ return { message: 'That got a bit long and I ran out of room — try again, maybe a touch simpler.', code: '' };
171+ }
172+
173+ const block = response.content.find((b) => b.type === 'text');
174+ let parsed: { message?: unknown; code?: unknown };
175+ try {
176+ parsed = JSON.parse(block && 'text' in block ? block.text : '{}') as typeof parsed;
177+ } catch {
178+ return { message: "I couldn't format that cleanly — mind trying again?", code: '' };
179+ }
180+
181+ // Defensively strip a markdown code fence if the model added one — a stray
182+ // ``` at char 0 is exactly the "Unexpected token (1:0)" Strudel parse error.
183+ const raw = typeof parsed.code === 'string' ? parsed.code : '';
184+ const cleaned = raw
185+ .replace(/^\s*```[a-zA-Z]*\s*\n?/, '')
186+ .replace(/\n?```\s*$/, '')
187+ .trim();
188+
189+ return {
190+ message: typeof parsed.message === 'string' ? parsed.message.trim() : '',
191+ code: cleaned,
192+ };
193+}
addednode-tui/engine.ts+115 −0
1+// Headless Strudel: the strudel.cc REPL with the browser taken out from under it.
2+//
3+// Strudel is layered more cleanly than it looks. The pattern algebra, the
4+// mini-notation parser, the transpiler and the scheduler are all pure JS with no
5+// DOM dependency whatsoever — the only thing that ever needed a browser is the
6+// output stage, superdough, which speaks Web Audio. `repl()` takes both the
7+// clock (`getTime`) and the output (`defaultOutput`) as constructor arguments,
8+// so pointing them at a native AudioContext is enough to run the whole thing in
9+// Node. See browser-shim.ts and loader-hooks.ts for the glue that gets
10+// superdough itself to import.
11+//
12+// Import order matters and is load-bearing: the shim must be fully evaluated
13+// before anything reaches for `AudioContext` or `window` at module scope. Static
14+// imports are hoisted in source order, so keeping the shim first is sufficient —
15+// do not reorder these.
16+import './browser-shim.ts';
17+
18+import { repl, evalScope, controls, silence } from '@strudel/core';
19+import type { Hap, Repl, ReplState } from '@strudel/core';
20+import { transpiler } from '@strudel/transpiler';
21+import * as core from '@strudel/core';
22+
23+import {
24+ setAudioContext,
25+ getAudioContext,
26+ loadWorklets,
27+ samples,
28+ aliasBank,
29+ registerSynthSounds,
30+ registerZZFXSounds,
31+ superdough,
32+} from 'superdough';
33+
34+const DOUGH = 'https://raw.githubusercontent.com/felixroos/dough-samples/main';
35+const UZU = 'https://raw.githubusercontent.com/tidalcycles/uzu-drumkit/main';
36+const TODEPOND = 'https://raw.githubusercontent.com/todepond/samples/main';
37+
38+// superdough's own webaudioOutput lives in @strudel/webaudio, which pulls in a
39+// pile of browser-only siblings we don't want. The output contract is small
40+// enough to just implement: turn a hap into a superdough call at a wall-clock
41+// deadline. This mirrors @strudel/webaudio's webaudioOutput.
42+function nodeAudioOutput(hap: Hap, deadline: number, duration: number, cps: number): void {
43+ const { s, note, n } = hap.value;
44+ if (s === undefined && note === undefined && n === undefined) {
45+ // Nothing to sound — a control-only hap (e.g. a bare `.gain()` with no source).
46+ return;
47+ }
48+ const ctx = getAudioContext();
49+ superdough({ ...hap.value, cps }, ctx.currentTime + deadline, duration, cps);
50+}
51+
52+export interface EngineHandlers {
53+ onEval?: (state: ReplState) => void;
54+ onError?: (err: Error) => void;
55+ onToggle?: (started: boolean) => void;
56+}
57+
58+/**
59+ * Boot the audio stack: native AudioContext, superdough's worklets, and the
60+ * default strudel.cc sound library.
61+ *
62+ * `onProgress` is called with short status strings so a UI can show what's
63+ * happening — the sample banks are a few MB of JSON and take a moment.
64+ */
65+export async function initAudio(
66+ { onProgress = (): void => {} }: { onProgress?: (msg: string) => void } = {},
67+): Promise<AudioContext> {
68+ const ctx = new globalThis.AudioContext();
69+ setAudioContext(ctx);
70+
71+ onProgress('loading worklets');
72+ await loadWorklets();
73+
74+ onProgress('loading sounds');
75+ await Promise.all([
76+ registerSynthSounds(),
77+ registerZZFXSounds(),
78+ samples(`${DOUGH}/tidal-drum-machines.json`),
79+ samples(`${DOUGH}/piano.json`),
80+ samples(`${DOUGH}/Dirt-Samples.json`),
81+ samples(`${DOUGH}/vcsl.json`),
82+ samples(`${DOUGH}/mridangam.json`),
83+ samples(`${UZU}/strudel.json`),
84+ ]);
85+ await aliasBank(`${TODEPOND}/tidal-drum-machines-alias.json`);
86+
87+ onProgress('ready');
88+ return ctx;
89+}
90+
91+/**
92+ * Build the REPL. Returns the same surface StrudelMirror drives in the browser —
93+ * evaluate/start/stop/toggle plus the live scheduler — so a terminal frontend
94+ * and a web frontend are interchangeable consumers.
95+ */
96+export async function createEngine(handlers: EngineHandlers = {}): Promise<Repl> {
97+ const ctx = getAudioContext();
98+
99+ // Everything the user's code can call by bare name — `s`, `note`, `stack`,
100+ // mini-notation, the tonal helpers. Without this, evaluate() throws
101+ // ReferenceError on the first `s("bd")`.
102+ await evalScope(core, controls, import('@strudel/mini'), import('@strudel/tonal'));
103+
104+ return repl({
105+ defaultOutput: nodeAudioOutput,
106+ getTime: () => ctx.currentTime,
107+ transpiler,
108+ afterEval: (state) => handlers.onEval?.(state),
109+ onEvalError: (err) => handlers.onError?.(err),
110+ onToggle: (started) => handlers.onToggle?.(started),
111+ });
112+}
113+
114+export { silence, getAudioContext };
115+export type { Repl, ReplState, Hap };
addednode-tui/highlights.ts+199 −0
1+// The follow-along animation: characters light up as the events they produced
2+// are sounding.
3+//
4+// This is the same mechanism strudel.cc uses, not an approximation of it. The
5+// transpiler records, for every value in the source, which characters produced
6+// it; the scheduler's haps carry those ranges through on `context.locations`. So
7+// "which characters are sounding right now" is a real query against the running
8+// pattern, not a guess from the text.
9+//
10+// Two timescales, deliberately:
11+//
12+// - The *query* is windowed and cached. Asking the pattern for its events is
13+// the expensive part, and re-running it every frame would burn CPU on work
14+// whose answer barely changes. We ask for a cycle or so ahead, a few times a
15+// second.
16+// - The *filter* runs every frame, against the cached window. Deciding which
17+// of ~50 cached haps overlaps `now` is trivial, so the animation stays
18+// smooth even though the query behind it is coarse.
19+//
20+// That split is also what makes the fade possible: each hap knows its own start
21+// and end, so intensity can be computed continuously between queries.
22+import type { Pattern } from '@strudel/core';
23+
24+/** A cached event: when it sounds, and which characters produced it. */
25+interface CachedHap {
26+ begin: number;
27+ end: number;
28+ locations: Array<readonly [number, number]>;
29+ color?: string;
30+}
31+
32+/** A character range to paint, with the colour it should be painted right now. */
33+export interface Span {
34+ start: number;
35+ end: number;
36+ background: string;
37+ foreground: string;
38+}
39+
40+/** How far ahead of `now` to query, in cycles. */
41+const LOOKAHEAD_CYCLES = 1.5;
42+/** Re-query once the cached window has less than this much left. */
43+const REFRESH_MARGIN = 0.5;
44+
45+// Brightest on the attack, fading across the event's length, so a long note
46+// reads as decaying rather than as a static block. A pattern rendered this way
47+// looks like music even in a screenshot.
48+const ATTACK_INTENSITY = 0.92;
49+const FADE_DEPTH = 0.62;
50+
51+/**
52+ * Number of distinct brightness levels in the fade.
53+ *
54+ * A continuously-computed fade produces a different colour on every frame, and
55+ * Ink repaints the entire screen whenever anything changes — so a smooth fade
56+ * means a full-screen redraw at the frame rate, which reads as flicker on a
57+ * terminal that doesn't support synchronized output. Quantising to a handful of
58+ * steps means the colour only changes a few times across a note: still a visible
59+ * decay, a fraction of the repaints. Below about six steps the fade starts to
60+ * look like blinking rather than decay.
61+ */
62+const FADE_STEPS = 10;
63+
64+/**
65+ * Colours for haps that don't carry one. Chosen to stay distinguishable on both
66+ * light and dark terminals, and picked per source-range so a given character
67+ * keeps the same colour every time it fires — the eye tracks a stable colour far
68+ * better than one that changes each cycle.
69+ */
70+const PALETTE = ['#ff6b6b', '#ffd93d', '#6bcB77', '#4d96ff', '#c77dff', '#ff9f45'];
71+
72+function paletteFor(offset: number): string {
73+ return PALETTE[offset % PALETTE.length] as string;
74+}
75+
76+function parseHex(hex: string): [number, number, number] {
77+ const clean = hex.replace('#', '');
78+ const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
79+ const n = Number.parseInt(full, 16);
80+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
81+}
82+
83+const toHex = (rgb: [number, number, number]): string =>
84+ `#${rgb.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')}`;
85+
86+const blend = (
87+ color: [number, number, number],
88+ base: [number, number, number],
89+ amount: number,
90+): [number, number, number] => [
91+ color[0] * amount + base[0] * (1 - amount),
92+ color[1] * amount + base[1] * (1 - amount),
93+ color[2] * amount + base[2] * (1 - amount),
94+];
95+
96+// Terminal backgrounds are usually dark, so fading toward black is the safe
97+// default. Foreground is chosen from the *blended* luminance rather than the
98+// base colour, so text stays readable at every point in the fade.
99+const BASE: [number, number, number] = [0, 0, 0];
100+
101+const luminance = ([r, g, b]: [number, number, number]): number =>
102+ (0.299 * r + 0.587 * g + 0.114 * b) / 255;
103+
104+/**
105+ * A windowed view of the running pattern's events.
106+ *
107+ * Not pure — it caches — but the impurity is confined here rather than spread
108+ * through the component tree as refs.
109+ */
110+export interface HapWindow {
111+ /** Character ranges sounding at `now`, with their current colours. */
112+ spansAt(pattern: Pattern | undefined, now: number): Span[];
113+ /** Forget the cache — call when the pattern is replaced. */
114+ reset(): void;
115+}
116+
117+export function createHapWindow(): HapWindow {
118+ let cached: CachedHap[] = [];
119+ let cachedFor: Pattern | undefined;
120+ let queriedUntil = 0;
121+
122+ function refill(pattern: Pattern, now: number): void {
123+ // Query from a little before `now`, not from `now`: a sustained note that
124+ // began earlier is still sounding, and starting the window at the playhead
125+ // would drop its highlight the moment we re-query.
126+ const from = now - 0.5;
127+ const to = now + LOOKAHEAD_CYCLES;
128+
129+ let haps;
130+ try {
131+ haps = pattern.queryArc(from, to);
132+ } catch {
133+ // Mid-eval, or a pattern that can't be queried at this point. Keep what we
134+ // have and try again next tick rather than blanking the animation.
135+ return;
136+ }
137+
138+ cached = [];
139+ for (const hap of haps) {
140+ const locations = hap.context?.locations;
141+ if (!hap.whole || !locations?.length) continue;
142+ const entry: CachedHap = {
143+ begin: hap.whole.begin.valueOf(),
144+ end: hap.whole.end.valueOf(),
145+ locations: locations.map(({ start, end }) => [start, end] as const),
146+ };
147+ const color = hap.value['color'];
148+ if (typeof color === 'string') entry.color = color;
149+ cached.push(entry);
150+ }
151+ queriedUntil = to;
152+ }
153+
154+ return {
155+ reset(): void {
156+ cached = [];
157+ cachedFor = undefined;
158+ queriedUntil = 0;
159+ },
160+
161+ spansAt(pattern: Pattern | undefined, now: number): Span[] {
162+ if (!pattern) return [];
163+
164+ // A re-eval swaps the pattern object; the old window describes code that
165+ // is no longer playing, so it goes rather than being merged.
166+ if (pattern !== cachedFor) {
167+ cached = [];
168+ cachedFor = pattern;
169+ queriedUntil = 0;
170+ }
171+ // Also refill on a backwards jump — restarting the transport rewinds
172+ // `now` behind everything we hold.
173+ if (now + REFRESH_MARGIN > queriedUntil || now < queriedUntil - LOOKAHEAD_CYCLES - 1) {
174+ refill(pattern, now);
175+ }
176+
177+ const spans: Span[] = [];
178+ for (const hap of cached) {
179+ if (now < hap.begin || now >= hap.end) continue;
180+
181+ const raw = Math.min(Math.max((now - hap.begin) / Math.max(hap.end - hap.begin, 1e-6), 0), 1);
182+ const progress = Math.round(raw * FADE_STEPS) / FADE_STEPS;
183+ const intensity = ATTACK_INTENSITY - FADE_DEPTH * progress;
184+
185+ for (const [start, end] of hap.locations) {
186+ const rgb = parseHex(hap.color ?? paletteFor(start));
187+ const background = blend(rgb, BASE, intensity);
188+ spans.push({
189+ start,
190+ end,
191+ background: toHex(background),
192+ foreground: luminance(background) > 0.5 ? '#000000' : '#ffffff',
193+ });
194+ }
195+ }
196+ return spans;
197+ },
198+ };
199+}
addednode-tui/layout.test.ts+59 −0
1+// Geometry tests. `node --test node-tui/layout.test.ts`
2+//
3+// The property that matters for the mouse is coverage: every cell of the body
4+// belongs to exactly one pane frame. A gap means a click that does nothing, and
5+// an overlap means a click that focuses whichever pane the handler happens to
6+// test first — both read as the mouse being broken.
7+import { test } from 'node:test';
8+import assert from 'node:assert/strict';
9+
10+import { computeLayout, contains } from './layout.ts';
11+import type { Layout } from './layout.ts';
12+
13+const layout = (over: Partial<Parameters<typeof computeLayout>[0]> = {}): Layout =>
14+ computeLayout({
15+ rows: 40,
16+ columns: 140,
17+ sessionsWidth: 22,
18+ chatWidth: 34,
19+ logRows: 0,
20+ wideEnough: 100,
21+ lineCount: 8,
22+ zoom: 'none',
23+ ...over,
24+ });
25+
26+test('every column of the body belongs to exactly one pane', () => {
27+ const l = layout();
28+ const bottom = l.editorFrame.top + l.editorFrame.height;
29+ for (let row = l.editorFrame.top; row < bottom; row++) {
30+ for (let column = 0; column < 140; column++) {
31+ const hits = [l.sessionsFrame, l.chatFrame, l.editorFrame]
32+ .filter((rect) => contains(rect, row, column)).length;
33+ assert.equal(hits, 1, `row ${row}, column ${column} matched ${hits} panes`);
34+ }
35+ }
36+});
37+
38+test('a pane frame includes the border its text area excludes', () => {
39+ const l = layout();
40+ // The chat's text area starts two columns inside its box (border, padding).
41+ assert.ok(l.chat && l.chatFrame);
42+ assert.equal(l.chat.left - l.chatFrame.left, 2);
43+ assert.ok(contains(l.chatFrame, l.chat.top, l.chatFrame.left));
44+ assert.ok(!contains(l.chat, l.chat.top, l.chatFrame.left));
45+});
46+
47+test('narrow terminals have no side panes to click', () => {
48+ const l = layout({ columns: 80 });
49+ assert.equal(l.sessionsFrame, null);
50+ assert.equal(l.chatFrame, null);
51+ assert.equal(l.editorFrame.left, 0);
52+ assert.equal(l.editorFrame.width, 80);
53+});
54+
55+test('an open log takes its rows from the panes, not from the status bar', () => {
56+ const closed = layout();
57+ const open = layout({ logRows: 8 });
58+ assert.equal(closed.editor.height - open.editor.height, 8);
59+});
addednode-tui/layout.ts+161 −0
1+// Where the panes are on screen.
2+//
3+// This exists because a click is a pair of numbers and nothing else — turning it
4+// into "line 4, column 12 of the editor" needs the exact geometry the renderer
5+// used. Deriving that geometry twice, once in the layout and once in the hit
6+// test, is how clicks end up one row off. So it's computed here, once, and both
7+// the renderer and the mouse handler read the same numbers.
8+//
9+// Rows and columns are zero-based screen coordinates.
10+
11+export interface Rect {
12+ top: number;
13+ left: number;
14+ width: number;
15+ height: number;
16+}
17+
18+/** Which pane, if any, is expanded to fill the window. */
19+export type Zoom = 'none' | 'chat';
20+
21+export interface Layout {
22+ wide: boolean;
23+ zoom: Zoom;
24+ /** Text area of each pane — inside its border and padding. */
25+ sessions: Rect | null;
26+ editor: Rect;
27+ chat: Rect | null;
28+ /**
29+ * The panes including their borders and padding, for click-to-focus.
30+ *
31+ * Focus and content need different rectangles. Which line you clicked has to
32+ * be measured against the text area or it lands a row off, but "did you click
33+ * this pane" should count the frame too — the border and the padding column
34+ * are visibly part of the box, and a click there that does nothing reads as
35+ * the mouse being broken rather than as a two-column miss.
36+ */
37+ sessionsFrame: Rect | null;
38+ editorFrame: Rect;
39+ chatFrame: Rect | null;
40+ /** Width of the editor's line-number gutter, including its trailing space. */
41+ gutter: number;
42+}
43+
44+export interface LayoutInput {
45+ rows: number;
46+ columns: number;
47+ sessionsWidth: number;
48+ chatWidth: number;
49+ /** Rows the log occupies including its border, or 0 when it is closed. */
50+ logRows: number;
51+ /** Show the side panels only above this width. */
52+ wideEnough: number;
53+ /** Line count, which sets how wide the gutter has to be. */
54+ lineCount: number;
55+ zoom: Zoom;
56+}
57+
58+/** Border (1) + horizontal padding (1) on each side of a bordered box. */
59+const FRAME = 2;
60+/** The status box: border, one row of content, border. */
61+const STATUS_HEIGHT = 3;
62+/** The help line under everything. */
63+const HELP_HEIGHT = 1;
64+
65+export function computeLayout(input: LayoutInput): Layout {
66+ const {
67+ rows, columns, sessionsWidth, chatWidth, logRows, wideEnough, lineCount, zoom,
68+ } = input;
69+ const wide = columns >= wideEnough;
70+
71+ // The panes start at the top of the screen; the status bar, the log (when
72+ // open) and the help line stack below them. Reading order follows where your
73+ // eyes already are — you look at the code, and the transport state is one
74+ // glance down rather than back up past it.
75+ const bodyTop = 0;
76+
77+ // Zoomed, the chat takes the whole body and the log goes with the rest: a
78+ // 34-column pane is fine for "added more sparkle" and hopeless for reading a
79+ // long explanation back, which is the whole reason to zoom.
80+ if (zoom === 'chat') {
81+ // The 2 is the zoomed chat box's own border, top and bottom.
82+ const height = Math.max(1, rows - STATUS_HEIGHT - HELP_HEIGHT - 2);
83+ const editor = { top: bodyTop, left: 1, width: Math.max(1, columns - 2), height: 0 };
84+ return {
85+ wide,
86+ zoom,
87+ sessions: null,
88+ sessionsFrame: null,
89+ editor,
90+ editorFrame: editor,
91+ chat: { top: bodyTop + 1, left: FRAME, width: Math.max(1, columns - FRAME * 2), height },
92+ chatFrame: { top: bodyTop, left: 0, width: columns, height: height + 2 },
93+ gutter: String(Math.max(1, lineCount)).length + 1,
94+ };
95+ }
96+
97+ const bodyHeight = Math.max(1, rows - STATUS_HEIGHT - logRows - HELP_HEIGHT);
98+
99+ const sessions: Rect | null = wide
100+ ? { top: bodyTop + 1, left: FRAME, width: sessionsWidth - FRAME * 2, height: bodyHeight - 2 }
101+ : null;
102+
103+ const editorLeft = wide ? sessionsWidth : 0;
104+ const editorWidth = columns - editorLeft - (wide ? chatWidth : 0);
105+
106+ const chat: Rect | null = wide
107+ ? {
108+ top: bodyTop + 1,
109+ left: columns - chatWidth + FRAME,
110+ width: chatWidth - FRAME * 2,
111+ height: bodyHeight - 2,
112+ }
113+ : null;
114+
115+ // The editor is not in a bordered box — only horizontal padding — so it is
116+ // one row taller than its neighbours and starts at the top of the body.
117+ const editor: Rect = {
118+ top: bodyTop,
119+ left: editorLeft + 1,
120+ width: Math.max(1, editorWidth - 2),
121+ height: bodyHeight,
122+ };
123+
124+ return {
125+ wide,
126+ zoom,
127+ sessions,
128+ chat,
129+ editor,
130+ sessionsFrame: wide
131+ ? { top: bodyTop, left: 0, width: sessionsWidth, height: bodyHeight }
132+ : null,
133+ chatFrame: wide
134+ ? { top: bodyTop, left: columns - chatWidth, width: chatWidth, height: bodyHeight }
135+ : null,
136+ editorFrame: { top: bodyTop, left: editorLeft, width: Math.max(1, editorWidth), height: bodyHeight },
137+ gutter: String(Math.max(1, lineCount)).length + 1,
138+ };
139+}
140+
141+export const contains = (rect: Rect | null, row: number, column: number): boolean =>
142+ rect !== null
143+ && row >= rect.top && row < rect.top + rect.height
144+ && column >= rect.left && column < rect.left + rect.width;
145+
146+/**
147+ * Which buffer position a click landed on.
148+ *
149+ * `scrollTop` is the first visible line — the editor scrolls to follow the
150+ * cursor, so a click on the third visible row is not line three.
151+ */
152+export function editorPosition(
153+ layout: Layout,
154+ scrollTop: number,
155+ row: number,
156+ column: number,
157+): { row: number; col: number } {
158+ const line = scrollTop + (row - layout.editor.top);
159+ const col = column - layout.editor.left - layout.gutter;
160+ return { row: Math.max(0, line), col: Math.max(0, col) };
161+}
addednode-tui/loader-hooks.ts+71 −0
1+// Module-resolution patches that stand in for what a Vite build normally does
2+// for Strudel. Registered by boot.ts.
3+//
4+// IMPORTANT: registerHooks() only takes effect for modules resolved *after* it
5+// runs, and an ESM graph is fully linked before any of it evaluates. A static
6+// `import '@strudel/core'` anywhere in the same graph as this file would
7+// therefore be linked before the hooks exist. That's why boot.ts imports the
8+// app with a dynamic `import()` — it defers resolution until after this module
9+// has evaluated. Don't turn that into a static import.
10+import { registerHooks } from 'node:module';
11+import { fileURLToPath } from 'node:url';
12+
13+const WORKLET_SUFFIX = '?audioworklet';
14+
15+// @strudel/core imports `SalatRepl` from @kabelsalat/web to support kabelsalat
16+// DSP graphs. That package ships a browser IIFE bundle, not ESM — Node's
17+// CJS named-export detection can't see through it, so linking @strudel/core
18+// fails outright with "does not provide an export named 'SalatRepl'".
19+//
20+// We don't use kabelsalat: it's reached only via the `kabel`/mondo code path,
21+// which is a separate DSP language from the patterns we evaluate. Rather than
22+// wire up a browser bundle we can't run anyway, hand core a stub that satisfies
23+// the import and throws only if a pattern actually tries to use it.
24+//
25+// The constructor must succeed — repl() instantiates SalatRepl eagerly at setup
26+// and only calls evaluate() from its compileKabel() path. Throwing from the
27+// constructor takes the whole REPL down at boot.
28+const KABELSALAT_STUB = `
29+export class SalatRepl {
30+ constructor() {}
31+ evaluate() {
32+ throw new Error('[node-tui] kabelsalat DSP is not available in the Node runtime');
33+ }
34+}
35+export default { SalatRepl };
36+`;
37+
38+registerHooks({
39+ resolve(specifier, context, next) {
40+ if (specifier === '@kabelsalat/web') {
41+ return { url: 'node-tui-stub:kabelsalat', shortCircuit: true };
42+ }
43+ if (specifier.endsWith(WORKLET_SUFFIX)) {
44+ const resolved = next(specifier.slice(0, -WORKLET_SUFFIX.length), context);
45+ return { ...resolved, url: resolved.url + WORKLET_SUFFIX, shortCircuit: true };
46+ }
47+ return next(specifier, context);
48+ },
49+
50+ load(url, context, next) {
51+ if (url === 'node-tui-stub:kabelsalat') {
52+ return { format: 'module', shortCircuit: true, source: KABELSALAT_STUB };
53+ }
54+ // superdough's *source* does `import workletsUrl from './worklets.mjs?audioworklet'`,
55+ // a Vite-only specifier. Resolve it to the module's path on disk, which
56+ // node-web-audio-api's addModule() accepts directly.
57+ //
58+ // (The published dist bundle inlines the worklets as a data: URL instead and
59+ // never hits this path — but superdough resolves to source in some setups,
60+ // and this costs nothing.)
61+ if (url.endsWith(WORKLET_SUFFIX)) {
62+ const path = fileURLToPath(url.slice(0, -WORKLET_SUFFIX.length));
63+ return {
64+ format: 'module',
65+ shortCircuit: true,
66+ source: `export default ${JSON.stringify(path)};`,
67+ };
68+ }
69+ return next(url, context);
70+ },
71+});
addednode-tui/mouse.test.ts+49 −0
1+// `node --test node-tui/mouse.test.ts`
2+import { test } from 'node:test';
3+import assert from 'node:assert/strict';
4+import { parseMouse, isMouseSequence } from './mouse.ts';
5+
6+test('parses a left click and converts to zero-based coordinates', () => {
7+ const [event] = parseMouse('\x1b[<0;35;12M');
8+ assert.deepEqual(event, { button: 'left', column: 34, row: 11, pressed: true });
9+});
10+
11+test('distinguishes press from release', () => {
12+ assert.equal(parseMouse('\x1b[<0;1;1m')[0]?.pressed, false);
13+});
14+
15+test('decodes the wheel', () => {
16+ assert.equal(parseMouse('\x1b[<64;1;1M')[0]?.button, 'wheel-up');
17+ assert.equal(parseMouse('\x1b[<65;1;1M')[0]?.button, 'wheel-down');
18+});
19+
20+test('ignores modifier bits when decoding the button', () => {
21+ // 16 is ctrl held; the button is still left.
22+ assert.equal(parseMouse('\x1b[<16;1;1M')[0]?.button, 'left');
23+});
24+
25+test('handles coordinates beyond the old 223-column limit', () => {
26+ const [event] = parseMouse('\x1b[<0;500;300M');
27+ assert.equal(event?.column, 499);
28+ assert.equal(event?.row, 299);
29+});
30+
31+test('parses several reports from one chunk', () => {
32+ assert.equal(parseMouse('\x1b[<0;1;1M\x1b[<0;1;1m\x1b[<65;2;2M').length, 3);
33+});
34+
35+test('is not confused by ordinary typing', () => {
36+ assert.deepEqual(parseMouse('hello'), []);
37+ assert.equal(isMouseSequence('hello'), false);
38+});
39+
40+test('recognises a mouse chunk so the keymap can skip it', () => {
41+ assert.equal(isMouseSequence('\x1b[<0;35;12M'), true);
42+});
43+
44+test('recognises a report whose ESC Ink already consumed', () => {
45+ // This is what useInput actually receives — without it, clicking types
46+ // "[<0;9;6M" into the pattern.
47+ assert.equal(isMouseSequence('[<0;9;6M'), true);
48+ assert.equal(parseMouse('[<0;9;6M')[0]?.button, 'left');
49+});
addednode-tui/mouse.ts+120 −0
1+// Mouse support.
2+//
3+// Ink has none — it reads keys and nothing else — so this turns tracking on at
4+// the terminal and parses the reports itself.
5+//
6+// Two escape sequences do the enabling. `?1000h` asks for button press/release
7+// reports; `?1006h` asks for them in SGR form. The second one matters: the
8+// original X10 encoding packs coordinates into single bytes offset by 32, which
9+// silently breaks past column 223 — a width any real terminal exceeds. SGR mode
10+// reports decimal coordinates instead and has no such ceiling.
11+//
12+// Both must be turned back off on exit. A terminal left in tracking mode after
13+// the program dies emits escape sequences into the user's shell every time they
14+// move the mouse, which looks like the shell is broken.
15+import { useEffect, useRef } from 'react';
16+import { useStdin, useStdout } from 'ink';
17+
18+const ENABLE = '\x1b[?1000h\x1b[?1006h';
19+const DISABLE = '\x1b[?1006l\x1b[?1000l';
20+
21+export type MouseButton = 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down';
22+
23+export interface MouseEvent {
24+ button: MouseButton;
25+ /** Zero-based, converted from the terminal's one-based coordinates. */
26+ column: number;
27+ row: number;
28+ pressed: boolean;
29+}
30+
31+// SGR reports look like `ESC [ < button ; column ; row (M|m)`, where M is a
32+// press and m a release.
33+//
34+// The leading ESC is optional here, and that is not defensiveness — Ink's key
35+// parser consumes the ESC before handing the rest to useInput, so the very same
36+// report reaches this module as `\x1b[<0;9;6M` on raw stdin and as `[<0;9;6M`
37+// through the keymap. Both have to be recognised: the first to be acted on, the
38+// second to be discarded before it gets typed into the buffer.
39+const SGR = /(?:\x1b)?\[<(\d+);(\d+);(\d+)([Mm])/g;
40+
41+/** True if a chunk contains a mouse report — used to keep them out of the keymap. */
42+export function isMouseSequence(input: string): boolean {
43+ return /(?:\x1b)?\[<\d+;\d+;\d+[Mm]/.test(input);
44+}
45+
46+function decodeButton(code: number): MouseButton | null {
47+ // Bit 6 marks wheel events; the low bits then say which direction.
48+ if (code & 64) return (code & 1) === 0 ? 'wheel-up' : 'wheel-down';
49+ // Bits 2-5 are modifier keys (shift/meta/ctrl) and drag state, not the button.
50+ switch (code & 3) {
51+ case 0: return 'left';
52+ case 1: return 'middle';
53+ case 2: return 'right';
54+ default: return null; // 3 is "released, button unknown"
55+ }
56+}
57+
58+/** Parse every mouse report in a chunk. Non-mouse bytes are ignored. */
59+export function parseMouse(chunk: string): MouseEvent[] {
60+ const events: MouseEvent[] = [];
61+ SGR.lastIndex = 0;
62+ let match: RegExpExecArray | null;
63+ while ((match = SGR.exec(chunk)) !== null) {
64+ const button = decodeButton(Number(match[1]));
65+ if (!button) continue;
66+ events.push({
67+ button,
68+ column: Number(match[2]) - 1,
69+ row: Number(match[3]) - 1,
70+ pressed: match[4] === 'M',
71+ });
72+ }
73+ return events;
74+}
75+
76+/**
77+ * Enable mouse reporting for as long as the component is mounted, and call
78+ * `onEvent` for each report.
79+ *
80+ * Only presses are delivered — releases would double every click, and nothing
81+ * here needs drag.
82+ */
83+export function useMouse(onEvent: (event: MouseEvent) => void): void {
84+ const { stdin, isRawModeSupported } = useStdin();
85+ const { stdout } = useStdout();
86+
87+ // The handler goes through a ref so the effect's dependencies stay stable.
88+ // Its natural dependencies — the layout, the song list — are rebuilt on every
89+ // render, and this component re-renders 25 times a second to animate. With
90+ // `onEvent` in the dependency array the effect would tear down and re-arm at
91+ // that rate: writing the enable/disable sequences continuously, and piling up
92+ // process 'exit' listeners until Node started warning about a leak. Reading
93+ // the latest handler from a ref means the terminal is configured exactly once.
94+ const handler = useRef(onEvent);
95+ handler.current = onEvent;
96+
97+ useEffect(() => {
98+ if (!isRawModeSupported) return;
99+
100+ stdout.write(ENABLE);
101+ const listener = (data: Buffer | string): void => {
102+ for (const event of parseMouse(data.toString('utf8'))) {
103+ if (event.pressed) handler.current(event);
104+ }
105+ };
106+ stdin.on('data', listener);
107+
108+ // Also disarm if the process is killed outright — an unmount handler alone
109+ // doesn't run on SIGINT/SIGTERM, and that's exactly when a terminal gets
110+ // left in tracking mode.
111+ const restore = (): void => void stdout.write(DISABLE);
112+ process.on('exit', restore);
113+
114+ return () => {
115+ stdin.off('data', listener);
116+ process.off('exit', restore);
117+ stdout.write(DISABLE);
118+ };
119+ }, [stdin, stdout, isRawModeSupported]);
120+}
addednode-tui/sessions.test.ts+111 −0
1+// `node --test node-tui/sessions.test.ts`
2+//
3+// Runs against a temporary directory via STRUDEL_SESSIONS, so it never touches
4+// a real library.
5+import { test, before, after } from 'node:test';
6+import assert from 'node:assert/strict';
7+import fs from 'node:fs/promises';
8+import os from 'node:os';
9+import path from 'node:path';
10+
11+const DIR = await fs.mkdtemp(path.join(os.tmpdir(), 'strudel-sessions-'));
12+process.env['STRUDEL_SESSIONS'] = DIR;
13+
14+// Imported after the env var is set — SESSIONS_DIR is resolved at module load.
15+const sessions = await import('./sessions.ts');
16+import type { Turn } from './claude.ts';
17+
18+after(async () => { await fs.rm(DIR, { recursive: true, force: true }); });
19+before(async () => { await fs.mkdir(DIR, { recursive: true }); });
20+
21+const HISTORY: Turn[] = [
22+ { role: 'user', content: 'make me a techno pattern' },
23+ { role: 'assistant', content: 'Here you go.', code: 's("bd*4")' },
24+];
25+
26+test('a saved session round-trips code and history together', async () => {
27+ await sessions.save('techno', 's("bd*4")', HISTORY);
28+ const [meta] = await sessions.list();
29+ assert.ok(meta);
30+ const loaded = await sessions.load(meta);
31+ assert.equal(loaded.code, 's("bd*4")');
32+ assert.deepEqual(loaded.history, HISTORY);
33+});
34+
35+test('names are slugified into something a shell will not fight', () => {
36+ assert.equal(sessions.slugify('Make Me A Techno!! pattern'), 'make-me-a-techno-pattern');
37+ assert.equal(sessions.slugify(' '), 'untitled');
38+ assert.equal(sessions.slugify('../../etc/passwd'), 'etc-passwd');
39+});
40+
41+test('a name is derived from the first user message', () => {
42+ assert.equal(sessions.nameFrom(HISTORY), 'make me a techno pattern');
43+ assert.equal(sessions.nameFrom([]), null);
44+ // Assistant-first history has nothing to name itself after.
45+ assert.equal(sessions.nameFrom([{ role: 'assistant', content: 'hi' }]), null);
46+});
47+
48+test('a long first message is truncated', () => {
49+ const long = 'x'.repeat(100);
50+ const name = sessions.nameFrom([{ role: 'user', content: long }]);
51+ assert.equal(name?.length, 33); // 32 chars + ellipsis
52+ assert.ok(name?.endsWith('…'));
53+});
54+
55+test('.str files are listed as code-only sessions', async () => {
56+ await fs.writeFile(path.join(DIR, 'plain.str'), 's("hh*8")\n');
57+ const list = await sessions.list();
58+ const plain = list.find((s) => s.name === 'plain');
59+ assert.ok(plain);
60+ assert.equal(plain.hasHistory, false);
61+ const loaded = await sessions.load(plain);
62+ assert.equal(loaded.code.trim(), 's("hh*8")');
63+ assert.deepEqual(loaded.history, []);
64+});
65+
66+test('a JSON session shadows a .str export of the same name', async () => {
67+ await sessions.save('both', 's("json")', HISTORY);
68+ await sessions.exportPlain('both', 's("plain")');
69+ const matches = (await sessions.list()).filter((s) => s.name === 'both');
70+ assert.equal(matches.length, 1, 'the same name must not appear twice');
71+ assert.equal(matches[0]?.hasHistory, true);
72+});
73+
74+test('a corrupt history costs the transcript, not the pattern', async () => {
75+ const file = path.join(DIR, 'broken.json');
76+ await fs.writeFile(file, JSON.stringify({ code: 's("bd")', history: 'not an array' }));
77+ const loaded = await sessions.load({
78+ name: 'broken', file, updatedAt: 0, hasHistory: true,
79+ });
80+ assert.equal(loaded.code, 's("bd")');
81+ assert.deepEqual(loaded.history, []);
82+});
83+
84+test('malformed turns are dropped individually', async () => {
85+ const file = path.join(DIR, 'partial.json');
86+ await fs.writeFile(file, JSON.stringify({
87+ code: 'x',
88+ history: [{ role: 'user', content: 'ok' }, null, { role: 'user' }, 'nope'],
89+ }));
90+ const loaded = await sessions.load({
91+ name: 'partial', file, updatedAt: 0, hasHistory: true,
92+ });
93+ assert.deepEqual(loaded.history, [{ role: 'user', content: 'ok' }]);
94+});
95+
96+test('the most recently touched session is listed first', async () => {
97+ await sessions.save('older', 'a', []);
98+ await new Promise((r) => setTimeout(r, 10));
99+ await sessions.save('newer', 'b', []);
100+ const list = await sessions.list();
101+ assert.equal(list[0]?.name, 'newer');
102+});
103+
104+test('listing an absent directory is empty, not an error', async () => {
105+ const gone = path.join(DIR, 'nope');
106+ const loaded = await fs.readdir(gone).catch(() => null);
107+ assert.equal(loaded, null); // precondition: really absent
108+ // list() reads SESSIONS_DIR, which exists; the guarantee is that a missing
109+ // directory yields [] rather than throwing, exercised on first run.
110+ assert.ok(Array.isArray(await sessions.list()));
111+});
addednode-tui/sessions.ts+139 −0
1+// Sessions: a pattern and the conversation that produced it, kept together.
2+//
3+// This mirrors the web app's model (src/sessions.js), where a session owns both
4+// the editor contents and the chat history. Keeping them together is the point:
5+// a pattern without the conversation that shaped it loses the "why", and asking
6+// Claude to change something it can't see the history of produces worse answers.
7+//
8+// Storage is one JSON file per session in a plain directory, rather than the
9+// browser's single localStorage blob. A directory is greppable, diffable, and
10+// survives this program — and one file per session means an unparseable session
11+// costs you that session, not the whole library.
12+//
13+// `.str` files in the same directory are read as code-only sessions. That's the
14+// plain-text import/export path: exactly the text you'd type into the editor,
15+// with nothing else around it, so a pattern can move between this, strudel.cc,
16+// and a gist without ceremony.
17+import fs from 'node:fs/promises';
18+import path from 'node:path';
19+import type { Turn } from './claude.ts';
20+
21+const JSON_EXT = '.json';
22+const PLAIN_EXT = '.str';
23+
24+/** Where sessions live. Override with STRUDEL_SESSIONS. */
25+export const SESSIONS_DIR = process.env['STRUDEL_SESSIONS']
26+ ?? path.join(process.cwd(), 'sessions');
27+
28+/** What the list needs to show a session without reading its body. */
29+export interface SessionMeta {
30+ name: string;
31+ file: string;
32+ updatedAt: number;
33+ /** False for a `.str` file, which carries no conversation. */
34+ hasHistory: boolean;
35+}
36+
37+export interface Session extends SessionMeta {
38+ code: string;
39+ history: Turn[];
40+}
41+
42+/** Filenames are user input; keep them to something a shell won't fight. */
43+export function slugify(name: string): string {
44+ const clean = name
45+ .trim()
46+ .toLowerCase()
47+ .replace(/[^a-z0-9._-]+/g, '-')
48+ .replace(/^[-.]+|[-.]+$/g, '')
49+ .slice(0, 64);
50+ return clean || 'untitled';
51+}
52+
53+/**
54+ * A name derived from the first thing the user asked for.
55+ *
56+ * Sessions accumulate whether or not you name them, and "untitled-4" tells you
57+ * nothing later. The opening request is usually a good description of what the
58+ * session became.
59+ */
60+export function nameFrom(history: readonly Turn[]): string | null {
61+ const first = history.find((t) => t.role === 'user')?.content.trim();
62+ if (!first) return null;
63+ return first.length > 32 ? `${first.slice(0, 32)}…` : first;
64+}
65+
66+export async function list(): Promise<SessionMeta[]> {
67+ let entries;
68+ try {
69+ entries = await fs.readdir(SESSIONS_DIR, { withFileTypes: true });
70+ } catch {
71+ return []; // no library yet — the first save creates it
72+ }
73+
74+ const found = new Map<string, SessionMeta>();
75+ for (const entry of entries) {
76+ if (!entry.isFile()) continue;
77+ const isJson = entry.name.endsWith(JSON_EXT);
78+ const isPlain = entry.name.endsWith(PLAIN_EXT);
79+ if (!isJson && !isPlain) continue;
80+
81+ const name = entry.name.slice(0, -(isJson ? JSON_EXT : PLAIN_EXT).length);
82+ // A session saved from here shadows a plain export of the same name — the
83+ // JSON is the one with the conversation in it.
84+ if (isPlain && found.has(name)) continue;
85+
86+ const file = path.join(SESSIONS_DIR, entry.name);
87+ const stat = await fs.stat(file);
88+ found.set(name, { name, file, updatedAt: stat.mtimeMs, hasHistory: isJson });
89+ }
90+
91+ // Most recently touched first: the session you're working on is the one you
92+ // want to get back to.
93+ return [...found.values()].sort((a, b) => b.updatedAt - a.updatedAt);
94+}
95+
96+export async function load(meta: SessionMeta): Promise<Session> {
97+ const raw = await fs.readFile(meta.file, 'utf8');
98+
99+ if (!meta.file.endsWith(JSON_EXT)) {
100+ return { ...meta, code: raw, history: [] };
101+ }
102+
103+ const parsed = JSON.parse(raw) as { code?: unknown; history?: unknown };
104+ return {
105+ ...meta,
106+ code: typeof parsed.code === 'string' ? parsed.code : '',
107+ // Anything malformed in the history is dropped rather than thrown over:
108+ // a corrupt transcript should cost you the transcript, not the pattern.
109+ history: Array.isArray(parsed.history)
110+ ? (parsed.history.filter(
111+ (t): t is Turn =>
112+ typeof t === 'object' && t !== null
113+ && (t as Turn).role !== undefined && typeof (t as Turn).content === 'string',
114+ ))
115+ : [],
116+ };
117+}
118+
119+export async function save(
120+ name: string,
121+ code: string,
122+ history: readonly Turn[],
123+): Promise<SessionMeta> {
124+ await fs.mkdir(SESSIONS_DIR, { recursive: true });
125+ const slug = slugify(name);
126+ const file = path.join(SESSIONS_DIR, `${slug}${JSON_EXT}`);
127+ const body = JSON.stringify({ name: slug, code, history }, null, 2);
128+ await fs.writeFile(file, `${body}\n`, 'utf8');
129+ const stat = await fs.stat(file);
130+ return { name: slug, file, updatedAt: stat.mtimeMs, hasHistory: true };
131+}
132+
133+/** Write just the pattern, as plain text — the export half of the `.str` path. */
134+export async function exportPlain(name: string, code: string): Promise<string> {
135+ await fs.mkdir(SESSIONS_DIR, { recursive: true });
136+ const file = path.join(SESSIONS_DIR, `${slugify(name)}${PLAIN_EXT}`);
137+ await fs.writeFile(file, code.endsWith('\n') ? code : `${code}\n`, 'utf8');
138+ return file;
139+}
addednode-tui/tsconfig.json+27 −0
1+{
2+ "compilerOptions": {
3+ // Node 24 strips types at runtime rather than compiling, so tsc is only
4+ // ever a checker here — never an emitter. `verbatimModuleSyntax` and
5+ // `erasableSyntaxOnly` keep us inside what stripping can actually handle:
6+ // no enums, no namespaces, no parameter properties, no emit-dependent
7+ // import elision.
8+ "noEmit": true,
9+ "erasableSyntaxOnly": true,
10+ "verbatimModuleSyntax": true,
11+
12+ "target": "es2023",
13+ "lib": ["es2023", "dom"],
14+ "module": "nodenext",
15+ "moduleResolution": "nodenext",
16+ // Node resolves the real file on disk, so imports name `./buffer.ts`.
17+ "allowImportingTsExtensions": true,
18+
19+ "strict": true,
20+ "noUncheckedIndexedAccess": true,
21+ "jsx": "react",
22+ "types": ["node"],
23+ "skipLibCheck": true
24+ },
25+ "include": ["**/*.ts"],
26+ "exclude": ["spike"]
27+}
addednode-tui/types/strudel.d.ts+86 −0
1+// Strudel and superdough ship no type declarations. Rather than let every call
2+// into them degrade to `any`, this describes the slice of their surface the TUI
3+// actually uses — which is small, and is exactly the boundary worth pinning
4+// down, since a rename upstream is otherwise a runtime crash.
5+
6+declare module '@strudel/core' {
7+ /** A single scheduled event: a value over a timespan. */
8+ export interface Hap {
9+ value: Record<string, unknown> & { s?: string; note?: unknown; n?: unknown };
10+ whole?: { begin: { valueOf(): number }; end: { valueOf(): number } };
11+ context?: { locations?: Array<{ start: number; end: number }> };
12+ }
13+
14+ export interface Pattern {
15+ queryArc(begin: number, end: number): Hap[];
16+ }
17+
18+ export interface Scheduler {
19+ cps: number;
20+ started: boolean;
21+ pattern?: Pattern;
22+ now(): number;
23+ }
24+
25+ export interface ReplState {
26+ code: string;
27+ activeCode: string;
28+ started: boolean;
29+ error?: Error;
30+ }
31+
32+ export type Output = (hap: Hap, deadline: number, duration: number, cps: number) => void;
33+
34+ export interface ReplOptions {
35+ defaultOutput: Output;
36+ getTime: () => number;
37+ transpiler?: unknown;
38+ afterEval?: (state: ReplState) => void;
39+ onEvalError?: (err: Error) => void;
40+ onToggle?: (started: boolean) => void;
41+ }
42+
43+ export interface Repl {
44+ scheduler: Scheduler;
45+ state: ReplState;
46+ evaluate(code: string, autostart?: boolean): Promise<unknown>;
47+ start(): void;
48+ stop(): void;
49+ pause(): void;
50+ toggle(): void;
51+ setCps(cps: number): void;
52+ }
53+
54+ export function repl(options: ReplOptions): Repl;
55+ export function evalScope(...modules: unknown[]): Promise<void>;
56+ export const controls: Record<string, unknown>;
57+ export const silence: Pattern;
58+}
59+
60+declare module '@strudel/transpiler' {
61+ export const transpiler: unknown;
62+}
63+
64+declare module '@strudel/mini';
65+declare module '@strudel/tonal';
66+
67+declare module 'superdough' {
68+ import type { Hap } from '@strudel/core';
69+
70+ export function setAudioContext(ctx: AudioContext): AudioContext;
71+ export function getAudioContext(): AudioContext;
72+ export function loadWorklets(): Promise<void>;
73+ export function superdough(
74+ value: Hap['value'] & { cps?: number },
75+ deadline: number,
76+ duration: number,
77+ cps?: number,
78+ ): void;
79+ export function samples(url: string): Promise<void>;
80+ export function aliasBank(url: string): Promise<void>;
81+ export function registerSynthSounds(): Promise<void>;
82+ export function registerZZFXSounds(): Promise<void>;
83+ export function setLogger(fn: (message: string, type?: string) => void): void;
84+}
85+
86+declare module 'node-web-audio-api/polyfill.js';
addednode-tui/ui/Chat.ts+124 −0
1+import { Box, Text } from 'ink';
2+import { memo } from 'react';
3+import type { ReactElement } from 'react';
4+import { html } from './html.ts';
5+import type { Turn } from '../claude.ts';
6+
7+/**
8+ * Wrap text to a width by words, so the transcript reads as prose rather than
9+ * being chopped mid-word.
10+ *
11+ * Ink can wrap a <Text> itself, but doing it here means the caller knows how
12+ * many rows a turn will occupy — which is what lets the pane show the *end* of
13+ * a long conversation instead of the start.
14+ */
15+function wrap(text: string, width: number): string[] {
16+ const out: string[] = [];
17+ for (const paragraph of text.split('\n')) {
18+ let line = '';
19+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
20+ if (line === '') line = word;
21+ else if (line.length + 1 + word.length <= width) line += ` ${word}`;
22+ else { out.push(line); line = word; }
23+ }
24+ out.push(line);
25+ }
26+ return out;
27+}
28+
29+export interface ChatProps {
30+ turns: readonly Turn[];
31+ /** Text the user is composing, or null when the composer isn't focused. */
32+ draft: string | null;
33+ pending: boolean;
34+ error: string | null;
35+ /** Whether ANTHROPIC_API_KEY is set — shown before the first turn. */
36+ hasKey: boolean;
37+ /** Whether the pane holds focus, which the prompt shows. */
38+ focused: boolean;
39+ width: number;
40+ height: number;
41+}
42+
43+export const Chat = memo(function Chat({
44+ turns, draft, pending, error, hasKey, focused, width, height,
45+}: ChatProps): ReactElement {
46+ // Rows available for the transcript, once the composer and any error line
47+ // have taken theirs.
48+ const body = Math.max(1, height - 1 - (error ? 1 : 0));
49+
50+ // Build the transcript bottom-up and stop once the pane is full: the newest
51+ // exchange is the one you need to see, and rendering from the top would push
52+ // it off the bottom in any conversation longer than the pane.
53+ //
54+ // The budget is a hard limit, not a stopping condition. Emitting a whole turn
55+ // because *some* of it fit overflows the box, and Ink's response to overflow
56+ // is to draw rows on top of each other — which looks like corrupted text, not
57+ // like a layout bug, and is very hard to read backwards. A turn that only
58+ // partly fits is therefore truncated to its last lines, which is also the
59+ // right half to keep: it's the end of the newest message.
60+ const rows: ReactElement[] = [];
61+ let used = 0;
62+ for (let i = turns.length - 1; i >= 0 && used < body; i--) {
63+ const turn = turns[i];
64+ if (!turn) continue;
65+ const user = turn.role === 'user';
66+ const all = wrap(turn.content, Math.max(8, width - 2));
67+
68+ // A blank row separates turns, but only when there's room for one. It goes
69+ // *below* the block: turns are prepended, so the separator belongs between
70+ // this turn and the newer one already below it — putting it on top would
71+ // leave a stray blank line above the oldest visible turn instead.
72+ const gap = rows.length > 0 ? 1 : 0;
73+ const room = body - used - gap;
74+ if (room <= 0) break;
75+ const lines = all.length > room ? all.slice(all.length - room) : all;
76+
77+ rows.unshift(html`
78+ <${Box} key=${i} flexDirection="column" marginBottom=${gap}>
79+ ${lines.map((line, j) => html`
80+ <${Text} key=${j} color=${user ? 'cyan' : undefined} dimColor=${!user}>
81+ ${j === 0 && lines.length === all.length ? (user ? '› ' : ' ') : ' '}${line}
82+ <//>
83+ `)}
84+ <//>
85+ `);
86+ used += lines.length + gap;
87+ }
88+
89+ return html`
90+ <${Box} flexDirection="column" height=${height}>
91+ <${Box} flexDirection="column" flexGrow=${1} overflow="hidden">
92+ ${rows.length === 0
93+ ? html`
94+ <${Box} flexDirection="column">
95+ <${Text} dimColor>ask for a pattern — ^P<//>
96+ ${hasKey
97+ ? null
98+ : html`<${Text} color="yellow" wrap="wrap">
99+ no ANTHROPIC_API_KEY — set it in .env, or run \`ant auth login\`
100+ <//>`}
101+ <//>
102+ `
103+ : rows}
104+ <//>
105+
106+ ${error ? html`<${Text} color="red" wrap="truncate-end">${error}<//>` : null}
107+
108+ ${/*
109+ The composer is a prompt whether or not it has focus. A hint that reads
110+ "^P to ask" describes the key rather than the thing, and it makes the
111+ bottom of the pane change shape the moment you press it; a prompt that
112+ is always there just becomes live. Dim until it is yours to type into,
113+ so it still says which pane the keys are going to.
114+ */ ''}
115+ <${Box}>
116+ ${pending
117+ ? html`<${Text} color="magenta">✦ thinking…<//>`
118+ : draft === null
119+ ? html`<${Text} color=${focused ? 'cyan' : undefined} dimColor=${!focused}>› <//>`
120+ : html`<${Text}><${Text} color="cyan">› <//>${draft}<${Text} inverse> <//><//>`}
121+ <//>
122+ <//>
123+ `;
124+});
addednode-tui/ui/Editor.ts+226 −0
1+// The code pane: the buffer, the cursor, and the follow-along highlighting.
2+//
3+// The cursor is drawn by inverting one character rather than by moving the
4+// terminal's real cursor: Ink repaints by rewriting the frame, so a hardware
5+// cursor position set during render doesn't survive. Inverting a cell is the
6+// only way to make it stay put.
7+//
8+// Rendering is a single left-to-right sweep per line that groups adjacent
9+// characters sharing a style into one <Text> run. That matters — a naive
10+// character-per-element render of a 60-column line is 60 React elements per
11+// line per frame, at 25fps, and Ink's diff is not free.
12+import { Box, Text } from 'ink';
13+import type { ReactElement } from 'react';
14+import { html } from './html.ts';
15+import * as buffer from '../buffer.ts';
16+import type { Buffer } from '../buffer.ts';
17+import type { Span } from '../highlights.ts';
18+
19+/** A run of characters sharing one style. */
20+interface Run {
21+ text: string;
22+ background?: string;
23+ foreground?: string;
24+ cursor: boolean;
25+ selected: boolean;
26+}
27+
28+interface CellStyle {
29+ background?: string;
30+ foreground?: string;
31+ selected: boolean;
32+}
33+
34+const sameStyle = (a: CellStyle, b: CellStyle): boolean =>
35+ a.background === b.background && a.foreground === b.foreground && a.selected === b.selected;
36+
37+/**
38+ * Group a line's characters into styled runs.
39+ *
40+ * `cursorCol` may sit one past the last character (that's where the cursor is
41+ * when appending), so the line is padded by one to give it a cell.
42+ */
43+function buildRuns(
44+ text: string,
45+ styles: ReadonlyMap<number, CellStyle>,
46+ cursorCol: number | null,
47+ selection: ReadonlySet<number>,
48+): Run[] {
49+ const width = cursorCol === null ? text.length : Math.max(text.length, cursorCol + 1);
50+ const padded = text.padEnd(width, ' ');
51+ const runs: Run[] = [];
52+
53+ for (let col = 0; col < width; col++) {
54+ const isCursor = col === cursorCol;
55+ const style: CellStyle = styles.get(col) ?? { selected: selection.has(col) };
56+ const cell: CellStyle = { ...style, selected: style.selected || selection.has(col) };
57+ const last = runs.at(-1);
58+
59+ // The cursor always breaks the run — it's exactly one cell wide.
60+ if (last && !isCursor && !last.cursor
61+ && sameStyle({ background: last.background, foreground: last.foreground, selected: last.selected }, cell)) {
62+ last.text += padded[col];
63+ continue;
64+ }
65+ const run: Run = { text: padded[col] ?? ' ', cursor: isCursor, selected: cell.selected };
66+ if (cell.background !== undefined) run.background = cell.background;
67+ if (cell.foreground !== undefined) run.foreground = cell.foreground;
68+ runs.push(run);
69+ }
70+ return runs;
71+}
72+
73+function renderRun(run: Run, i: number, cursorStyle: CursorStyle): ReactElement {
74+ // Cursor and selection both read as "inverted"; a highlighted cell keeps its
75+ // colour underneath, so a sounding character under the cursor still shows
76+ // which voice it belongs to.
77+ //
78+ // Insert mode underlines instead of inverting, which is the terminal's
79+ // closest equivalent to vim's bar cursor — you can still read the character
80+ // you're about to type over.
81+ const bar = run.cursor && cursorStyle === 'bar';
82+ return html`<${Text}
83+ key=${i}
84+ backgroundColor=${run.background}
85+ color=${run.foreground}
86+ inverse=${(run.cursor && !bar) || run.selected}
87+ underline=${bar}
88+ >${run.text}<//>`;
89+}
90+
91+/** Block in normal/visual mode, bar (underline) in insert — as vim does. */
92+export type CursorStyle = 'block' | 'bar';
93+
94+interface LineProps {
95+ text: string;
96+ cursorCol: number | null;
97+ number: number;
98+ gutterWidth: number;
99+ styles: ReadonlyMap<number, CellStyle>;
100+ selection: ReadonlySet<number>;
101+ /** The line the cursor is on — the only thing the gutter marks. */
102+ current: boolean;
103+ cursorStyle: CursorStyle;
104+}
105+
106+function Line({
107+ text, cursorCol, number, gutterWidth, styles, selection, current, cursorStyle,
108+}: LineProps): ReactElement {
109+ // The gutter tracks the cursor and nothing else. It used to light up for
110+ // whichever lines were sounding, which duplicated what the characters
111+ // themselves already say — and since a pattern lights most lines most of the
112+ // time, the one number worth finding at a glance was the one that never
113+ // stood out.
114+ const label = String(number).padStart(gutterWidth);
115+ const gutter = html`<${Text} color=${current ? 'cyan' : undefined} dimColor=${!current}>${label} ${''}<//>`;
116+
117+ if (cursorCol === null && styles.size === 0 && selection.size === 0) {
118+ return html`<${Box}>${gutter}<${Text}>${text}<//><//>`;
119+ }
120+ const runs = buildRuns(text, styles, cursorCol, selection);
121+ return html`<${Box}>${gutter}${runs.map((run, i) => renderRun(run, i, cursorStyle))}<//>`;
122+}
123+
124+export interface EditorProps {
125+ buffer: Buffer;
126+ height: number;
127+ /** Character ranges currently sounding, as offsets into the buffer text. */
128+ spans: readonly Span[];
129+ /** The visual-mode selection, as an offset range. */
130+ selection: { start: number; end: number } | null;
131+ cursorStyle: CursorStyle;
132+ /**
133+ * First visible line. Computed by the caller rather than here, because the
134+ * mouse hit test needs the same number — deriving it in two places is how a
135+ * click ends up on the wrong line.
136+ */
137+ scrollTop: number;
138+}
139+
140+/**
141+ * Turn offset ranges into per-row, per-column styles.
142+ *
143+ * Ranges can span lines; each row gets the slice that falls inside it, so a
144+ * multi-line range lights up on every line it touches rather than only the
145+ * first.
146+ */
147+function styleByRow(
148+ lines: readonly string[],
149+ spans: readonly Span[],
150+): Map<number, Map<number, CellStyle>> {
151+ const byRow = new Map<number, Map<number, CellStyle>>();
152+
153+ for (const span of spans) {
154+ const from = buffer.offsetToPosition(lines, span.start);
155+ const to = buffer.offsetToPosition(lines, span.end);
156+ for (let row = from.row; row <= to.row; row++) {
157+ const lineLength = (lines[row] ?? '').length;
158+ const startCol = row === from.row ? from.col : 0;
159+ const endCol = row === to.row ? to.col : lineLength;
160+ let cells = byRow.get(row);
161+ if (!cells) byRow.set(row, (cells = new Map()));
162+ for (let col = startCol; col < Math.max(endCol, startCol + 1) && col < lineLength; col++) {
163+ // Later spans win. Overlapping ranges mean two voices share a character,
164+ // and showing the most recent one beats blending into mud.
165+ cells.set(col, { background: span.background, foreground: span.foreground, selected: false });
166+ }
167+ }
168+ }
169+ return byRow;
170+}
171+
172+function selectionByRow(
173+ lines: readonly string[],
174+ selection: { start: number; end: number } | null,
175+): Map<number, Set<number>> {
176+ const byRow = new Map<number, Set<number>>();
177+ if (!selection) return byRow;
178+
179+ const from = buffer.offsetToPosition(lines, selection.start);
180+ const to = buffer.offsetToPosition(lines, selection.end);
181+ for (let row = from.row; row <= to.row; row++) {
182+ const lineLength = (lines[row] ?? '').length;
183+ const startCol = row === from.row ? from.col : 0;
184+ const endCol = row === to.row ? to.col : lineLength;
185+ const cols = new Set<number>();
186+ for (let col = startCol; col < endCol; col++) cols.add(col);
187+ byRow.set(row, cols);
188+ }
189+ return byRow;
190+}
191+
192+const EMPTY_STYLES: ReadonlyMap<number, CellStyle> = new Map();
193+const EMPTY_SELECTION: ReadonlySet<number> = new Set();
194+
195+export function Editor({
196+ buffer: bufferState, height, spans, selection, cursorStyle, scrollTop,
197+}: EditorProps): ReactElement {
198+ const { lines, row, col } = bufferState;
199+ const gutterWidth = String(lines.length).length;
200+
201+ const visible = Math.max(1, height);
202+ const top = scrollTop;
203+ const shown = lines.slice(top, top + visible);
204+
205+ const styles = styleByRow(lines, spans);
206+ const selected = selectionByRow(lines, selection);
207+
208+ return html`
209+ <${Box} flexDirection="column">
210+ ${shown.map((text, i) => {
211+ const lineRow = top + i;
212+ return html`<${Line}
213+ key=${lineRow}
214+ text=${text}
215+ number=${lineRow + 1}
216+ gutterWidth=${gutterWidth}
217+ cursorCol=${lineRow === row ? col : null}
218+ styles=${styles.get(lineRow) ?? EMPTY_STYLES}
219+ selection=${selected.get(lineRow) ?? EMPTY_SELECTION}
220+ current=${lineRow === row}
221+ cursorStyle=${cursorStyle}
222+ />`;
223+ })}
224+ <//>
225+ `;
226+}
addednode-tui/ui/LogPane.ts+57 −0
1+import { Box, Text } from 'ink';
2+import { memo } from 'react';
3+import type { ReactElement } from 'react';
4+import { html } from './html.ts';
5+import type { LogEntry } from '../browser-shim.ts';
6+
7+/** A log line plus the identity the list needs to render it stably. */
8+export interface LogRecord extends LogEntry {
9+ id: number;
10+ /** How many times this message repeated in a row. Absent means once. */
11+ count?: number;
12+}
13+
14+// superdough and @strudel/core both prefix their messages with a bracketed
15+// origin ("[sampler] load sound ..."). Dimming the prefix keeps the origin
16+// available without letting it dominate a pane that's mostly sample loading.
17+const PREFIX = /^(\[[^\]]+\])\s*([\s\S]*)$/;
18+
19+const COLORS: Record<string, string> = { error: 'red', warning: 'yellow' };
20+
21+function LogLine({ entry }: { entry: LogRecord }): ReactElement {
22+ const match = PREFIX.exec(entry.message);
23+ const color = entry.type ? COLORS[entry.type] : undefined;
24+ const repeat = entry.count && entry.count > 1
25+ ? html`<${Text} dimColor> ×${entry.count}<//>`
26+ : null;
27+
28+ if (!match) {
29+ return html`
30+ <${Text} wrap="truncate-end">
31+ <${Text} color=${color}>${entry.message}<//>${repeat}
32+ <//>
33+ `;
34+ }
35+
36+ return html`
37+ <${Text} wrap="truncate-end">
38+ <${Text} dimColor>${match[1]} <//>
39+ <${Text} color=${color}>${match[2]}<//>${repeat}
40+ <//>
41+ `;
42+}
43+
44+// Memoised because the app re-renders on every animation frame while the log
45+// itself changes only when something is logged. Ink still repaints the frame,
46+// but there is no reason to rebuild this subtree twenty times a second.
47+export const LogPane = memo(function LogPane({ entries, height }: {
48+ entries: readonly LogRecord[];
49+ height: number;
50+}): ReactElement {
51+ const shown = entries.slice(-height);
52+ return html`
53+ <${Box} flexDirection="column" height=${height}>
54+ ${shown.map((entry) => html`<${LogLine} key=${entry.id} entry=${entry} />`)}
55+ <//>
56+ `;
57+});
addednode-tui/ui/SessionList.ts+54 −0
1+import { Box, Text } from 'ink';
2+import { memo } from 'react';
3+import type { ReactElement } from 'react';
4+import { html } from './html.ts';
5+import type { SessionMeta } from '../sessions.ts';
6+
7+export interface SessionListProps {
8+ sessions: readonly SessionMeta[];
9+ /** Index of the highlighted row, or -1 for none. */
10+ selected: number;
11+ /** The session currently open in the editor. */
12+ current: string | null;
13+ /** Whether this panel has keyboard focus. */
14+ focused: boolean;
15+ height: number;
16+ /** First visible row — computed by the caller so clicks map to the same row. */
17+ scrollTop: number;
18+}
19+
20+export const SessionList = memo(function SessionList({
21+ sessions, selected, current, focused, height, scrollTop,
22+}: SessionListProps): ReactElement {
23+ if (sessions.length === 0) {
24+ return html`
25+ <${Box} flexDirection="column">
26+ <${Text} dimColor>no sessions yet<//>
27+ <${Text} dimColor>^S to save<//>
28+ <//>
29+ `;
30+ }
31+
32+ const top = scrollTop;
33+ const shown = sessions.slice(top, top + height);
34+
35+ return html`
36+ <${Box} flexDirection="column">
37+ ${shown.map((session, i) => {
38+ const index = top + i;
39+ const isSelected = index === selected;
40+ const isCurrent = session.name === current;
41+ return html`
42+ <${Text}
43+ key=${session.file}
44+ wrap="truncate-end"
45+ inverse=${isSelected && focused}
46+ bold=${isCurrent}
47+ color=${isCurrent ? 'magenta' : undefined}
48+ dimColor=${!isCurrent && !isSelected}
49+ >${isCurrent ? '▸ ' : ' '}${session.name}${session.hasHistory ? '' : ' ·'}<//>
50+ `;
51+ })}
52+ <//>
53+ `;
54+});
addednode-tui/ui/StatusBar.ts+81 −0
1+import { Box, Text } from 'ink';
2+import type { ReactElement, ReactNode } from 'react';
3+import { html } from './html.ts';
4+import type { Mode } from '../vim.ts';
5+
6+function Field({ label, children, color }: {
7+ label: string;
8+ children: ReactNode;
9+ color?: string;
10+}): ReactElement {
11+ return html`
12+ <${Box} marginRight=${2}>
13+ <${Text} dimColor>${label} <//>
14+ <${Text} color=${color}>${children}<//>
15+ <//>
16+ `;
17+}
18+
19+const MODE_COLORS: Record<Mode, string> = {
20+ normal: 'blue',
21+ insert: 'green',
22+ visual: 'yellow',
23+ 'visual-line': 'yellow',
24+};
25+
26+const MODE_LABELS: Record<Mode, string> = {
27+ normal: 'NORMAL',
28+ insert: 'INSERT',
29+ visual: 'VISUAL',
30+ 'visual-line': 'V-LINE',
31+};
32+
33+export interface StatusBarProps {
34+ playing: boolean;
35+ cps: number;
36+ cycle: number;
37+ /** True when the buffer has edits that haven't been evaluated yet. */
38+ dirty: boolean;
39+ mode: Mode;
40+ /** Keys accumulated toward a command — vim's bottom-right indicator. */
41+ pending: string;
42+ session: string | null;
43+ error: string | null;
44+}
45+
46+export function StatusBar({
47+ playing, cps, cycle, dirty, mode, pending, session, error,
48+}: StatusBarProps): ReactElement {
49+ const modeChip = html`
50+ <${Text} backgroundColor=${MODE_COLORS[mode]} color="black" bold> ${MODE_LABELS[mode]} <//>
51+ `;
52+ // An eval error is the one thing worth losing the whole status line over —
53+ // it's why the pattern you just heard isn't the pattern on screen.
54+ if (error) {
55+ return html`
56+ <${Box}>
57+ ${modeChip}
58+ <${Text} backgroundColor="red" color="white" bold> ERROR <//>
59+ <${Text} color="red" wrap="truncate-end"> ${error}<//>
60+ <//>
61+ `;
62+ }
63+
64+ return html`
65+ <${Box}>
66+ ${modeChip}
67+ <${Text}> <//>
68+ <${Text} color=${playing ? 'green' : 'gray'} bold>
69+ ${playing ? '▶ playing' : '■ stopped'}
70+ <//>
71+ <${Text}> <//>
72+ <${Field} label="cpm">${(cps * 60).toFixed(0)}<//>
73+ <${Field} label="cycle">${cycle.toFixed(1)}<//>
74+ ${session ? html`<${Field} label="♫" color="magenta">${session}<//>` : null}
75+ ${dirty ? html`<${Text} color="yellow">● unevaluated<//>` : null}
76+ <${Box} flexGrow=${1} justifyContent="flex-end">
77+ <${Text} dimColor>${pending}<//>
78+ <//>
79+ <//>
80+ `;
81+}
addednode-tui/ui/html.ts+17 −0
1+// htm bound to React's createElement, so components can be written as tagged
2+// templates instead of nested createElement calls. This buys us JSX-like markup
3+// with no build step — which matters twice over here: the Node TUI runs straight
4+// off `node` with no Vite in the loop, and Node's native TypeScript support
5+// strips types but does *not* understand JSX, so .tsx would need a bundler.
6+import { createElement } from 'react';
7+import type { ReactElement } from 'react';
8+import htm from 'htm';
9+
10+// htm's own types describe it as a namespace with a call signature, which
11+// doesn't carry `bind` through. The bound result is what we actually care about,
12+// so the shape is declared here rather than fought upstream.
13+type Html = (strings: TemplateStringsArray, ...values: unknown[]) => ReactElement;
14+
15+export const html = (htm as unknown as { bind(fn: typeof createElement): Html }).bind(
16+ createElement,
17+);
addednode-tui/vim.test.ts+212 −0
1+// Vim grammar tests. `node --test node-tui/vim.test.ts`
2+//
3+// The editing layer is a pure reducer precisely so it can be checked without a
4+// terminal or an audio context — which matters, because "did `d2w` delete the
5+// right span" is not a question you want to answer by squinting at a TUI.
6+import { test } from 'node:test';
7+import assert from 'node:assert/strict';
8+
9+import * as vim from './vim.ts';
10+import * as buf from './buffer.ts';
11+
12+const KEY: vim.Key = {
13+ input: '', ctrl: false, escape: false, return: false, backspace: false,
14+ tab: false, leftArrow: false, rightArrow: false, upArrow: false, downArrow: false,
15+};
16+
17+/** Feed a string of keystrokes. `<esc>` and `<cr>` stand in for those keys. */
18+function press(state: vim.VimState, keys: string): vim.VimState {
19+ let current = state;
20+ const tokens = keys.match(/<esc>|<cr>|[\s\S]/g) ?? [];
21+ for (const token of tokens) {
22+ const key: vim.Key =
23+ token === '<esc>' ? { ...KEY, escape: true }
24+ : token === '<cr>' ? { ...KEY, return: true }
25+ : { ...KEY, input: token };
26+ current = vim.handleKey(current, key).state;
27+ }
28+ return current;
29+}
30+
31+const text = (state: vim.VimState): string => buf.toText(state.buffer);
32+const start = (source: string): vim.VimState => vim.createVimState(source);
33+
34+test('starts in normal mode and does not insert text', () => {
35+ const state = press(start('hello'), 'jkl');
36+ assert.equal(text(state), 'hello');
37+ assert.equal(state.mode, 'normal');
38+});
39+
40+test('i enters insert mode and types', () => {
41+ const state = press(start('bd'), 'iX');
42+ assert.equal(text(state), 'Xbd');
43+ assert.equal(state.mode, 'insert');
44+});
45+
46+test('escape leaves insert mode and steps the cursor back', () => {
47+ const state = press(start('bd'), 'iX<esc>');
48+ assert.equal(state.mode, 'normal');
49+ assert.equal(buf.cursorOffset(state.buffer), 0);
50+});
51+
52+test('A appends at end of line', () => {
53+ assert.equal(text(press(start('s("bd")'), 'A.fast(2)<esc>')), 's("bd").fast(2)');
54+});
55+
56+test('x deletes the character under the cursor', () => {
57+ assert.equal(text(press(start('abc'), 'x')), 'bc');
58+});
59+
60+test('x with a count deletes several', () => {
61+ assert.equal(text(press(start('abcdef'), '3x')), 'def');
62+});
63+
64+test('dw deletes to the start of the next word', () => {
65+ assert.equal(text(press(start('foo bar baz'), 'dw')), 'bar baz');
66+});
67+
68+test('d2w takes a count between operator and motion', () => {
69+ assert.equal(text(press(start('foo bar baz'), 'd2w')), 'baz');
70+});
71+
72+test('de is inclusive of the word end', () => {
73+ assert.equal(text(press(start('foo bar'), 'de')), ' bar');
74+});
75+
76+test('dd deletes the whole line', () => {
77+ assert.equal(text(press(start('one\ntwo\nthree'), 'jdd')), 'one\nthree');
78+});
79+
80+test('2dd deletes two lines', () => {
81+ assert.equal(text(press(start('one\ntwo\nthree'), '2dd')), 'three');
82+});
83+
84+test('dd on the last line does not leave a blank', () => {
85+ assert.equal(text(press(start('one\ntwo'), 'jdd')), 'one');
86+});
87+
88+test('D deletes to end of line, keeping the line', () => {
89+ assert.equal(text(press(start('foo bar\nbaz'), 'wD')), 'foo \nbaz');
90+});
91+
92+test('cw changes a word and lands in insert mode', () => {
93+ const state = press(start('foo bar'), 'cwqux');
94+ assert.equal(text(state), 'qux bar');
95+ assert.equal(state.mode, 'insert');
96+});
97+
98+test('o opens a line below and inherits indentation', () => {
99+ const state = press(start(' s("bd")'), 'o.fast(2)');
100+ assert.equal(text(state), ' s("bd")\n .fast(2)');
101+});
102+
103+test('O opens a line above', () => {
104+ assert.equal(text(press(start('two'), 'Oone<esc>')), 'one\ntwo');
105+});
106+
107+test('yy then p duplicates a line below', () => {
108+ assert.equal(text(press(start('one\ntwo'), 'yyp')), 'one\none\ntwo');
109+});
110+
111+test('dd then P puts the line back where it was', () => {
112+ assert.equal(text(press(start('one\ntwo'), 'ddP')), 'one\ntwo');
113+});
114+
115+test('u undoes an edit', () => {
116+ assert.equal(text(press(start('one\ntwo'), 'ddu')), 'one\ntwo');
117+});
118+
119+test('undo is not confused by cursor-only motions', () => {
120+ assert.equal(text(press(start('abc'), 'xlllu')), 'abc');
121+});
122+
123+test('0 and $ move within the line', () => {
124+ const state = press(start('hello'), '$');
125+ assert.equal(state.buffer.col, 4);
126+ assert.equal(press(state, '0').buffer.col, 0);
127+});
128+
129+test('a bare 0 is a motion, but 10 is a count', () => {
130+ const ten = press(start('a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk'), '10j');
131+ assert.equal(ten.buffer.row, 10);
132+});
133+
134+test('gg and G jump to the first and last line', () => {
135+ const state = press(start('one\ntwo\nthree'), 'G');
136+ assert.equal(state.buffer.row, 2);
137+ assert.equal(press(state, 'gg').buffer.row, 0);
138+});
139+
140+test('G with a count goes to that line', () => {
141+ assert.equal(press(start('one\ntwo\nthree'), '2G').buffer.row, 1);
142+});
143+
144+test('the cursor may not rest past the last character in normal mode', () => {
145+ const state = press(start('ab\n'), '$lll');
146+ assert.equal(state.buffer.col, 1);
147+});
148+
149+test('visual mode selects and deletes', () => {
150+ const state = press(start('foo bar'), 'vlld');
151+ assert.equal(text(state), ' bar');
152+ assert.equal(state.mode, 'normal');
153+});
154+
155+test('V selects whole lines', () => {
156+ assert.equal(text(press(start('one\ntwo\nthree'), 'Vjd')), 'three');
157+});
158+
159+test('ctrl-e requests evaluation without touching the buffer', () => {
160+ const result = vim.handleKey(start('s("bd")'), { ...KEY, ctrl: true, input: 'e' });
161+ assert.equal(result.effects.evaluate, true);
162+ assert.equal(text(result.state), 's("bd")');
163+});
164+
165+test('ctrl-e works from insert mode too', () => {
166+ const state = press(start('x'), 'i');
167+ const result = vim.handleKey(state, { ...KEY, ctrl: true, input: 'e' });
168+ assert.equal(result.effects.evaluate, true);
169+ assert.equal(result.state.mode, 'insert');
170+});
171+
172+test('enter evaluates from normal mode', () => {
173+ const result = vim.handleKey(start('s("bd")'), { ...KEY, return: true });
174+ assert.equal(result.effects.evaluate, true);
175+ assert.equal(text(result.state), 's("bd")');
176+ assert.equal(result.state.mode, 'normal');
177+});
178+
179+test('enter evaluates from visual mode and drops a half-typed count', () => {
180+ const result = vim.handleKey(press(start('one\ntwo'), 'v2'), { ...KEY, return: true });
181+ assert.equal(result.effects.evaluate, true);
182+ assert.equal(result.state.pending, '');
183+ assert.equal(text(result.state), 'one\ntwo');
184+});
185+
186+test('enter still inserts a newline in insert mode', () => {
187+ const result = vim.handleKey(press(start('ab'), 'i'), { ...KEY, return: true });
188+ assert.equal(result.effects.evaluate, undefined);
189+ assert.equal(text(result.state), '\nab');
190+});
191+
192+test('an unknown command is dropped rather than left pending', () => {
193+ const state = press(start('abc'), 'Z');
194+ assert.equal(state.pending, '');
195+ assert.equal(text(state), 'abc');
196+});
197+
198+test('a partial operator stays pending', () => {
199+ assert.equal(press(start('abc'), 'd').pending, 'd');
200+ assert.equal(press(start('abc'), 'd2').pending, 'd2');
201+});
202+
203+test('word motions stop at punctuation, as vim does', () => {
204+ // The cursor should land on the `(`, not skip the whole call.
205+ const state = press(start('s("bd")'), 'w');
206+ assert.equal(buf.cursorOffset(state.buffer), 1);
207+});
208+
209+test('W treats punctuation as part of the word', () => {
210+ const state = press(start('s("bd") fast'), 'W');
211+ assert.equal(buf.cursorOffset(state.buffer), 8);
212+});
addednode-tui/vim.ts+597 −0
1+// Modal editing.
2+//
3+// Not a vim emulation in any complete sense — it's the subset that matters when
4+// the text you're editing is eight lines of pattern code you keep re-evaluating:
5+// motions to get around, operators to restructure, undo to back out of an idea.
6+// Anything reachable through a different key is deliberately absent rather than
7+// half-implemented, so nothing here silently does the wrong thing.
8+//
9+// The whole layer is a pure reducer: (state, key) → state. That keeps the
10+// grammar testable without a terminal, and it's what makes counts and pending
11+// operators tractable — "d2w" is three keystrokes that must accumulate
12+// somewhere, and accumulating them in state beats a stateful parser object.
13+//
14+// Commands are expressed as offsets into the flat text (see buffer.ts) so that
15+// motions spanning lines, joins, and multi-line deletes are all one operation.
16+import * as buf from './buffer.ts';
17+import type { Buffer } from './buffer.ts';
18+
19+export type Mode = 'normal' | 'insert' | 'visual' | 'visual-line';
20+
21+/** What the caller may need to act on after a keystroke. */
22+export interface VimEffects {
23+ /** The user asked to evaluate the buffer. */
24+ evaluate?: boolean;
25+ /** The user asked to stop playback. */
26+ hush?: boolean;
27+ /** A message for the status line (errors, "3 lines yanked"). */
28+ message?: string;
29+}
30+
31+export interface VimState {
32+ buffer: Buffer;
33+ mode: Mode;
34+ /** Keystrokes accumulated toward a command, e.g. "d2" waiting on a motion. */
35+ pending: string;
36+ /** The unnamed register. `linewise` decides whether `p` pastes above or after. */
37+ register: { text: string; linewise: boolean };
38+ /** Where visual mode started, as an offset. */
39+ anchor: number | null;
40+ undoStack: Buffer[];
41+ redoStack: Buffer[];
42+}
43+
44+const UNDO_LIMIT = 200;
45+
46+export function createVimState(text: string): VimState {
47+ return {
48+ buffer: buf.create(text),
49+ mode: 'normal',
50+ pending: '',
51+ register: { text: '', linewise: false },
52+ anchor: null,
53+ undoStack: [],
54+ redoStack: [],
55+ };
56+}
57+
58+// ---------------------------------------------------------------------------
59+// Character classes
60+//
61+// vim's notion of a "word" is three classes, not two: word characters,
62+// punctuation, and whitespace. `w` stops at the boundary between word and
63+// punctuation, which is why it lands usefully inside `s("bd*2")` instead of
64+// skipping the whole expression.
65+// ---------------------------------------------------------------------------
66+
67+type CharClass = 'space' | 'word' | 'punct';
68+
69+function classOf(ch: string | undefined): CharClass {
70+ if (ch === undefined || /\s/.test(ch)) return 'space';
71+ if (/[\w]/.test(ch)) return 'word';
72+ return 'punct';
73+}
74+
75+// ---------------------------------------------------------------------------
76+// Motions
77+// ---------------------------------------------------------------------------
78+
79+/**
80+ * How a motion's range is treated when an operator consumes it.
81+ *
82+ * - `exclusive`: the character at the destination is not included (`dw`)
83+ * - `inclusive`: it is (`de`, `x`)
84+ * - `linewise`: the whole lines the range touches (`dd`, `dj`)
85+ */
86+type MotionKind = 'exclusive' | 'inclusive' | 'linewise';
87+
88+interface MotionResult {
89+ offset: number;
90+ kind: MotionKind;
91+}
92+
93+const lineBounds = (text: string, offset: number): { start: number; end: number } => {
94+ const start = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
95+ const nl = text.indexOf('\n', offset);
96+ return { start, end: nl === -1 ? text.length : nl };
97+};
98+
99+function forwardWord(text: string, from: number, bigWord: boolean): number {
100+ let i = from;
101+ const startClass = classOf(text[i]);
102+ if (startClass !== 'space') {
103+ // Skip the rest of the current run. With `W`, punctuation counts as part of
104+ // the word, so only whitespace ends it.
105+ while (i < text.length && classOf(text[i]) !== 'space'
106+ && (bigWord || classOf(text[i]) === startClass)) i++;
107+ }
108+ while (i < text.length && classOf(text[i]) === 'space') i++;
109+ return i;
110+}
111+
112+function backWord(text: string, from: number, bigWord: boolean): number {
113+ let i = from - 1;
114+ while (i > 0 && classOf(text[i]) === 'space') i--;
115+ if (i <= 0) return 0;
116+ const cls = classOf(text[i]);
117+ while (i > 0 && classOf(text[i - 1]) !== 'space'
118+ && (bigWord || classOf(text[i - 1]) === cls)) i--;
119+ return Math.max(0, i);
120+}
121+
122+function endOfWord(text: string, from: number, bigWord: boolean): number {
123+ let i = from + 1;
124+ while (i < text.length && classOf(text[i]) === 'space') i++;
125+ if (i >= text.length) return text.length - 1;
126+ const cls = classOf(text[i]);
127+ while (i + 1 < text.length && classOf(text[i + 1]) !== 'space'
128+ && (bigWord || classOf(text[i + 1]) === cls)) i++;
129+ return i;
130+}
131+
132+/** Move `count` lines vertically, keeping the column the cursor is aiming for. */
133+function verticalMove(state: VimState, count: number): number {
134+ const { lines } = state.buffer;
135+ const row = Math.max(0, Math.min(state.buffer.row + count, lines.length - 1));
136+ const col = Math.min(state.buffer.desiredCol, (lines[row] ?? '').length);
137+ return buf.positionToOffset(lines, { row, col });
138+}
139+
140+/** Offset of the first non-blank character on the cursor's line. */
141+function firstNonBlank(text: string, offset: number): number {
142+ const { start, end } = lineBounds(text, offset);
143+ let i = start;
144+ while (i < end && /\s/.test(text[i] ?? '')) i++;
145+ return i;
146+}
147+
148+function paragraphMove(text: string, from: number, forward: boolean): number {
149+ const lines = text.split('\n');
150+ const { row } = buf.offsetToPosition(lines, from);
151+ const step = forward ? 1 : -1;
152+ let r = row + step;
153+ while (r > 0 && r < lines.length - 1 && (lines[r] ?? '').trim() !== '') r += step;
154+ return buf.positionToOffset(lines, { row: Math.max(0, Math.min(r, lines.length - 1)), col: 0 });
155+}
156+
157+/**
158+ * Resolve a motion key to a destination. Returns null when the key isn't a
159+ * motion, which is how the command parser tells motions from everything else.
160+ */
161+function resolveMotion(
162+ state: VimState,
163+ key: string,
164+ count: number,
165+ explicitCount: boolean,
166+): MotionResult | null {
167+ const text = buf.toText(state.buffer);
168+ const at = buf.cursorOffset(state.buffer);
169+ const { start, end } = lineBounds(text, at);
170+ const lines = state.buffer.lines;
171+
172+ switch (key) {
173+ case 'h': return { offset: Math.max(start, at - count), kind: 'exclusive' };
174+ case 'l': return { offset: Math.min(end, at + count), kind: 'exclusive' };
175+ case 'j': return { offset: verticalMove(state, count), kind: 'linewise' };
176+ case 'k': return { offset: verticalMove(state, -count), kind: 'linewise' };
177+ case '0': return { offset: start, kind: 'exclusive' };
178+ case '^': return { offset: firstNonBlank(text, at), kind: 'exclusive' };
179+ case '$': return { offset: end, kind: 'inclusive' };
180+ case 'w': case 'W': {
181+ let o = at;
182+ for (let i = 0; i < count; i++) o = forwardWord(text, o, key === 'W');
183+ return { offset: o, kind: 'exclusive' };
184+ }
185+ case 'b': case 'B': {
186+ let o = at;
187+ for (let i = 0; i < count; i++) o = backWord(text, o, key === 'B');
188+ return { offset: o, kind: 'exclusive' };
189+ }
190+ case 'e': case 'E': {
191+ let o = at;
192+ for (let i = 0; i < count; i++) o = endOfWord(text, o, key === 'E');
193+ return { offset: o, kind: 'inclusive' };
194+ }
195+ case '{': return { offset: paragraphMove(text, at, false), kind: 'exclusive' };
196+ case '}': return { offset: paragraphMove(text, at, true), kind: 'exclusive' };
197+ case 'G': {
198+ // Bare G goes to the last line; with a count, to that line number.
199+ const row = explicitCount
200+ ? Math.max(0, Math.min(count - 1, lines.length - 1))
201+ : lines.length - 1;
202+ return { offset: buf.positionToOffset(lines, { row, col: 0 }), kind: 'linewise' };
203+ }
204+ default: return null;
205+ }
206+}
207+
208+// ---------------------------------------------------------------------------
209+// Operators
210+// ---------------------------------------------------------------------------
211+
212+/** Widen a range to whole lines, including the trailing newline if there is one. */
213+function expandLinewise(text: string, from: number, to: number): { start: number; end: number } {
214+ const a = lineBounds(text, Math.min(from, to));
215+ const b = lineBounds(text, Math.max(from, to));
216+ // Take the newline *after* the last line so `dd` removes the line rather than
217+ // leaving a blank one. At end of file there is none, so take the one before.
218+ if (b.end < text.length) return { start: a.start, end: b.end + 1 };
219+ return { start: Math.max(0, a.start - 1), end: b.end };
220+}
221+
222+function pushUndo(state: VimState): Pick<VimState, 'undoStack' | 'redoStack'> {
223+ const undoStack = [...state.undoStack, state.buffer].slice(-UNDO_LIMIT);
224+ return { undoStack, redoStack: [] };
225+}
226+
227+function applyOperator(
228+ state: VimState,
229+ op: 'd' | 'c' | 'y',
230+ from: number,
231+ to: number,
232+ kind: MotionKind,
233+): VimState {
234+ const text = buf.toText(state.buffer);
235+ const linewise = kind === 'linewise';
236+
237+ let start = Math.min(from, to);
238+ let end = Math.max(from, to);
239+ if (linewise) {
240+ ({ start, end } = expandLinewise(text, from, to));
241+ } else if (kind === 'inclusive') {
242+ end = Math.min(end + 1, text.length);
243+ }
244+
245+ const taken = text.slice(start, end);
246+ const register = { text: taken, linewise };
247+
248+ if (op === 'y') {
249+ // Yank leaves the text alone and, as in vim, parks the cursor at the start
250+ // of what was yanked.
251+ return { ...state, register, buffer: buf.setCursorOffset(state.buffer, start), mode: 'normal', anchor: null };
252+ }
253+
254+ // `cc`/`c`-linewise keeps the line itself so you type into an empty line
255+ // rather than having the line vanish under you.
256+ const replacement = op === 'c' && linewise ? '\n' : '';
257+ const cursorAt = op === 'c' && linewise ? start : start;
258+
259+ return {
260+ ...state,
261+ ...pushUndo(state),
262+ register,
263+ buffer: buf.replaceRange(state.buffer, start, end, replacement, cursorAt),
264+ mode: op === 'c' ? 'insert' : 'normal',
265+ anchor: null,
266+ };
267+}
268+
269+function paste(state: VimState, before: boolean): VimState {
270+ const { text: reg, linewise } = state.register;
271+ if (!reg) return state;
272+ const text = buf.toText(state.buffer);
273+ const at = buf.cursorOffset(state.buffer);
274+
275+ if (linewise) {
276+ const { start, end } = lineBounds(text, at);
277+ const payload = reg.endsWith('\n') ? reg : `${reg}\n`;
278+ const insertAt = before ? start : Math.min(end + 1, text.length);
279+ // At end of file with no trailing newline there's nowhere "after" to paste,
280+ // so open one.
281+ const needsLeadingNewline = !before && end >= text.length;
282+ const body = needsLeadingNewline ? `\n${payload.replace(/\n$/, '')}` : payload;
283+ return {
284+ ...state,
285+ ...pushUndo(state),
286+ buffer: buf.replaceRange(state.buffer, insertAt, insertAt, body, insertAt + (needsLeadingNewline ? 1 : 0)),
287+ };
288+ }
289+
290+ const insertAt = before ? at : Math.min(at + 1, text.length);
291+ return {
292+ ...state,
293+ ...pushUndo(state),
294+ buffer: buf.replaceRange(state.buffer, insertAt, insertAt, reg, insertAt + reg.length - 1),
295+ };
296+}
297+
298+function openLine(state: VimState, below: boolean): VimState {
299+ const text = buf.toText(state.buffer);
300+ const at = buf.cursorOffset(state.buffer);
301+ const { start, end } = lineBounds(text, at);
302+ // Match the current line's indentation — patterns are chains of indented
303+ // continuations, and re-typing the leading spaces every time is friction.
304+ const indent = /^\s*/.exec(text.slice(start, end))?.[0] ?? '';
305+
306+ const insertAt = below ? end : start;
307+ const body = below ? `\n${indent}` : `${indent}\n`;
308+ const cursorAt = below ? end + 1 + indent.length : start + indent.length;
309+
310+ return {
311+ ...state,
312+ ...pushUndo(state),
313+ buffer: buf.replaceRange(state.buffer, insertAt, insertAt, body, cursorAt),
314+ mode: 'insert',
315+ };
316+}
317+
318+function undo(state: VimState): VimState {
319+ const previous = state.undoStack.at(-1);
320+ if (!previous) return state;
321+ return {
322+ ...state,
323+ buffer: previous,
324+ undoStack: state.undoStack.slice(0, -1),
325+ redoStack: [...state.redoStack, state.buffer],
326+ };
327+}
328+
329+function redo(state: VimState): VimState {
330+ const next = state.redoStack.at(-1);
331+ if (!next) return state;
332+ return {
333+ ...state,
334+ buffer: next,
335+ redoStack: state.redoStack.slice(0, -1),
336+ undoStack: [...state.undoStack, state.buffer],
337+ };
338+}
339+
340+/**
341+ * In normal mode the cursor sits *on* a character, so it may not rest past the
342+ * last one — otherwise it appears to hang in space at end of line.
343+ */
344+function clampNormal(state: VimState): VimState {
345+ if (state.mode === 'insert') return state;
346+ const len = (state.buffer.lines[state.buffer.row] ?? '').length;
347+ if (state.buffer.col < Math.max(len, 1)) return state;
348+ const col = Math.max(0, len - 1);
349+ return { ...state, buffer: { ...state.buffer, col, desiredCol: col } };
350+}
351+
352+// ---------------------------------------------------------------------------
353+// Key dispatch
354+// ---------------------------------------------------------------------------
355+
356+/** A keystroke, normalised out of Ink's (input, key) pair. */
357+export interface Key {
358+ input: string;
359+ ctrl: boolean;
360+ escape: boolean;
361+ return: boolean;
362+ backspace: boolean;
363+ tab: boolean;
364+ leftArrow: boolean;
365+ rightArrow: boolean;
366+ upArrow: boolean;
367+ downArrow: boolean;
368+}
369+
370+export interface VimResult {
371+ state: VimState;
372+ effects: VimEffects;
373+}
374+
375+const NONE: VimEffects = {};
376+
377+function insertModeKey(state: VimState, key: Key): VimResult {
378+ if (key.escape) {
379+ // vim steps left on leaving insert mode, so the cursor lands on the
380+ // character you just typed rather than past it.
381+ const at = buf.cursorOffset(state.buffer);
382+ const text = buf.toText(state.buffer);
383+ const { start } = lineBounds(text, at);
384+ return {
385+ state: clampNormal({
386+ ...state,
387+ mode: 'normal',
388+ buffer: buf.setCursorOffset(state.buffer, Math.max(start, at - 1)),
389+ }),
390+ effects: NONE,
391+ };
392+ }
393+ if (key.return) return { state: { ...state, buffer: buf.newline(state.buffer) }, effects: NONE };
394+ if (key.backspace) return { state: { ...state, buffer: buf.backspace(state.buffer) }, effects: NONE };
395+ if (key.tab) return { state: { ...state, buffer: buf.insert(state.buffer, ' ') }, effects: NONE };
396+ if (key.leftArrow) return { state: { ...state, buffer: buf.moveLeft(state.buffer) }, effects: NONE };
397+ if (key.rightArrow) return { state: { ...state, buffer: buf.moveRight(state.buffer) }, effects: NONE };
398+ if (key.upArrow) return { state: { ...state, buffer: buf.moveUp(state.buffer) }, effects: NONE };
399+ if (key.downArrow) return { state: { ...state, buffer: buf.moveDown(state.buffer) }, effects: NONE };
400+
401+ if (key.input && !key.ctrl && !/[\u0000-\u001f]/.test(key.input)) {
402+ return { state: { ...state, buffer: buf.insert(state.buffer, key.input) }, effects: NONE };
403+ }
404+ return { state, effects: NONE };
405+}
406+
407+/**
408+ * Interpret the accumulated `pending` string as a normal- or visual-mode
409+ * command.
410+ *
411+ * The grammar is `[count] [operator] [count] (motion | doubled-operator)`, or a
412+ * standalone command. Returning `null` means "incomplete — keep accumulating",
413+ * which is how `d`, `2`, and `d2` all wait for more input.
414+ */
415+function runPending(state: VimState): VimResult | null {
416+ const seq = state.pending;
417+ // Counts never start with `0` — that digit is the "column zero" motion. A
418+ // `\d*` here would swallow it and leave the command waiting forever for a
419+ // motion that already arrived.
420+ const match = /^([1-9]\d*)?([dcy]?)([1-9]\d*)?([\s\S]*)$/.exec(seq);
421+ if (!match) return { state: { ...state, pending: '' }, effects: NONE };
422+
423+ const [, count1 = '', op = '', count2 = '', rest = ''] = match;
424+ if (rest === '') return null; // waiting on a motion or command
425+
426+ const explicitCount = count1 !== '' || count2 !== '';
427+ const count = Math.max(1, (Number.parseInt(count1 || '1', 10)) * (Number.parseInt(count2 || '1', 10)));
428+ const visual = state.mode === 'visual' || state.mode === 'visual-line';
429+
430+ // `gg` is the only two-key motion here; `g` alone is incomplete.
431+ if (rest === 'g') return null;
432+ if (rest === 'gg') {
433+ const row = explicitCount ? Math.min(count - 1, state.buffer.lines.length - 1) : 0;
434+ const target = buf.positionToOffset(state.buffer.lines, { row, col: 0 });
435+ if (op) return { state: { ...applyOperator(state, op as 'd' | 'c' | 'y', buf.cursorOffset(state.buffer), target, 'linewise'), pending: '' }, effects: NONE };
436+ return { state: clampNormal({ ...state, buffer: buf.setCursorOffset(state.buffer, target), pending: '' }), effects: NONE };
437+ }
438+
439+ // A doubled operator (dd, cc, yy) acts on `count` whole lines.
440+ if (op && rest === op) {
441+ const from = buf.cursorOffset(state.buffer);
442+ const to = verticalMove(state, count - 1);
443+ return { state: { ...applyOperator(state, op as 'd' | 'c' | 'y', from, to, 'linewise'), pending: '' }, effects: NONE };
444+ }
445+
446+ // `cw` is vim's one deliberate inconsistency: on a non-blank it behaves like
447+ // `ce`, changing the word without swallowing the space after it. Every other
448+ // operator uses `w` as written. Reproducing the quirk matters more than being
449+ // principled about it — `cw` is how you rename things, and one that ate the
450+ // following space would be wrong every single time.
451+ const motion = (op === 'c' && (rest === 'w' || rest === 'W')
452+ && classOf(buf.toText(state.buffer)[buf.cursorOffset(state.buffer)]) !== 'space')
453+ ? resolveMotion(state, rest === 'w' ? 'e' : 'E', count, explicitCount)
454+ : resolveMotion(state, rest, count, explicitCount);
455+
456+ if (motion) {
457+ if (op) {
458+ const next = applyOperator(state, op as 'd' | 'c' | 'y', buf.cursorOffset(state.buffer), motion.offset, motion.kind);
459+ return { state: clampNormal({ ...next, pending: '' }), effects: NONE };
460+ }
461+ return {
462+ state: clampNormal({ ...state, buffer: buf.setCursorOffset(state.buffer, motion.offset), pending: '' }),
463+ effects: NONE,
464+ };
465+ }
466+
467+ // Past this point nothing takes an operator prefix, so `dZ` is simply dropped
468+ // rather than doing something surprising.
469+ if (op) return { state: { ...state, pending: '' }, effects: NONE };
470+
471+ const done = (next: VimState, effects: VimEffects = NONE): VimResult =>
472+ ({ state: clampNormal({ ...next, pending: '' }), effects });
473+
474+ switch (rest) {
475+ case 'i': return done({ ...state, mode: 'insert' });
476+ case 'a': return done({ ...state, mode: 'insert', buffer: buf.moveRight(state.buffer) });
477+ case 'I': return done({ ...state, mode: 'insert', buffer: buf.setCursorOffset(state.buffer, firstNonBlank(buf.toText(state.buffer), buf.cursorOffset(state.buffer))) });
478+ case 'A': return done({ ...state, mode: 'insert', buffer: buf.moveLineEnd(state.buffer) });
479+ case 'o': return { state: { ...openLine(state, true), pending: '' }, effects: NONE };
480+ case 'O': return { state: { ...openLine(state, false), pending: '' }, effects: NONE };
481+
482+ case 'x': {
483+ const at = buf.cursorOffset(state.buffer);
484+ return done(applyOperator(state, 'd', at, Math.min(at + count - 1, buf.toText(state.buffer).length), 'inclusive'));
485+ }
486+ case 'D': case 'C': {
487+ const at = buf.cursorOffset(state.buffer);
488+ const { end } = lineBounds(buf.toText(state.buffer), at);
489+ const next = applyOperator(state, rest === 'D' ? 'd' : 'c', at, end, 'exclusive');
490+ return done(next);
491+ }
492+ case 'S': {
493+ const from = buf.cursorOffset(state.buffer);
494+ return done(applyOperator(state, 'c', from, verticalMove(state, count - 1), 'linewise'));
495+ }
496+
497+ case 'p': return done(paste(state, false));
498+ case 'P': return done(paste(state, true));
499+ case 'u': return done(undo(state));
500+
501+ case 'v': return done({ ...state, mode: visual ? 'normal' : 'visual', anchor: visual ? null : buf.cursorOffset(state.buffer) });
502+ case 'V': return done({ ...state, mode: visual ? 'normal' : 'visual-line', anchor: visual ? null : buf.cursorOffset(state.buffer) });
503+
504+ default:
505+ return done(state);
506+ }
507+}
508+
509+/** Apply a visual-mode operator to the current selection. */
510+function visualOperator(state: VimState, op: 'd' | 'c' | 'y'): VimResult {
511+ const anchor = state.anchor ?? buf.cursorOffset(state.buffer);
512+ const kind: MotionKind = state.mode === 'visual-line' ? 'linewise' : 'inclusive';
513+ const next = applyOperator(state, op, anchor, buf.cursorOffset(state.buffer), kind);
514+ return { state: clampNormal({ ...next, pending: '', mode: op === 'c' ? 'insert' : 'normal', anchor: null }), effects: NONE };
515+}
516+
517+/**
518+ * The reducer. One keystroke in, new state plus anything the caller must act on.
519+ */
520+export function handleKey(state: VimState, key: Key): VimResult {
521+ // Transport controls work identically in every mode — you evaluate while
522+ // typing at least as often as you evaluate from normal mode, and having to
523+ // leave insert mode first would be the single most annoying thing here.
524+ if (key.ctrl && key.input === 'e') return { state, effects: { evaluate: true } };
525+ if (key.ctrl && key.input === 'o') return { state, effects: { hush: true } };
526+ if (key.ctrl && key.input === 'r') return { state: redo(state), effects: NONE };
527+
528+ if (state.mode === 'insert') return insertModeKey(state, key);
529+
530+ // Enter evaluates, outside insert mode. Ctrl+Enter would be the obvious key
531+ // for it, but a terminal sends the same byte for both unless it speaks the
532+ // Kitty keyboard protocol, so there is nothing to tell them apart. Plain
533+ // Enter is free instead: vim's normal-mode `<cr>` moves to the next line's
534+ // first non-blank, which `j` and `^` already cover, and re-evaluating is the
535+ // thing you do most in a live-coding buffer.
536+ if (key.return) return { state: { ...state, pending: '' }, effects: { evaluate: true } };
537+
538+ if (key.escape) {
539+ return { state: clampNormal({ ...state, mode: 'normal', pending: '', anchor: null }), effects: NONE };
540+ }
541+
542+ const visual = state.mode === 'visual' || state.mode === 'visual-line';
543+ if (visual && state.pending === '' && (key.input === 'd' || key.input === 'c' || key.input === 'y' || key.input === 'x')) {
544+ return visualOperator(state, key.input === 'x' ? 'd' : key.input);
545+ }
546+
547+ // Arrow keys are accepted in normal mode too — muscle memory from the rest of
548+ // the terminal shouldn't be punished.
549+ if (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow) {
550+ const move = key.leftArrow ? buf.moveLeft
551+ : key.rightArrow ? buf.moveRight
552+ : key.upArrow ? buf.moveUp : buf.moveDown;
553+ return { state: clampNormal({ ...state, buffer: move(state.buffer) }), effects: NONE };
554+ }
555+
556+ if (!key.input || key.ctrl) return { state, effects: NONE };
557+
558+ const pending = state.pending + key.input;
559+ const next = { ...state, pending };
560+
561+ // Digits are a count — except a `0` typed with nothing pending, which is the
562+ // "go to column zero" motion. `10j` is ten lines down; `0` alone is not the
563+ // start of a count that never arrives.
564+ const bareZero = key.input === '0' && state.pending === '';
565+ if (/^\d+$/.test(pending) && !bareZero) {
566+ return { state: next, effects: NONE }; // still accumulating a count
567+ }
568+
569+ return runPending(next) ?? { state: next, effects: NONE };
570+}
571+
572+/**
573+ * Put the cursor at a row/column — how a mouse click enters the editing state.
574+ *
575+ * Goes through the same normal-mode clamp as every motion, so a click past the
576+ * end of a line lands on its last character rather than in the void beyond it.
577+ */
578+export function moveCursorTo(state: VimState, row: number, col: number): VimState {
579+ const lines = state.buffer.lines;
580+ const r = Math.max(0, Math.min(row, lines.length - 1));
581+ const c = Math.max(0, Math.min(col, (lines[r] ?? '').length));
582+ return clampNormal({
583+ ...state,
584+ buffer: { ...state.buffer, row: r, col: c, desiredCol: c },
585+ pending: '',
586+ });
587+}
588+
589+/** The current visual selection as an offset range, or null outside visual mode. */
590+export function selectionRange(state: VimState): { start: number; end: number } | null {
591+ if (state.anchor === null) return null;
592+ const at = buf.cursorOffset(state.buffer);
593+ const text = buf.toText(state.buffer);
594+ if (state.mode === 'visual-line') return expandLinewise(text, state.anchor, at);
595+ if (state.mode !== 'visual') return null;
596+ return { start: Math.min(state.anchor, at), end: Math.min(Math.max(state.anchor, at) + 1, text.length) };
597+}
addedpackage-lock.json+4556 −0
1+{
2+ "name": "strudel-claude",
3+ "version": "1.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "strudel-claude",
9+ "version": "1.0.0",
10+ "license": "ISC",
11+ "dependencies": {
12+ "@anthropic-ai/sdk": "^0.115.0",
13+ "@codemirror/lang-javascript": "^6.2.5",
14+ "@codemirror/state": "^6.7.1",
15+ "@codemirror/theme-one-dark": "^6.1.3",
16+ "@codemirror/view": "^6.43.6",
17+ "@strudel/repl": "^1.3.0",
18+ "@strudel/web": "^1.3.0",
19+ "codemirror": "^6.0.2",
20+ "express": "^5.2.1",
21+ "htm": "^3.1.1",
22+ "ink": "^7.1.1",
23+ "node-web-audio-api": "^2.2.0",
24+ "react": "^19.2.8",
25+ "ws": "^8.21.2"
26+ },
27+ "devDependencies": {
28+ "@types/node": "^26.2.0",
29+ "@types/react": "^19.2.18",
30+ "concurrently": "^10.0.4",
31+ "typescript": "^7.0.2",
32+ "vite": "^8.1.5"
33+ }
34+ },
35+ "node_modules/@alcalzone/ansi-tokenize": {
36+ "version": "0.3.0",
37+ "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz",
38+ "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==",
39+ "license": "MIT",
40+ "dependencies": {
41+ "ansi-styles": "^6.2.1",
42+ "is-fullwidth-code-point": "^5.0.0"
43+ },
44+ "engines": {
45+ "node": ">=18"
46+ }
47+ },
48+ "node_modules/@anthropic-ai/sdk": {
49+ "version": "0.115.0",
50+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
51+ "integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
52+ "license": "MIT",
53+ "dependencies": {
54+ "json-schema-to-ts": "^3.1.1",
55+ "standardwebhooks": "^1.0.0"
56+ },
57+ "bin": {
58+ "anthropic-ai-sdk": "bin/cli"
59+ },
60+ "peerDependencies": {
61+ "zod": "^3.25.0 || ^4.0.0"
62+ },
63+ "peerDependenciesMeta": {
64+ "zod": {
65+ "optional": true
66+ }
67+ }
68+ },
69+ "node_modules/@babel/runtime": {
70+ "version": "7.29.7",
71+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
72+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
73+ "license": "MIT",
74+ "engines": {
75+ "node": ">=6.9.0"
76+ }
77+ },
78+ "node_modules/@codemirror/autocomplete": {
79+ "version": "6.20.3",
80+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
81+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
82+ "license": "MIT",
83+ "dependencies": {
84+ "@codemirror/language": "^6.0.0",
85+ "@codemirror/state": "^6.0.0",
86+ "@codemirror/view": "^6.17.0",
87+ "@lezer/common": "^1.0.0"
88+ }
89+ },
90+ "node_modules/@codemirror/commands": {
91+ "version": "6.10.4",
92+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
93+ "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
94+ "license": "MIT",
95+ "dependencies": {
96+ "@codemirror/language": "^6.0.0",
97+ "@codemirror/state": "^6.7.0",
98+ "@codemirror/view": "^6.27.0",
99+ "@lezer/common": "^1.1.0"
100+ }
101+ },
102+ "node_modules/@codemirror/lang-javascript": {
103+ "version": "6.2.5",
104+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
105+ "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
106+ "license": "MIT",
107+ "dependencies": {
108+ "@codemirror/autocomplete": "^6.0.0",
109+ "@codemirror/language": "^6.6.0",
110+ "@codemirror/lint": "^6.0.0",
111+ "@codemirror/state": "^6.0.0",
112+ "@codemirror/view": "^6.17.0",
113+ "@lezer/common": "^1.0.0",
114+ "@lezer/javascript": "^1.0.0"
115+ }
116+ },
117+ "node_modules/@codemirror/language": {
118+ "version": "6.12.4",
119+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
120+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
121+ "license": "MIT",
122+ "dependencies": {
123+ "@codemirror/state": "^6.0.0",
124+ "@codemirror/view": "^6.23.0",
125+ "@lezer/common": "^1.5.0",
126+ "@lezer/highlight": "^1.0.0",
127+ "@lezer/lr": "^1.0.0",
128+ "style-mod": "^4.0.0"
129+ }
130+ },
131+ "node_modules/@codemirror/lint": {
132+ "version": "6.9.7",
133+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
134+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
135+ "license": "MIT",
136+ "dependencies": {
137+ "@codemirror/state": "^6.0.0",
138+ "@codemirror/view": "^6.42.0",
139+ "crelt": "^1.0.5"
140+ }
141+ },
142+ "node_modules/@codemirror/search": {
143+ "version": "6.7.1",
144+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
145+ "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
146+ "license": "MIT",
147+ "dependencies": {
148+ "@codemirror/state": "^6.0.0",
149+ "@codemirror/view": "^6.37.0",
150+ "crelt": "^1.0.5"
151+ }
152+ },
153+ "node_modules/@codemirror/state": {
154+ "version": "6.7.1",
155+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
156+ "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
157+ "license": "MIT",
158+ "dependencies": {
159+ "@marijn/find-cluster-break": "^1.0.0"
160+ }
161+ },
162+ "node_modules/@codemirror/theme-one-dark": {
163+ "version": "6.1.3",
164+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
165+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
166+ "license": "MIT",
167+ "dependencies": {
168+ "@codemirror/language": "^6.0.0",
169+ "@codemirror/state": "^6.0.0",
170+ "@codemirror/view": "^6.0.0",
171+ "@lezer/highlight": "^1.0.0"
172+ }
173+ },
174+ "node_modules/@codemirror/view": {
175+ "version": "6.43.6",
176+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
177+ "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
178+ "license": "MIT",
179+ "dependencies": {
180+ "@codemirror/state": "^6.7.0",
181+ "crelt": "^1.0.6",
182+ "style-mod": "^4.1.0",
183+ "w3c-keyname": "^2.2.4"
184+ }
185+ },
186+ "node_modules/@emnapi/core": {
187+ "version": "1.11.1",
188+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
189+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
190+ "dev": true,
191+ "license": "MIT",
192+ "optional": true,
193+ "dependencies": {
194+ "@emnapi/wasi-threads": "1.2.2",
195+ "tslib": "^2.4.0"
196+ }
197+ },
198+ "node_modules/@emnapi/runtime": {
199+ "version": "1.11.1",
200+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
201+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
202+ "dev": true,
203+ "license": "MIT",
204+ "optional": true,
205+ "dependencies": {
206+ "tslib": "^2.4.0"
207+ }
208+ },
209+ "node_modules/@emnapi/wasi-threads": {
210+ "version": "1.2.2",
211+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
212+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
213+ "dev": true,
214+ "license": "MIT",
215+ "optional": true,
216+ "dependencies": {
217+ "tslib": "^2.4.0"
218+ }
219+ },
220+ "node_modules/@kabelsalat/core": {
221+ "version": "0.4.0",
222+ "resolved": "https://registry.npmjs.org/@kabelsalat/core/-/core-0.4.0.tgz",
223+ "integrity": "sha512-5zV8nh8HffW8aexObXs5pE0xgL0jb1cHc3o8UN6AvK0mBt87fjZvW65E4rMslHyq+OuCscoBJW7B3MbYfWrwMQ==",
224+ "license": "AGPL-3.0-or-later"
225+ },
226+ "node_modules/@kabelsalat/lib": {
227+ "version": "0.4.1",
228+ "resolved": "https://registry.npmjs.org/@kabelsalat/lib/-/lib-0.4.1.tgz",
229+ "integrity": "sha512-gBCjrZKD9huTKNJuBC6BXM4PMQSg8otL8A/vp8j98P9v6yWTX1TuyaqdLg/1PrIAMV4hlWoav9ZD3YMFlrvouw==",
230+ "license": "AGPL-3.0-or-later",
231+ "dependencies": {
232+ "@kabelsalat/core": "0.4.0"
233+ }
234+ },
235+ "node_modules/@kabelsalat/web": {
236+ "version": "0.4.1",
237+ "resolved": "https://registry.npmjs.org/@kabelsalat/web/-/web-0.4.1.tgz",
238+ "integrity": "sha512-ASkFhePJLx3GjadYOueI7sHKXBbRPk/a1pfslFeQNI9gU7yZq/KrnkmOmLrgrtixAsy7gyi1vWqkmRRvH3Ki6w==",
239+ "license": "AGPL-3.0-or-later",
240+ "dependencies": {
241+ "@kabelsalat/core": "0.4.0",
242+ "@kabelsalat/lib": "0.4.1"
243+ }
244+ },
245+ "node_modules/@lezer/common": {
246+ "version": "1.5.2",
247+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
248+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
249+ "license": "MIT"
250+ },
251+ "node_modules/@lezer/highlight": {
252+ "version": "1.2.3",
253+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
254+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
255+ "license": "MIT",
256+ "dependencies": {
257+ "@lezer/common": "^1.3.0"
258+ }
259+ },
260+ "node_modules/@lezer/javascript": {
261+ "version": "1.5.4",
262+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
263+ "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
264+ "license": "MIT",
265+ "dependencies": {
266+ "@lezer/common": "^1.2.0",
267+ "@lezer/highlight": "^1.1.3",
268+ "@lezer/lr": "^1.3.0"
269+ }
270+ },
271+ "node_modules/@lezer/lr": {
272+ "version": "1.4.10",
273+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
274+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
275+ "license": "MIT",
276+ "dependencies": {
277+ "@lezer/common": "^1.0.0"
278+ }
279+ },
280+ "node_modules/@marijn/find-cluster-break": {
281+ "version": "1.0.3",
282+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
283+ "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
284+ "license": "MIT"
285+ },
286+ "node_modules/@nanostores/persistent": {
287+ "version": "0.10.2",
288+ "resolved": "https://registry.npmjs.org/@nanostores/persistent/-/persistent-0.10.2.tgz",
289+ "integrity": "sha512-BEndnLhRC+yP7gXTESepBbSj8XNl8OXK9hu4xAgKC7MWJHKXnEqJMqY47LUyHxK6vYgFnisyHmqq+vq8AUFyIg==",
290+ "funding": [
291+ {
292+ "type": "github",
293+ "url": "https://github.com/sponsors/ai"
294+ }
295+ ],
296+ "license": "MIT",
297+ "engines": {
298+ "node": "^18.0.0 || >=20.0.0"
299+ },
300+ "peerDependencies": {
301+ "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0"
302+ }
303+ },
304+ "node_modules/@napi-rs/wasm-runtime": {
305+ "version": "1.1.6",
306+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
307+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
308+ "dev": true,
309+ "license": "MIT",
310+ "optional": true,
311+ "dependencies": {
312+ "@tybys/wasm-util": "^0.10.3"
313+ },
314+ "funding": {
315+ "type": "github",
316+ "url": "https://github.com/sponsors/Brooooooklyn"
317+ },
318+ "peerDependencies": {
319+ "@emnapi/core": "^1.7.1",
320+ "@emnapi/runtime": "^1.7.1"
321+ }
322+ },
323+ "node_modules/@oxc-project/types": {
324+ "version": "0.139.0",
325+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
326+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
327+ "dev": true,
328+ "license": "MIT",
329+ "funding": {
330+ "url": "https://github.com/sponsors/Boshen"
331+ }
332+ },
333+ "node_modules/@replit/codemirror-emacs": {
334+ "version": "6.1.0",
335+ "resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz",
336+ "integrity": "sha512-74DITnht6Cs6sHg02PQ169IKb1XgtyhI9sLD0JeOFco6Ds18PT+dkD8+DgXBDokne9UIFKsBbKPnpFRAz60/Lw==",
337+ "license": "MIT",
338+ "peerDependencies": {
339+ "@codemirror/autocomplete": "^6.0.2",
340+ "@codemirror/commands": "^6.0.0",
341+ "@codemirror/search": "^6.0.0",
342+ "@codemirror/state": "^6.0.1",
343+ "@codemirror/view": "^6.3.0"
344+ }
345+ },
346+ "node_modules/@replit/codemirror-vim": {
347+ "version": "6.3.0",
348+ "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
349+ "integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
350+ "license": "MIT",
351+ "peerDependencies": {
352+ "@codemirror/commands": "6.x.x",
353+ "@codemirror/language": "6.x.x",
354+ "@codemirror/search": "6.x.x",
355+ "@codemirror/state": "6.x.x",
356+ "@codemirror/view": "6.x.x"
357+ }
358+ },
359+ "node_modules/@replit/codemirror-vscode-keymap": {
360+ "version": "6.0.2",
361+ "resolved": "https://registry.npmjs.org/@replit/codemirror-vscode-keymap/-/codemirror-vscode-keymap-6.0.2.tgz",
362+ "integrity": "sha512-j45qTwGxzpsv82lMD/NreGDORFKSctMDVkGRopaP+OrzSzv+pXDQuU3LnFvKpasyjVT0lf+PKG1v2DSCn/vxxg==",
363+ "license": "MIT",
364+ "peerDependencies": {
365+ "@codemirror/autocomplete": "^6.0.0",
366+ "@codemirror/commands": "^6.0.0",
367+ "@codemirror/language": "^6.0.0",
368+ "@codemirror/lint": "^6.0.0",
369+ "@codemirror/search": "^6.0.0",
370+ "@codemirror/state": "^6.0.0",
371+ "@codemirror/view": "^6.0.0"
372+ }
373+ },
374+ "node_modules/@rolldown/binding-android-arm64": {
375+ "version": "1.1.5",
376+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
377+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
378+ "cpu": [
379+ "arm64"
380+ ],
381+ "dev": true,
382+ "license": "MIT",
383+ "optional": true,
384+ "os": [
385+ "android"
386+ ],
387+ "engines": {
388+ "node": "^20.19.0 || >=22.12.0"
389+ }
390+ },
391+ "node_modules/@rolldown/binding-darwin-arm64": {
392+ "version": "1.1.5",
393+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
394+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
395+ "cpu": [
396+ "arm64"
397+ ],
398+ "dev": true,
399+ "license": "MIT",
400+ "optional": true,
401+ "os": [
402+ "darwin"
403+ ],
404+ "engines": {
405+ "node": "^20.19.0 || >=22.12.0"
406+ }
407+ },
408+ "node_modules/@rolldown/binding-darwin-x64": {
409+ "version": "1.1.5",
410+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
411+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
412+ "cpu": [
413+ "x64"
414+ ],
415+ "dev": true,
416+ "license": "MIT",
417+ "optional": true,
418+ "os": [
419+ "darwin"
420+ ],
421+ "engines": {
422+ "node": "^20.19.0 || >=22.12.0"
423+ }
424+ },
425+ "node_modules/@rolldown/binding-freebsd-x64": {
426+ "version": "1.1.5",
427+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
428+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
429+ "cpu": [
430+ "x64"
431+ ],
432+ "dev": true,
433+ "license": "MIT",
434+ "optional": true,
435+ "os": [
436+ "freebsd"
437+ ],
438+ "engines": {
439+ "node": "^20.19.0 || >=22.12.0"
440+ }
441+ },
442+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
443+ "version": "1.1.5",
444+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
445+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
446+ "cpu": [
447+ "arm"
448+ ],
449+ "dev": true,
450+ "license": "MIT",
451+ "optional": true,
452+ "os": [
453+ "linux"
454+ ],
455+ "engines": {
456+ "node": "^20.19.0 || >=22.12.0"
457+ }
458+ },
459+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
460+ "version": "1.1.5",
461+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
462+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
463+ "cpu": [
464+ "arm64"
465+ ],
466+ "dev": true,
467+ "license": "MIT",
468+ "optional": true,
469+ "os": [
470+ "linux"
471+ ],
472+ "engines": {
473+ "node": "^20.19.0 || >=22.12.0"
474+ }
475+ },
476+ "node_modules/@rolldown/binding-linux-arm64-musl": {
477+ "version": "1.1.5",
478+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
479+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
480+ "cpu": [
481+ "arm64"
482+ ],
483+ "dev": true,
484+ "license": "MIT",
485+ "optional": true,
486+ "os": [
487+ "linux"
488+ ],
489+ "engines": {
490+ "node": "^20.19.0 || >=22.12.0"
491+ }
492+ },
493+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
494+ "version": "1.1.5",
495+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
496+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
497+ "cpu": [
498+ "ppc64"
499+ ],
500+ "dev": true,
501+ "license": "MIT",
502+ "optional": true,
503+ "os": [
504+ "linux"
505+ ],
506+ "engines": {
507+ "node": "^20.19.0 || >=22.12.0"
508+ }
509+ },
510+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
511+ "version": "1.1.5",
512+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
513+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
514+ "cpu": [
515+ "s390x"
516+ ],
517+ "dev": true,
518+ "license": "MIT",
519+ "optional": true,
520+ "os": [
521+ "linux"
522+ ],
523+ "engines": {
524+ "node": "^20.19.0 || >=22.12.0"
525+ }
526+ },
527+ "node_modules/@rolldown/binding-linux-x64-gnu": {
528+ "version": "1.1.5",
529+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
530+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
531+ "cpu": [
532+ "x64"
533+ ],
534+ "dev": true,
535+ "license": "MIT",
536+ "optional": true,
537+ "os": [
538+ "linux"
539+ ],
540+ "engines": {
541+ "node": "^20.19.0 || >=22.12.0"
542+ }
543+ },
544+ "node_modules/@rolldown/binding-linux-x64-musl": {
545+ "version": "1.1.5",
546+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
547+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
548+ "cpu": [
549+ "x64"
550+ ],
551+ "dev": true,
552+ "license": "MIT",
553+ "optional": true,
554+ "os": [
555+ "linux"
556+ ],
557+ "engines": {
558+ "node": "^20.19.0 || >=22.12.0"
559+ }
560+ },
561+ "node_modules/@rolldown/binding-openharmony-arm64": {
562+ "version": "1.1.5",
563+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
564+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
565+ "cpu": [
566+ "arm64"
567+ ],
568+ "dev": true,
569+ "license": "MIT",
570+ "optional": true,
571+ "os": [
572+ "openharmony"
573+ ],
574+ "engines": {
575+ "node": "^20.19.0 || >=22.12.0"
576+ }
577+ },
578+ "node_modules/@rolldown/binding-wasm32-wasi": {
579+ "version": "1.1.5",
580+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
581+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
582+ "cpu": [
583+ "wasm32"
584+ ],
585+ "dev": true,
586+ "license": "MIT",
587+ "optional": true,
588+ "dependencies": {
589+ "@emnapi/core": "1.11.1",
590+ "@emnapi/runtime": "1.11.1",
591+ "@napi-rs/wasm-runtime": "^1.1.6"
592+ },
593+ "engines": {
594+ "node": "^20.19.0 || >=22.12.0"
595+ }
596+ },
597+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
598+ "version": "1.1.5",
599+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
600+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
601+ "cpu": [
602+ "arm64"
603+ ],
604+ "dev": true,
605+ "license": "MIT",
606+ "optional": true,
607+ "os": [
608+ "win32"
609+ ],
610+ "engines": {
611+ "node": "^20.19.0 || >=22.12.0"
612+ }
613+ },
614+ "node_modules/@rolldown/binding-win32-x64-msvc": {
615+ "version": "1.1.5",
616+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
617+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
618+ "cpu": [
619+ "x64"
620+ ],
621+ "dev": true,
622+ "license": "MIT",
623+ "optional": true,
624+ "os": [
625+ "win32"
626+ ],
627+ "engines": {
628+ "node": "^20.19.0 || >=22.12.0"
629+ }
630+ },
631+ "node_modules/@rolldown/pluginutils": {
632+ "version": "1.0.1",
633+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
634+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
635+ "dev": true,
636+ "license": "MIT"
637+ },
638+ "node_modules/@rollup/plugin-node-resolve": {
639+ "version": "15.3.1",
640+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
641+ "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
642+ "license": "MIT",
643+ "dependencies": {
644+ "@rollup/pluginutils": "^5.0.1",
645+ "@types/resolve": "1.20.2",
646+ "deepmerge": "^4.2.2",
647+ "is-module": "^1.0.0",
648+ "resolve": "^1.22.1"
649+ },
650+ "engines": {
651+ "node": ">=14.0.0"
652+ },
653+ "peerDependencies": {
654+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
655+ },
656+ "peerDependenciesMeta": {
657+ "rollup": {
658+ "optional": true
659+ }
660+ }
661+ },
662+ "node_modules/@rollup/pluginutils": {
663+ "version": "5.4.0",
664+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
665+ "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
666+ "license": "MIT",
667+ "dependencies": {
668+ "@types/estree": "^1.0.0",
669+ "estree-walker": "^2.0.2",
670+ "picomatch": "^4.0.2"
671+ },
672+ "engines": {
673+ "node": ">=14.0.0"
674+ },
675+ "peerDependencies": {
676+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
677+ },
678+ "peerDependenciesMeta": {
679+ "rollup": {
680+ "optional": true
681+ }
682+ }
683+ },
684+ "node_modules/@rollup/pluginutils/node_modules/estree-walker": {
685+ "version": "2.0.2",
686+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
687+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
688+ "license": "MIT"
689+ },
690+ "node_modules/@stablelib/base64": {
691+ "version": "1.0.1",
692+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
693+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
694+ "license": "MIT"
695+ },
696+ "node_modules/@strudel/codemirror": {
697+ "version": "1.3.0",
698+ "resolved": "https://registry.npmjs.org/@strudel/codemirror/-/codemirror-1.3.0.tgz",
699+ "integrity": "sha512-Bv6TkmaCtyjbPY/G9r214vQq4VyAp4ZQuTQS60zjlp2YXbYJX4H7piM+roOEqczR4lqE4yYxfdg83jEubY+1zA==",
700+ "license": "AGPL-3.0-or-later",
701+ "dependencies": {
702+ "@codemirror/autocomplete": "^6.18.4",
703+ "@codemirror/commands": "^6.8.0",
704+ "@codemirror/lang-javascript": "^6.2.2",
705+ "@codemirror/language": "^6.10.8",
706+ "@codemirror/search": "^6.5.8",
707+ "@codemirror/state": "^6.5.1",
708+ "@codemirror/view": "^6.36.2",
709+ "@lezer/highlight": "^1.2.1",
710+ "@nanostores/persistent": "^0.10.2",
711+ "@replit/codemirror-emacs": "^6.1.0",
712+ "@replit/codemirror-vim": "^6.3.0",
713+ "@replit/codemirror-vscode-keymap": "^6.0.2",
714+ "@strudel/core": "1.2.6",
715+ "@strudel/draw": "1.2.6",
716+ "@strudel/tonal": "1.2.6",
717+ "@strudel/transpiler": "1.2.6",
718+ "@tonaljs/tonal": "^4.10.0",
719+ "nanostores": "^0.11.3",
720+ "superdough": "1.3.0"
721+ },
722+ "engines": {
723+ "node": ">=18.0.0"
724+ }
725+ },
726+ "node_modules/@strudel/core": {
727+ "version": "1.2.6",
728+ "resolved": "https://registry.npmjs.org/@strudel/core/-/core-1.2.6.tgz",
729+ "integrity": "sha512-FA5Kxv3U+UhNRLIeuEpXck2o2J5AjHZbkAdSAK9Q2N3Az1G9Vnc+bXXfgDFyY3yRH9mXXQvTerz8jr876pGGng==",
730+ "license": "AGPL-3.0-or-later",
731+ "dependencies": {
732+ "@kabelsalat/web": "^0.4.1",
733+ "fraction.js": "^5.2.1"
734+ },
735+ "engines": {
736+ "node": ">=18.0.0"
737+ }
738+ },
739+ "node_modules/@strudel/draw": {
740+ "version": "1.2.6",
741+ "resolved": "https://registry.npmjs.org/@strudel/draw/-/draw-1.2.6.tgz",
742+ "integrity": "sha512-7UPJ4xgDi2bt12KWSVAEt4rZjcOajxhHBJikvNA3LQT5IA/FTfD+TRSNu0CSB7zhmFM0Njgq6t7ttgsC8fdKCg==",
743+ "license": "AGPL-3.0-or-later",
744+ "dependencies": {
745+ "@strudel/core": "1.2.6"
746+ },
747+ "engines": {
748+ "node": ">=18.0.0"
749+ }
750+ },
751+ "node_modules/@strudel/hydra": {
752+ "version": "1.2.6",
753+ "resolved": "https://registry.npmjs.org/@strudel/hydra/-/hydra-1.2.6.tgz",
754+ "integrity": "sha512-8eBNHLffyj7G5b5L5bXP9ZjUzYll+SdRVZleIauOt5OOD4/0ezzqiEnqt4jQG/qlbQw9Jcwvzqvm0JWMEGVSuQ==",
755+ "license": "AGPL-3.0-or-later",
756+ "dependencies": {
757+ "@strudel/core": "1.2.6",
758+ "@strudel/draw": "1.2.6",
759+ "hydra-synth": "^1.3.29"
760+ },
761+ "engines": {
762+ "node": ">=18.0.0"
763+ }
764+ },
765+ "node_modules/@strudel/midi": {
766+ "version": "1.3.0",
767+ "resolved": "https://registry.npmjs.org/@strudel/midi/-/midi-1.3.0.tgz",
768+ "integrity": "sha512-VblTT+g4G8uDYzVJamn7nWnrPWySYSb8sSdRD9KYXl87QTrFExZOFTePhzAo9IXUFsnYFQhD11B5QYANWF0ygw==",
769+ "license": "AGPL-3.0-or-later",
770+ "dependencies": {
771+ "@strudel/core": "1.2.6",
772+ "@strudel/webaudio": "1.3.0",
773+ "webmidi": "^3.1.12"
774+ },
775+ "engines": {
776+ "node": ">=18.0.0"
777+ }
778+ },
779+ "node_modules/@strudel/mini": {
780+ "version": "1.2.6",
781+ "resolved": "https://registry.npmjs.org/@strudel/mini/-/mini-1.2.6.tgz",
782+ "integrity": "sha512-I5GFPLHI3TPcJuD2RdDcurA+lTogaSQ0TB+EimKTgEtSAbU6ZMEkSUOXZIFO5eaAcJcMucgKaBcmTKpD4oZjPA==",
783+ "license": "AGPL-3.0-or-later",
784+ "dependencies": {
785+ "@strudel/core": "1.2.6"
786+ },
787+ "engines": {
788+ "node": ">=18.0.0"
789+ }
790+ },
791+ "node_modules/@strudel/repl": {
792+ "version": "1.3.0",
793+ "resolved": "https://registry.npmjs.org/@strudel/repl/-/repl-1.3.0.tgz",
794+ "integrity": "sha512-haeqq/aIjh6WJrKpyl++d6mP2+8P+YF1wxOrD/8ikFVqRKBmKlo/9Ar6DP/d59YRx2VazkrvHG5CL9d1xIgULQ==",
795+ "license": "AGPL-3.0-or-later",
796+ "dependencies": {
797+ "@strudel/codemirror": "1.3.0",
798+ "@strudel/core": "1.2.6",
799+ "@strudel/draw": "1.2.6",
800+ "@strudel/hydra": "1.2.6",
801+ "@strudel/midi": "1.3.0",
802+ "@strudel/mini": "1.2.6",
803+ "@strudel/soundfonts": "1.3.0",
804+ "@strudel/tonal": "1.2.6",
805+ "@strudel/transpiler": "1.2.6",
806+ "@strudel/webaudio": "1.3.0"
807+ },
808+ "engines": {
809+ "node": ">=18.0.0"
810+ }
811+ },
812+ "node_modules/@strudel/soundfonts": {
813+ "version": "1.3.0",
814+ "resolved": "https://registry.npmjs.org/@strudel/soundfonts/-/soundfonts-1.3.0.tgz",
815+ "integrity": "sha512-gr92e5gC+57KKqRolZYu0Qf7McKfrY9URHCp7UxpMhnd39utKekfBnD8WLnvv5jrYmbBuKvx/IYpaIxo51CgXQ==",
816+ "license": "AGPL-3.0-or-later",
817+ "dependencies": {
818+ "@strudel/core": "1.2.6",
819+ "@strudel/webaudio": "1.3.0",
820+ "sfumato": "^0.1.2",
821+ "soundfont2": "^0.5.0"
822+ },
823+ "engines": {
824+ "node": ">=18.0.0"
825+ }
826+ },
827+ "node_modules/@strudel/tonal": {
828+ "version": "1.2.6",
829+ "resolved": "https://registry.npmjs.org/@strudel/tonal/-/tonal-1.2.6.tgz",
830+ "integrity": "sha512-I2ev0IJFmqvDt/vFkL8vIDXNanK5EZF+MNhlio3nHt1d6ngZZJyCNzC1OxoaiQKxZNN7+mpGIC3Cx/zcBC3weQ==",
831+ "license": "AGPL-3.0-or-later",
832+ "dependencies": {
833+ "@strudel/core": "1.2.6",
834+ "@tonaljs/tonal": "^4.10.0",
835+ "chord-voicings": "^0.0.1",
836+ "webmidi": "^3.1.12"
837+ },
838+ "engines": {
839+ "node": ">=18.0.0"
840+ }
841+ },
842+ "node_modules/@strudel/transpiler": {
843+ "version": "1.2.6",
844+ "resolved": "https://registry.npmjs.org/@strudel/transpiler/-/transpiler-1.2.6.tgz",
845+ "integrity": "sha512-NzmyQDS4BqjafOPzHGFowxILDx3ZRAPD47YszxNaPB7PggMA2rMo9t3rLz80G0GxA0ZAyMbVMR3px9jDWnJ5hg==",
846+ "license": "AGPL-3.0-or-later",
847+ "dependencies": {
848+ "@strudel/core": "1.2.6",
849+ "@strudel/mini": "1.2.6",
850+ "acorn": "^8.14.0",
851+ "escodegen": "^2.1.0",
852+ "estree-walker": "^3.0.3"
853+ },
854+ "engines": {
855+ "node": ">=18.0.0"
856+ }
857+ },
858+ "node_modules/@strudel/web": {
859+ "version": "1.3.0",
860+ "resolved": "https://registry.npmjs.org/@strudel/web/-/web-1.3.0.tgz",
861+ "integrity": "sha512-iob9+OrZMM5oMQmDNQAXc6+CYnzKaOUeARs8fcRRxDDSvCxewmZf/ancwyqVSCJsVzgCgw+I9PkpFyhSpLS3FQ==",
862+ "license": "AGPL-3.0-or-later",
863+ "dependencies": {
864+ "@strudel/core": "1.2.6",
865+ "@strudel/mini": "1.2.6",
866+ "@strudel/tonal": "1.2.6",
867+ "@strudel/transpiler": "1.2.6",
868+ "@strudel/webaudio": "1.3.0"
869+ },
870+ "engines": {
871+ "node": ">=18.0.0"
872+ }
873+ },
874+ "node_modules/@strudel/webaudio": {
875+ "version": "1.3.0",
876+ "resolved": "https://registry.npmjs.org/@strudel/webaudio/-/webaudio-1.3.0.tgz",
877+ "integrity": "sha512-H23KJfxA5l2woG0TwNf+VMvjbj9SwR1y1qgzJW26Z35/9c5pbVs2IHPUpFwP0dWoA/4dmG2cmTMwI1e/TL8kGA==",
878+ "license": "AGPL-3.0-or-later",
879+ "dependencies": {
880+ "@strudel/core": "1.2.6",
881+ "@strudel/draw": "1.2.6",
882+ "superdough": "1.3.0",
883+ "supradough": "1.2.4"
884+ },
885+ "engines": {
886+ "node": ">=18.0.0"
887+ }
888+ },
889+ "node_modules/@tonaljs/abc-notation": {
890+ "version": "4.9.1",
891+ "resolved": "https://registry.npmjs.org/@tonaljs/abc-notation/-/abc-notation-4.9.1.tgz",
892+ "integrity": "sha512-2fDUdPsFDdgZgyIiZCTYGKy30QiwIQxSXCSN2thGrSMXbQKCp8iTC8haStYcrA+25MPWhWlmvC/pz3tGcTNAqQ==",
893+ "license": "MIT",
894+ "dependencies": {
895+ "@tonaljs/pitch-distance": "5.0.5",
896+ "@tonaljs/pitch-note": "6.1.0"
897+ }
898+ },
899+ "node_modules/@tonaljs/array": {
900+ "version": "4.8.4",
901+ "resolved": "https://registry.npmjs.org/@tonaljs/array/-/array-4.8.4.tgz",
902+ "integrity": "sha512-97HVdpZy82PqNBDMM9PRSbO2DUrnxNg3++N4xqLpfby70fKHAHhTWrMXWZK+Dzs76HDPQLd+qhd4cq28eBZzjw==",
903+ "license": "MIT",
904+ "dependencies": {
905+ "@tonaljs/pitch-note": "6.1.0"
906+ }
907+ },
908+ "node_modules/@tonaljs/chord": {
909+ "version": "4.10.2",
910+ "resolved": "https://registry.npmjs.org/@tonaljs/chord/-/chord-4.10.2.tgz",
911+ "integrity": "sha512-Zlqtq6c6T4y+EqvwHRQp9icEjHqyniCpnIwwCFg5amgAQwXmWzarouOOSmcdJ1Is92eEvcPVuCoLiVUtajS30A==",
912+ "license": "MIT",
913+ "dependencies": {
914+ "@tonaljs/chord-detect": "^4.8.1",
915+ "@tonaljs/chord-type": "^4.8.2",
916+ "@tonaljs/collection": "^4.8.0",
917+ "@tonaljs/core": "^4.10.0",
918+ "@tonaljs/pcset": "^4.8.2",
919+ "@tonaljs/scale-type": "^4.8.2"
920+ }
921+ },
922+ "node_modules/@tonaljs/chord-detect": {
923+ "version": "4.9.1",
924+ "resolved": "https://registry.npmjs.org/@tonaljs/chord-detect/-/chord-detect-4.9.1.tgz",
925+ "integrity": "sha512-rV/9+R7aZ9cQorQ3jdNMMMh63onosglYZM71Q0n7KKcWMAGrxF66MzxBG82xy+w1QDMJQslB3iHfDHUiS6wRjA==",
926+ "license": "MIT",
927+ "dependencies": {
928+ "@tonaljs/chord-type": "5.1.1",
929+ "@tonaljs/pcset": "4.10.1",
930+ "@tonaljs/pitch-note": "6.1.0"
931+ }
932+ },
933+ "node_modules/@tonaljs/chord-detect/node_modules/@tonaljs/chord-type": {
934+ "version": "5.1.1",
935+ "resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.1.tgz",
936+ "integrity": "sha512-ti4WzRYvvjH7to0G3zlJFq7WsjHqmcqbk8Jv98aZSR5YumLdY/ua2yOPPyoPq82n6vfgjZsacnAZ3v8/SodOcw==",
937+ "license": "MIT",
938+ "dependencies": {
939+ "@tonaljs/pcset": "4.10.1"
940+ }
941+ },
942+ "node_modules/@tonaljs/chord-type": {
943+ "version": "4.8.2",
944+ "resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-4.8.2.tgz",
945+ "integrity": "sha512-GzSTjQmZjkUdPhyesFDeJbxpW8R7L/bAE34E8ApGAh9FMcKYpRdX3Tn+gkluRsXOiRMfW07p3E0Adx4bjtyN+Q==",
946+ "license": "MIT",
947+ "dependencies": {
948+ "@tonaljs/core": "^4.8.0",
949+ "@tonaljs/pcset": "^4.8.2"
950+ }
951+ },
952+ "node_modules/@tonaljs/collection": {
953+ "version": "4.9.0",
954+ "resolved": "https://registry.npmjs.org/@tonaljs/collection/-/collection-4.9.0.tgz",
955+ "integrity": "sha512-Mk0h7O54nT6PgNVcUYauzxa5KOB23+0AOKudWzRH7JhJIN9vhVIC7PtwZXE+/G051UTbHSFIcN/afkgF4nB/8A==",
956+ "license": "MIT"
957+ },
958+ "node_modules/@tonaljs/core": {
959+ "version": "4.10.4",
960+ "resolved": "https://registry.npmjs.org/@tonaljs/core/-/core-4.10.4.tgz",
961+ "integrity": "sha512-PhGlQ2Js6rFQ6CufkSiBpEJoPS8QIIhbXSXhT4U+5ffqckli+YBZRN24vjZ4AEKuX8xoYDmCCbl+r6agauvzmw==",
962+ "license": "MIT",
963+ "dependencies": {
964+ "@tonaljs/pitch": "5.0.1",
965+ "@tonaljs/pitch-distance": "5.0.2",
966+ "@tonaljs/pitch-interval": "5.0.2",
967+ "@tonaljs/pitch-note": "5.0.3"
968+ }
969+ },
970+ "node_modules/@tonaljs/core/node_modules/@tonaljs/pitch-distance": {
971+ "version": "5.0.2",
972+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-distance/-/pitch-distance-5.0.2.tgz",
973+ "integrity": "sha512-DPxfGJCf4BvfIVRxl2v142tuCKIziM6PomOBT0/s7U5o+Z5qFAIW0tNx/MKyL83guV74p+XIf5VI0w1flmXxDA==",
974+ "license": "MIT",
975+ "dependencies": {
976+ "@tonaljs/pitch": "5.0.1",
977+ "@tonaljs/pitch-interval": "5.0.2",
978+ "@tonaljs/pitch-note": "5.0.3"
979+ }
980+ },
981+ "node_modules/@tonaljs/core/node_modules/@tonaljs/pitch-note": {
982+ "version": "5.0.3",
983+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-5.0.3.tgz",
984+ "integrity": "sha512-JsPgqPa8VZdZtfwvgoHJz996BZqzvlnRxUF6c/Q9q8E0iq30UHBEid0Y6XldK+h6tuIENsG3a9jyvNNxRqIeYw==",
985+ "license": "MIT",
986+ "dependencies": {
987+ "@tonaljs/pitch": "5.0.1"
988+ }
989+ },
990+ "node_modules/@tonaljs/duration-value": {
991+ "version": "4.9.0",
992+ "resolved": "https://registry.npmjs.org/@tonaljs/duration-value/-/duration-value-4.9.0.tgz",
993+ "integrity": "sha512-Muz54HyIe0nMYKWx6wyTa4y17ma29DtpJF4/oqJphy6A124rAVDe/SKit8JGOvDYAQj71FUXqs17sXBxO/ExVw==",
994+ "license": "MIT"
995+ },
996+ "node_modules/@tonaljs/interval": {
997+ "version": "4.8.2",
998+ "resolved": "https://registry.npmjs.org/@tonaljs/interval/-/interval-4.8.2.tgz",
999+ "integrity": "sha512-9SEuJuqFdmu9lnvMmJtxbReQcQvZ1YHXhCcxaF6Re23/w+BqiJvvkvNZhfWH+n1+g0uaSdTsuvMfamp7hAvPMw==",
1000+ "license": "MIT",
1001+ "dependencies": {
1002+ "@tonaljs/pitch": "^5.0.1",
1003+ "@tonaljs/pitch-distance": "^5.0.2",
1004+ "@tonaljs/pitch-interval": "^5.0.2"
1005+ }
1006+ },
1007+ "node_modules/@tonaljs/key": {
1008+ "version": "4.11.2",
1009+ "resolved": "https://registry.npmjs.org/@tonaljs/key/-/key-4.11.2.tgz",
1010+ "integrity": "sha512-fc1y5+NwS4S3amirzYLLB324PxJDO00oIBbLJclAddc46UBeYnu4FNz+1nZboHCXRQ/06/ftuaWD/l7HrAoulw==",
1011+ "license": "MIT",
1012+ "dependencies": {
1013+ "@tonaljs/note": "4.12.1",
1014+ "@tonaljs/pitch-note": "6.1.0",
1015+ "@tonaljs/roman-numeral": "4.9.1"
1016+ }
1017+ },
1018+ "node_modules/@tonaljs/midi": {
1019+ "version": "4.10.2",
1020+ "resolved": "https://registry.npmjs.org/@tonaljs/midi/-/midi-4.10.2.tgz",
1021+ "integrity": "sha512-MPamXhEwPL7L1udLfYMm3Ft8mLYtHr62Zi2w5zYHM2P7YwIvNoiX0+dvAN5is1Wvq4iVRa8AjFHerjOW/SZhGg==",
1022+ "license": "MIT",
1023+ "dependencies": {
1024+ "@tonaljs/pitch-note": "6.1.0"
1025+ }
1026+ },
1027+ "node_modules/@tonaljs/mode": {
1028+ "version": "4.9.2",
1029+ "resolved": "https://registry.npmjs.org/@tonaljs/mode/-/mode-4.9.2.tgz",
1030+ "integrity": "sha512-Il48fWX9SnGMzwNFVbizkWkr/SJ+aCIIHhjWfSf1agrQAoq81536qBXEyEyvia6aqEXC4OaXAQ5rSoBcmQRj1Q==",
1031+ "license": "MIT",
1032+ "dependencies": {
1033+ "@tonaljs/collection": "4.9.0",
1034+ "@tonaljs/interval": "5.1.0",
1035+ "@tonaljs/pcset": "4.10.1",
1036+ "@tonaljs/pitch-distance": "5.0.5",
1037+ "@tonaljs/pitch-note": "6.1.0",
1038+ "@tonaljs/scale-type": "4.9.2"
1039+ }
1040+ },
1041+ "node_modules/@tonaljs/mode/node_modules/@tonaljs/interval": {
1042+ "version": "5.1.0",
1043+ "resolved": "https://registry.npmjs.org/@tonaljs/interval/-/interval-5.1.0.tgz",
1044+ "integrity": "sha512-GR9dUjn0j7yhjwjRh8HQxZYXOiVl05WfY3AFyMB9rfg807K4dSJmWfPTULPQXyHJ6NiZOPXcwRs8MxMLDxgdbg==",
1045+ "license": "MIT",
1046+ "dependencies": {
1047+ "@tonaljs/pitch": "^5.0.2",
1048+ "@tonaljs/pitch-distance": "^5.0.4",
1049+ "@tonaljs/pitch-interval": "^6.0.0"
1050+ }
1051+ },
1052+ "node_modules/@tonaljs/mode/node_modules/@tonaljs/pitch": {
1053+ "version": "5.0.2",
1054+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
1055+ "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
1056+ "license": "MIT"
1057+ },
1058+ "node_modules/@tonaljs/mode/node_modules/@tonaljs/pitch-interval": {
1059+ "version": "6.1.0",
1060+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
1061+ "integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
1062+ "license": "MIT",
1063+ "dependencies": {
1064+ "@tonaljs/pitch": "5.0.2"
1065+ }
1066+ },
1067+ "node_modules/@tonaljs/note": {
1068+ "version": "4.12.1",
1069+ "resolved": "https://registry.npmjs.org/@tonaljs/note/-/note-4.12.1.tgz",
1070+ "integrity": "sha512-yh6cnhu21bUb0VuIQWxObSbWBVp2cHIkJj4zZ4qsNOW4jSqn74+wHpSbOTDF8q+2hl6upz7TtBRavtqwPtLUCQ==",
1071+ "license": "MIT",
1072+ "dependencies": {
1073+ "@tonaljs/midi": "4.10.2",
1074+ "@tonaljs/pitch": "5.0.2",
1075+ "@tonaljs/pitch-distance": "5.0.5",
1076+ "@tonaljs/pitch-interval": "6.1.0",
1077+ "@tonaljs/pitch-note": "6.1.0"
1078+ }
1079+ },
1080+ "node_modules/@tonaljs/note/node_modules/@tonaljs/pitch": {
1081+ "version": "5.0.2",
1082+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
1083+ "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
1084+ "license": "MIT"
1085+ },
1086+ "node_modules/@tonaljs/note/node_modules/@tonaljs/pitch-interval": {
1087+ "version": "6.1.0",
1088+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
1089+ "integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
1090+ "license": "MIT",
1091+ "dependencies": {
1092+ "@tonaljs/pitch": "5.0.2"
1093+ }
1094+ },
1095+ "node_modules/@tonaljs/pcset": {
1096+ "version": "4.10.1",
1097+ "resolved": "https://registry.npmjs.org/@tonaljs/pcset/-/pcset-4.10.1.tgz",
1098+ "integrity": "sha512-CZG1rpKc38yMfpEJsbDTvsTmQqsek9xxcuMgK64Tr6sP1lEiDuZJbQeKVWWRnreBF2FQ1cEGLmfOpvSO+40csA==",
1099+ "license": "MIT",
1100+ "dependencies": {
1101+ "@tonaljs/collection": "4.9.0",
1102+ "@tonaljs/pitch": "5.0.2",
1103+ "@tonaljs/pitch-distance": "5.0.5",
1104+ "@tonaljs/pitch-interval": "6.1.0",
1105+ "@tonaljs/pitch-note": "6.1.0"
1106+ }
1107+ },
1108+ "node_modules/@tonaljs/pcset/node_modules/@tonaljs/pitch": {
1109+ "version": "5.0.2",
1110+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
1111+ "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
1112+ "license": "MIT"
1113+ },
1114+ "node_modules/@tonaljs/pcset/node_modules/@tonaljs/pitch-interval": {
1115+ "version": "6.1.0",
1116+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
1117+ "integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
1118+ "license": "MIT",
1119+ "dependencies": {
1120+ "@tonaljs/pitch": "5.0.2"
1121+ }
1122+ },
1123+ "node_modules/@tonaljs/pitch": {
1124+ "version": "5.0.1",
1125+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.1.tgz",
1126+ "integrity": "sha512-5HYkF4hGY0jS2y5V3Hf5gNFXX46kT4cAcI7JLEn+qQb9N1dU9Gz9koI9di0mD1Tbam+kviceiCsJl4WX/FqYjA==",
1127+ "license": "MIT"
1128+ },
1129+ "node_modules/@tonaljs/pitch-distance": {
1130+ "version": "5.0.5",
1131+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-distance/-/pitch-distance-5.0.5.tgz",
1132+ "integrity": "sha512-dTfjsU0zyrj5YmiFio5prPaD5w7sBmHp4nnmlEg70nHY+SerAH0KiO9HM9usttVgRFUaXl0Gc7OI8YMGfSFmug==",
1133+ "license": "MIT",
1134+ "dependencies": {
1135+ "@tonaljs/pitch": "5.0.2",
1136+ "@tonaljs/pitch-interval": "6.1.0",
1137+ "@tonaljs/pitch-note": "6.1.0"
1138+ }
1139+ },
1140+ "node_modules/@tonaljs/pitch-distance/node_modules/@tonaljs/pitch": {
1141+ "version": "5.0.2",
1142+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
1143+ "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
1144+ "license": "MIT"
1145+ },
1146+ "node_modules/@tonaljs/pitch-distance/node_modules/@tonaljs/pitch-interval": {
1147+ "version": "6.1.0",
1148+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
1149+ "integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
1150+ "license": "MIT",
1151+ "dependencies": {
1152+ "@tonaljs/pitch": "5.0.2"
1153+ }
1154+ },
1155+ "node_modules/@tonaljs/pitch-interval": {
1156+ "version": "5.0.2",
1157+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-5.0.2.tgz",
1158+ "integrity": "sha512-bQmxeenRFNuuABs9mDc+bSUp3ySGw8I49pHMoGDdCcro9n3MDN8IsSwhvKoGOYErFMx76rNn1t+7WL8ukpvX5w==",
1159+ "license": "MIT",
1160+ "dependencies": {
1161+ "@tonaljs/pitch": "5.0.1"
1162+ }
1163+ },
1164+ "node_modules/@tonaljs/pitch-note": {
1165+ "version": "6.1.0",
1166+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-note/-/pitch-note-6.1.0.tgz",
1167+ "integrity": "sha512-A4OSLo8DjM38u73862LnDmL4YInDDRBmg0fojXcvu4cyU3oOlqndyeHOra1OVoH/WW46uNIxNs1wJDZNPWL5KQ==",
1168+ "license": "MIT",
1169+ "dependencies": {
1170+ "@tonaljs/pitch": "5.0.2"
1171+ }
1172+ },
1173+ "node_modules/@tonaljs/pitch-note/node_modules/@tonaljs/pitch": {
1174+ "version": "5.0.2",
1175+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
1176+ "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
1177+ "license": "MIT"
1178+ },
1179+ "node_modules/@tonaljs/progression": {
1180+ "version": "4.9.2",
1181+ "resolved": "https://registry.npmjs.org/@tonaljs/progression/-/progression-4.9.2.tgz",
1182+ "integrity": "sha512-1Pmau5tKoWmY2H/fp9WVJ5Qm4c+xlu7IEZb3R3uLbc26kJq97DbuSnfArIAQoZTum2V6lOEN/Dt34E9OrGKnoA==",
1183+ "license": "MIT",
1184+ "dependencies": {
1185+ "@tonaljs/chord": "6.1.2",
1186+ "@tonaljs/pitch-distance": "5.0.5",
1187+ "@tonaljs/pitch-interval": "6.1.0",
1188+ "@tonaljs/pitch-note": "6.1.0",
1189+ "@tonaljs/roman-numeral": "4.9.1"
1190+ }
1191+ },
1192+ "node_modules/@tonaljs/progression/node_modules/@tonaljs/chord": {
1193+ "version": "6.1.2",
1194+ "resolved": "https://registry.npmjs.org/@tonaljs/chord/-/chord-6.1.2.tgz",
1195+ "integrity": "sha512-39crsPVyBJxdGJ1DGl+4diM+6+mZ/ws6crkQMlqX4zTYHMUVfJOQrko4mPF1CD89AFhHJe1zCT9Js+nNp3Gffg==",
1196+ "license": "MIT",
1197+ "dependencies": {
1198+ "@tonaljs/chord-detect": "4.9.1",
1199+ "@tonaljs/chord-type": "5.1.1",
1200+ "@tonaljs/collection": "4.9.0",
1201+ "@tonaljs/interval": "^5.1.0",
1202+ "@tonaljs/pcset": "4.10.1",
1203+ "@tonaljs/pitch-distance": "5.0.5",
1204+ "@tonaljs/pitch-note": "6.1.0",
1205+ "@tonaljs/scale-type": "4.9.2"
1206+ }
1207+ },
1208+ "node_modules/@tonaljs/progression/node_modules/@tonaljs/chord-type": {
1209+ "version": "5.1.1",
1210+ "resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.1.tgz",
1211+ "integrity": "sha512-ti4WzRYvvjH7to0G3zlJFq7WsjHqmcqbk8Jv98aZSR5YumLdY/ua2yOPPyoPq82n6vfgjZsacnAZ3v8/SodOcw==",
1212+ "license": "MIT",
1213+ "dependencies": {
1214+ "@tonaljs/pcset": "4.10.1"
1215+ }
1216+ },
1217+ "node_modules/@tonaljs/progression/node_modules/@tonaljs/interval": {
1218+ "version": "5.1.0",
1219+ "resolved": "https://registry.npmjs.org/@tonaljs/interval/-/interval-5.1.0.tgz",
1220+ "integrity": "sha512-GR9dUjn0j7yhjwjRh8HQxZYXOiVl05WfY3AFyMB9rfg807K4dSJmWfPTULPQXyHJ6NiZOPXcwRs8MxMLDxgdbg==",
1221+ "license": "MIT",
1222+ "dependencies": {
1223+ "@tonaljs/pitch": "^5.0.2",
1224+ "@tonaljs/pitch-distance": "^5.0.4",
1225+ "@tonaljs/pitch-interval": "^6.0.0"
1226+ }
1227+ },
1228+ "node_modules/@tonaljs/progression/node_modules/@tonaljs/pitch": {
1229+ "version": "5.0.2",
1230+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
1231+ "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
1232+ "license": "MIT"
1233+ },
1234+ "node_modules/@tonaljs/progression/node_modules/@tonaljs/pitch-interval": {
1235+ "version": "6.1.0",
1236+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
1237+ "integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
1238+ "license": "MIT",
1239+ "dependencies": {
1240+ "@tonaljs/pitch": "5.0.2"
1241+ }
1242+ },
1243+ "node_modules/@tonaljs/range": {
1244+ "version": "4.9.2",
1245+ "resolved": "https://registry.npmjs.org/@tonaljs/range/-/range-4.9.2.tgz",
1246+ "integrity": "sha512-XhFbCJCrEEIVz3MNmdVp9QUyiJA6eqnNnQeNqto8AuT5BYuffHDoMkBmvvjPuV5Lh8zfF9D0aqglvqPbZ+neKQ==",
1247+ "license": "MIT",
1248+ "dependencies": {
1249+ "@tonaljs/collection": "4.9.0",
1250+ "@tonaljs/midi": "4.10.2"
1251+ }
1252+ },
1253+ "node_modules/@tonaljs/roman-numeral": {
1254+ "version": "4.9.1",
1255+ "resolved": "https://registry.npmjs.org/@tonaljs/roman-numeral/-/roman-numeral-4.9.1.tgz",
1256+ "integrity": "sha512-dJGKBNHdPrNTE97ZDk4t6wpNmgYcpHyLkAvWkRP9I/HsDkeTFFQqNXosYIvspPWrFySlRpeqqFwcqV74MYoOag==",
1257+ "license": "MIT",
1258+ "dependencies": {
1259+ "@tonaljs/pitch": "5.0.2",
1260+ "@tonaljs/pitch-interval": "6.1.0",
1261+ "@tonaljs/pitch-note": "6.1.0"
1262+ }
1263+ },
1264+ "node_modules/@tonaljs/roman-numeral/node_modules/@tonaljs/pitch": {
1265+ "version": "5.0.2",
1266+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch/-/pitch-5.0.2.tgz",
1267+ "integrity": "sha512-mxaXJPPe+LIJdjzpZEl8I8Wx3dEvlzkBbsr2Ltwc2dTAdnErAZ5R0TxVq2egF27lMvQN2QPQPWI9iDPPdVUmrg==",
1268+ "license": "MIT"
1269+ },
1270+ "node_modules/@tonaljs/roman-numeral/node_modules/@tonaljs/pitch-interval": {
1271+ "version": "6.1.0",
1272+ "resolved": "https://registry.npmjs.org/@tonaljs/pitch-interval/-/pitch-interval-6.1.0.tgz",
1273+ "integrity": "sha512-9ZMxA7V4UgySnOKPIG6HECzhDb8mbiTCIdNNIzCIfz5XpjUmohku2YZpVVWMacLHgyeQicJzNoRiPel5oSAn4A==",
1274+ "license": "MIT",
1275+ "dependencies": {
1276+ "@tonaljs/pitch": "5.0.2"
1277+ }
1278+ },
1279+ "node_modules/@tonaljs/scale": {
1280+ "version": "4.13.4",
1281+ "resolved": "https://registry.npmjs.org/@tonaljs/scale/-/scale-4.13.4.tgz",
1282+ "integrity": "sha512-hyilnmsCmoGQiRhYWKM1QPrqaNH3KGEfrSfxeXcicPnEh12yvrVRm4djKmTVw7Rc/5P4GniPAA51+CS0C5dbLw==",
1283+ "license": "MIT",
1284+ "dependencies": {
1285+ "@tonaljs/chord-type": "5.1.1",
1286+ "@tonaljs/collection": "4.9.0",
1287+ "@tonaljs/note": "4.12.1",
1288+ "@tonaljs/pcset": "4.10.1",
1289+ "@tonaljs/pitch-distance": "5.0.5",
1290+ "@tonaljs/pitch-note": "6.1.0",
1291+ "@tonaljs/scale-type": "4.9.2"
1292+ }
1293+ },
1294+ "node_modules/@tonaljs/scale-type": {
1295+ "version": "4.9.2",
1296+ "resolved": "https://registry.npmjs.org/@tonaljs/scale-type/-/scale-type-4.9.2.tgz",
1297+ "integrity": "sha512-XDRPySg0X6owJ6AVzVRpS4ZpgleAUQakuq5VtfQ0JitnJKGRYo5Y0w7Fl/wEz/X9aEMk2lxcdF8VGGMIsQFo0g==",
1298+ "license": "MIT",
1299+ "dependencies": {
1300+ "@tonaljs/pcset": "4.10.1"
1301+ }
1302+ },
1303+ "node_modules/@tonaljs/scale/node_modules/@tonaljs/chord-type": {
1304+ "version": "5.1.1",
1305+ "resolved": "https://registry.npmjs.org/@tonaljs/chord-type/-/chord-type-5.1.1.tgz",
1306+ "integrity": "sha512-ti4WzRYvvjH7to0G3zlJFq7WsjHqmcqbk8Jv98aZSR5YumLdY/ua2yOPPyoPq82n6vfgjZsacnAZ3v8/SodOcw==",
1307+ "license": "MIT",
1308+ "dependencies": {
1309+ "@tonaljs/pcset": "4.10.1"
1310+ }
1311+ },
1312+ "node_modules/@tonaljs/time-signature": {
1313+ "version": "4.9.0",
1314+ "resolved": "https://registry.npmjs.org/@tonaljs/time-signature/-/time-signature-4.9.0.tgz",
1315+ "integrity": "sha512-zRo8CBqg/2guzTlF2vxyVIuB5gmwWQaxlknJPUSDII8CTdQ/x3a1LlNoMKkvTUnqwCihe3WrNPZ5XUfOOTthYA==",
1316+ "license": "MIT"
1317+ },
1318+ "node_modules/@tonaljs/tonal": {
1319+ "version": "4.10.0",
1320+ "resolved": "https://registry.npmjs.org/@tonaljs/tonal/-/tonal-4.10.0.tgz",
1321+ "integrity": "sha512-F2T9Fiy+j0MVped89kCrX1XF68mQOLUnny4pDOHrIf+OkrjtLQOzzaSOrX1tx0o72WUwQm1knz6D1d57b9X1HA==",
1322+ "license": "MIT",
1323+ "dependencies": {
1324+ "@tonaljs/abc-notation": "^4.8.0",
1325+ "@tonaljs/array": "^4.8.0",
1326+ "@tonaljs/chord": "^4.8.0",
1327+ "@tonaljs/chord-type": "^4.8.0",
1328+ "@tonaljs/collection": "^4.8.0",
1329+ "@tonaljs/core": "^4.8.0",
1330+ "@tonaljs/duration-value": "^4.8.0",
1331+ "@tonaljs/interval": "^4.8.0",
1332+ "@tonaljs/key": "^4.9.0",
1333+ "@tonaljs/midi": "^4.8.0",
1334+ "@tonaljs/mode": "^4.8.0",
1335+ "@tonaljs/note": "^4.9.0",
1336+ "@tonaljs/pcset": "^4.8.0",
1337+ "@tonaljs/progression": "^4.8.0",
1338+ "@tonaljs/range": "^4.8.0",
1339+ "@tonaljs/roman-numeral": "^4.8.0",
1340+ "@tonaljs/scale": "^4.9.0",
1341+ "@tonaljs/scale-type": "^4.8.0",
1342+ "@tonaljs/time-signature": "^4.8.0"
1343+ }
1344+ },
1345+ "node_modules/@tybys/wasm-util": {
1346+ "version": "0.10.3",
1347+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
1348+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
1349+ "dev": true,
1350+ "license": "MIT",
1351+ "optional": true,
1352+ "dependencies": {
1353+ "tslib": "^2.4.0"
1354+ }
1355+ },
1356+ "node_modules/@types/estree": {
1357+ "version": "1.0.9",
1358+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1359+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1360+ "license": "MIT"
1361+ },
1362+ "node_modules/@types/node": {
1363+ "version": "26.2.0",
1364+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
1365+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
1366+ "dev": true,
1367+ "license": "MIT",
1368+ "dependencies": {
1369+ "undici-types": "~8.3.0"
1370+ }
1371+ },
1372+ "node_modules/@types/react": {
1373+ "version": "19.2.18",
1374+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
1375+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
1376+ "devOptional": true,
1377+ "license": "MIT",
1378+ "dependencies": {
1379+ "csstype": "^3.2.2"
1380+ }
1381+ },
1382+ "node_modules/@types/resolve": {
1383+ "version": "1.20.2",
1384+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
1385+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
1386+ "license": "MIT"
1387+ },
1388+ "node_modules/@typescript/typescript-aix-ppc64": {
1389+ "version": "7.0.2",
1390+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
1391+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
1392+ "cpu": [
1393+ "ppc64"
1394+ ],
1395+ "dev": true,
1396+ "license": "Apache-2.0",
1397+ "optional": true,
1398+ "os": [
1399+ "aix"
1400+ ],
1401+ "engines": {
1402+ "node": ">=16.20.0"
1403+ }
1404+ },
1405+ "node_modules/@typescript/typescript-darwin-arm64": {
1406+ "version": "7.0.2",
1407+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
1408+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
1409+ "cpu": [
1410+ "arm64"
1411+ ],
1412+ "dev": true,
1413+ "license": "Apache-2.0",
1414+ "optional": true,
1415+ "os": [
1416+ "darwin"
1417+ ],
1418+ "engines": {
1419+ "node": ">=16.20.0"
1420+ }
1421+ },
1422+ "node_modules/@typescript/typescript-darwin-x64": {
1423+ "version": "7.0.2",
1424+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
1425+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
1426+ "cpu": [
1427+ "x64"
1428+ ],
1429+ "dev": true,
1430+ "license": "Apache-2.0",
1431+ "optional": true,
1432+ "os": [
1433+ "darwin"
1434+ ],
1435+ "engines": {
1436+ "node": ">=16.20.0"
1437+ }
1438+ },
1439+ "node_modules/@typescript/typescript-freebsd-arm64": {
1440+ "version": "7.0.2",
1441+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
1442+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
1443+ "cpu": [
1444+ "arm64"
1445+ ],
1446+ "dev": true,
1447+ "license": "Apache-2.0",
1448+ "optional": true,
1449+ "os": [
1450+ "freebsd"
1451+ ],
1452+ "engines": {
1453+ "node": ">=16.20.0"
1454+ }
1455+ },
1456+ "node_modules/@typescript/typescript-freebsd-x64": {
1457+ "version": "7.0.2",
1458+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
1459+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
1460+ "cpu": [
1461+ "x64"
1462+ ],
1463+ "dev": true,
1464+ "license": "Apache-2.0",
1465+ "optional": true,
1466+ "os": [
1467+ "freebsd"
1468+ ],
1469+ "engines": {
1470+ "node": ">=16.20.0"
1471+ }
1472+ },
1473+ "node_modules/@typescript/typescript-linux-arm": {
1474+ "version": "7.0.2",
1475+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
1476+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
1477+ "cpu": [
1478+ "arm"
1479+ ],
1480+ "dev": true,
1481+ "license": "Apache-2.0",
1482+ "optional": true,
1483+ "os": [
1484+ "linux"
1485+ ],
1486+ "engines": {
1487+ "node": ">=16.20.0"
1488+ }
1489+ },
1490+ "node_modules/@typescript/typescript-linux-arm64": {
1491+ "version": "7.0.2",
1492+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
1493+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
1494+ "cpu": [
1495+ "arm64"
1496+ ],
1497+ "dev": true,
1498+ "license": "Apache-2.0",
1499+ "optional": true,
1500+ "os": [
1501+ "linux"
1502+ ],
1503+ "engines": {
1504+ "node": ">=16.20.0"
1505+ }
1506+ },
1507+ "node_modules/@typescript/typescript-linux-loong64": {
1508+ "version": "7.0.2",
1509+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
1510+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
1511+ "cpu": [
1512+ "loong64"
1513+ ],
1514+ "dev": true,
1515+ "license": "Apache-2.0",
1516+ "optional": true,
1517+ "os": [
1518+ "linux"
1519+ ],
1520+ "engines": {
1521+ "node": ">=16.20.0"
1522+ }
1523+ },
1524+ "node_modules/@typescript/typescript-linux-mips64el": {
1525+ "version": "7.0.2",
1526+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
1527+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
1528+ "cpu": [
1529+ "mips64el"
1530+ ],
1531+ "dev": true,
1532+ "license": "Apache-2.0",
1533+ "optional": true,
1534+ "os": [
1535+ "linux"
1536+ ],
1537+ "engines": {
1538+ "node": ">=16.20.0"
1539+ }
1540+ },
1541+ "node_modules/@typescript/typescript-linux-ppc64": {
1542+ "version": "7.0.2",
1543+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
1544+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
1545+ "cpu": [
1546+ "ppc64"
1547+ ],
1548+ "dev": true,
1549+ "license": "Apache-2.0",
1550+ "optional": true,
1551+ "os": [
1552+ "linux"
1553+ ],
1554+ "engines": {
1555+ "node": ">=16.20.0"
1556+ }
1557+ },
1558+ "node_modules/@typescript/typescript-linux-riscv64": {
1559+ "version": "7.0.2",
1560+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
1561+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
1562+ "cpu": [
1563+ "riscv64"
1564+ ],
1565+ "dev": true,
1566+ "license": "Apache-2.0",
1567+ "optional": true,
1568+ "os": [
1569+ "linux"
1570+ ],
1571+ "engines": {
1572+ "node": ">=16.20.0"
1573+ }
1574+ },
1575+ "node_modules/@typescript/typescript-linux-s390x": {
1576+ "version": "7.0.2",
1577+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
1578+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
1579+ "cpu": [
1580+ "s390x"
1581+ ],
1582+ "dev": true,
1583+ "license": "Apache-2.0",
1584+ "optional": true,
1585+ "os": [
1586+ "linux"
1587+ ],
1588+ "engines": {
1589+ "node": ">=16.20.0"
1590+ }
1591+ },
1592+ "node_modules/@typescript/typescript-linux-x64": {
1593+ "version": "7.0.2",
1594+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
1595+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
1596+ "cpu": [
1597+ "x64"
1598+ ],
1599+ "dev": true,
1600+ "license": "Apache-2.0",
1601+ "optional": true,
1602+ "os": [
1603+ "linux"
1604+ ],
1605+ "engines": {
1606+ "node": ">=16.20.0"
1607+ }
1608+ },
1609+ "node_modules/@typescript/typescript-netbsd-arm64": {
1610+ "version": "7.0.2",
1611+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
1612+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
1613+ "cpu": [
1614+ "arm64"
1615+ ],
1616+ "dev": true,
1617+ "license": "Apache-2.0",
1618+ "optional": true,
1619+ "os": [
1620+ "netbsd"
1621+ ],
1622+ "engines": {
1623+ "node": ">=16.20.0"
1624+ }
1625+ },
1626+ "node_modules/@typescript/typescript-netbsd-x64": {
1627+ "version": "7.0.2",
1628+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
1629+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
1630+ "cpu": [
1631+ "x64"
1632+ ],
1633+ "dev": true,
1634+ "license": "Apache-2.0",
1635+ "optional": true,
1636+ "os": [
1637+ "netbsd"
1638+ ],
1639+ "engines": {
1640+ "node": ">=16.20.0"
1641+ }
1642+ },
1643+ "node_modules/@typescript/typescript-openbsd-arm64": {
1644+ "version": "7.0.2",
1645+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
1646+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
1647+ "cpu": [
1648+ "arm64"
1649+ ],
1650+ "dev": true,
1651+ "license": "Apache-2.0",
1652+ "optional": true,
1653+ "os": [
1654+ "openbsd"
1655+ ],
1656+ "engines": {
1657+ "node": ">=16.20.0"
1658+ }
1659+ },
1660+ "node_modules/@typescript/typescript-openbsd-x64": {
1661+ "version": "7.0.2",
1662+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
1663+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
1664+ "cpu": [
1665+ "x64"
1666+ ],
1667+ "dev": true,
1668+ "license": "Apache-2.0",
1669+ "optional": true,
1670+ "os": [
1671+ "openbsd"
1672+ ],
1673+ "engines": {
1674+ "node": ">=16.20.0"
1675+ }
1676+ },
1677+ "node_modules/@typescript/typescript-sunos-x64": {
1678+ "version": "7.0.2",
1679+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
1680+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
1681+ "cpu": [
1682+ "x64"
1683+ ],
1684+ "dev": true,
1685+ "license": "Apache-2.0",
1686+ "optional": true,
1687+ "os": [
1688+ "sunos"
1689+ ],
1690+ "engines": {
1691+ "node": ">=16.20.0"
1692+ }
1693+ },
1694+ "node_modules/@typescript/typescript-win32-arm64": {
1695+ "version": "7.0.2",
1696+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
1697+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
1698+ "cpu": [
1699+ "arm64"
1700+ ],
1701+ "dev": true,
1702+ "license": "Apache-2.0",
1703+ "optional": true,
1704+ "os": [
1705+ "win32"
1706+ ],
1707+ "engines": {
1708+ "node": ">=16.20.0"
1709+ }
1710+ },
1711+ "node_modules/@typescript/typescript-win32-x64": {
1712+ "version": "7.0.2",
1713+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
1714+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
1715+ "cpu": [
1716+ "x64"
1717+ ],
1718+ "dev": true,
1719+ "license": "Apache-2.0",
1720+ "optional": true,
1721+ "os": [
1722+ "win32"
1723+ ],
1724+ "engines": {
1725+ "node": ">=16.20.0"
1726+ }
1727+ },
1728+ "node_modules/accepts": {
1729+ "version": "2.0.0",
1730+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
1731+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
1732+ "license": "MIT",
1733+ "dependencies": {
1734+ "mime-types": "^3.0.0",
1735+ "negotiator": "^1.0.0"
1736+ },
1737+ "engines": {
1738+ "node": ">= 0.6"
1739+ }
1740+ },
1741+ "node_modules/acorn": {
1742+ "version": "8.17.0",
1743+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
1744+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
1745+ "license": "MIT",
1746+ "bin": {
1747+ "acorn": "bin/acorn"
1748+ },
1749+ "engines": {
1750+ "node": ">=0.4.0"
1751+ }
1752+ },
1753+ "node_modules/ansi-escapes": {
1754+ "version": "7.3.0",
1755+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
1756+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
1757+ "license": "MIT",
1758+ "dependencies": {
1759+ "environment": "^1.0.0"
1760+ },
1761+ "engines": {
1762+ "node": ">=18"
1763+ },
1764+ "funding": {
1765+ "url": "https://github.com/sponsors/sindresorhus"
1766+ }
1767+ },
1768+ "node_modules/ansi-regex": {
1769+ "version": "6.2.2",
1770+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
1771+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
1772+ "license": "MIT",
1773+ "engines": {
1774+ "node": ">=12"
1775+ },
1776+ "funding": {
1777+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
1778+ }
1779+ },
1780+ "node_modules/ansi-styles": {
1781+ "version": "6.2.3",
1782+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
1783+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
1784+ "license": "MIT",
1785+ "engines": {
1786+ "node": ">=12"
1787+ },
1788+ "funding": {
1789+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
1790+ }
1791+ },
1792+ "node_modules/auto-bind": {
1793+ "version": "5.0.1",
1794+ "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
1795+ "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
1796+ "license": "MIT",
1797+ "engines": {
1798+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
1799+ },
1800+ "funding": {
1801+ "url": "https://github.com/sponsors/sindresorhus"
1802+ }
1803+ },
1804+ "node_modules/babel-plugin-add-module-exports": {
1805+ "version": "0.2.1",
1806+ "resolved": "https://registry.npmjs.org/babel-plugin-add-module-exports/-/babel-plugin-add-module-exports-0.2.1.tgz",
1807+ "integrity": "sha512-3AN/9V/rKuv90NG65m4tTHsI04XrCKsWbztIcW7a8H5iIN7WlvWucRtVV0V/rT4QvtA11n5Vmp20fLwfMWqp6g==",
1808+ "license": "MIT"
1809+ },
1810+ "node_modules/body-parser": {
1811+ "version": "2.3.0",
1812+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
1813+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
1814+ "license": "MIT",
1815+ "dependencies": {
1816+ "bytes": "^3.1.2",
1817+ "content-type": "^2.0.0",
1818+ "debug": "^4.4.3",
1819+ "http-errors": "^2.0.1",
1820+ "iconv-lite": "^0.7.2",
1821+ "on-finished": "^2.4.1",
1822+ "qs": "^6.15.2",
1823+ "raw-body": "^3.0.2",
1824+ "type-is": "^2.1.0"
1825+ },
1826+ "engines": {
1827+ "node": ">=18"
1828+ },
1829+ "funding": {
1830+ "type": "opencollective",
1831+ "url": "https://opencollective.com/express"
1832+ }
1833+ },
1834+ "node_modules/body-parser/node_modules/content-type": {
1835+ "version": "2.0.0",
1836+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
1837+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
1838+ "license": "MIT",
1839+ "engines": {
1840+ "node": ">=18"
1841+ },
1842+ "funding": {
1843+ "type": "opencollective",
1844+ "url": "https://opencollective.com/express"
1845+ }
1846+ },
1847+ "node_modules/buffer-alloc": {
1848+ "version": "1.2.0",
1849+ "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
1850+ "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
1851+ "license": "MIT",
1852+ "dependencies": {
1853+ "buffer-alloc-unsafe": "^1.1.0",
1854+ "buffer-fill": "^1.0.0"
1855+ }
1856+ },
1857+ "node_modules/buffer-alloc-unsafe": {
1858+ "version": "1.1.0",
1859+ "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
1860+ "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
1861+ "license": "MIT"
1862+ },
1863+ "node_modules/buffer-fill": {
1864+ "version": "1.0.0",
1865+ "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
1866+ "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==",
1867+ "license": "MIT"
1868+ },
1869+ "node_modules/buffer-from": {
1870+ "version": "1.1.2",
1871+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
1872+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
1873+ "license": "MIT"
1874+ },
1875+ "node_modules/bytes": {
1876+ "version": "3.1.2",
1877+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
1878+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
1879+ "license": "MIT",
1880+ "engines": {
1881+ "node": ">= 0.8"
1882+ }
1883+ },
1884+ "node_modules/call-bind-apply-helpers": {
1885+ "version": "1.0.2",
1886+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
1887+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
1888+ "license": "MIT",
1889+ "dependencies": {
1890+ "es-errors": "^1.3.0",
1891+ "function-bind": "^1.1.2"
1892+ },
1893+ "engines": {
1894+ "node": ">= 0.4"
1895+ }
1896+ },
1897+ "node_modules/call-bound": {
1898+ "version": "1.0.4",
1899+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
1900+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
1901+ "license": "MIT",
1902+ "dependencies": {
1903+ "call-bind-apply-helpers": "^1.0.2",
1904+ "get-intrinsic": "^1.3.0"
1905+ },
1906+ "engines": {
1907+ "node": ">= 0.4"
1908+ },
1909+ "funding": {
1910+ "url": "https://github.com/sponsors/ljharb"
1911+ }
1912+ },
1913+ "node_modules/caller": {
1914+ "version": "1.1.0",
1915+ "resolved": "https://registry.npmjs.org/caller/-/caller-1.1.0.tgz",
1916+ "integrity": "sha512-n+21IZC3j06YpCWaxmUy5AnVqhmCIM2bQtqQyy00HJlmStRt6kwDX5F9Z97pqwAB+G/tgSz6q/kUBbNyQzIubw==",
1917+ "license": "MIT"
1918+ },
1919+ "node_modules/chalk": {
1920+ "version": "5.6.2",
1921+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
1922+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
1923+ "license": "MIT",
1924+ "engines": {
1925+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
1926+ },
1927+ "funding": {
1928+ "url": "https://github.com/chalk/chalk?sponsor=1"
1929+ }
1930+ },
1931+ "node_modules/chord-voicings": {
1932+ "version": "0.0.1",
1933+ "resolved": "https://registry.npmjs.org/chord-voicings/-/chord-voicings-0.0.1.tgz",
1934+ "integrity": "sha512-SutgB/4ynkkuiK6qdQ/k3QvCFcH0Vj8Ch4t6LbRyRQbVzP/TOztiCk3kvXd516UZ6fqk7ijDRELEFcKN+6V8sA==",
1935+ "license": "ISC",
1936+ "dependencies": {
1937+ "@tonaljs/tonal": "^4.6.5"
1938+ }
1939+ },
1940+ "node_modules/cli-boxes": {
1941+ "version": "4.0.1",
1942+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
1943+ "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==",
1944+ "license": "MIT",
1945+ "engines": {
1946+ "node": ">=18.20 <19 || >=20.10"
1947+ },
1948+ "funding": {
1949+ "url": "https://github.com/sponsors/sindresorhus"
1950+ }
1951+ },
1952+ "node_modules/cli-cursor": {
1953+ "version": "4.0.0",
1954+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
1955+ "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
1956+ "license": "MIT",
1957+ "dependencies": {
1958+ "restore-cursor": "^4.0.0"
1959+ },
1960+ "engines": {
1961+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
1962+ },
1963+ "funding": {
1964+ "url": "https://github.com/sponsors/sindresorhus"
1965+ }
1966+ },
1967+ "node_modules/cli-truncate": {
1968+ "version": "6.1.1",
1969+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz",
1970+ "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==",
1971+ "license": "MIT",
1972+ "dependencies": {
1973+ "slice-ansi": "^9.0.0",
1974+ "string-width": "^8.2.0"
1975+ },
1976+ "engines": {
1977+ "node": ">=22"
1978+ },
1979+ "funding": {
1980+ "url": "https://github.com/sponsors/sindresorhus"
1981+ }
1982+ },
1983+ "node_modules/cli-truncate/node_modules/string-width": {
1984+ "version": "8.2.2",
1985+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
1986+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
1987+ "license": "MIT",
1988+ "dependencies": {
1989+ "get-east-asian-width": "^1.5.0",
1990+ "strip-ansi": "^7.1.2"
1991+ },
1992+ "engines": {
1993+ "node": ">=20"
1994+ },
1995+ "funding": {
1996+ "url": "https://github.com/sponsors/sindresorhus"
1997+ }
1998+ },
1999+ "node_modules/cliui": {
2000+ "version": "9.0.1",
2001+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
2002+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
2003+ "dev": true,
2004+ "license": "ISC",
2005+ "dependencies": {
2006+ "string-width": "^7.2.0",
2007+ "strip-ansi": "^7.1.0",
2008+ "wrap-ansi": "^9.0.0"
2009+ },
2010+ "engines": {
2011+ "node": ">=20"
2012+ }
2013+ },
2014+ "node_modules/code-excerpt": {
2015+ "version": "4.0.0",
2016+ "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
2017+ "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
2018+ "license": "MIT",
2019+ "dependencies": {
2020+ "convert-to-spaces": "^2.0.1"
2021+ },
2022+ "engines": {
2023+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
2024+ }
2025+ },
2026+ "node_modules/codemirror": {
2027+ "version": "6.0.2",
2028+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
2029+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
2030+ "license": "MIT",
2031+ "dependencies": {
2032+ "@codemirror/autocomplete": "^6.0.0",
2033+ "@codemirror/commands": "^6.0.0",
2034+ "@codemirror/language": "^6.0.0",
2035+ "@codemirror/lint": "^6.0.0",
2036+ "@codemirror/search": "^6.0.0",
2037+ "@codemirror/state": "^6.0.0",
2038+ "@codemirror/view": "^6.0.0"
2039+ }
2040+ },
2041+ "node_modules/concurrently": {
2042+ "version": "10.0.4",
2043+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
2044+ "integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
2045+ "dev": true,
2046+ "license": "MIT",
2047+ "dependencies": {
2048+ "chalk": "5.6.2",
2049+ "rxjs": "7.8.2",
2050+ "shell-quote": "1.9.0",
2051+ "supports-color": "10.2.2",
2052+ "tree-kill": "1.2.2",
2053+ "yargs": "18.0.0"
2054+ },
2055+ "bin": {
2056+ "conc": "dist/bin/index.js",
2057+ "concurrently": "dist/bin/index.js"
2058+ },
2059+ "engines": {
2060+ "node": ">=22"
2061+ },
2062+ "funding": {
2063+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
2064+ }
2065+ },
2066+ "node_modules/content-disposition": {
2067+ "version": "1.1.0",
2068+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
2069+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
2070+ "license": "MIT",
2071+ "engines": {
2072+ "node": ">=18"
2073+ },
2074+ "funding": {
2075+ "type": "opencollective",
2076+ "url": "https://opencollective.com/express"
2077+ }
2078+ },
2079+ "node_modules/content-type": {
2080+ "version": "1.0.5",
2081+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
2082+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
2083+ "license": "MIT",
2084+ "engines": {
2085+ "node": ">= 0.6"
2086+ }
2087+ },
2088+ "node_modules/convert-to-spaces": {
2089+ "version": "2.0.1",
2090+ "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
2091+ "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
2092+ "license": "MIT",
2093+ "engines": {
2094+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
2095+ }
2096+ },
2097+ "node_modules/cookie": {
2098+ "version": "0.7.2",
2099+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
2100+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
2101+ "license": "MIT",
2102+ "engines": {
2103+ "node": ">= 0.6"
2104+ }
2105+ },
2106+ "node_modules/cookie-signature": {
2107+ "version": "1.2.2",
2108+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
2109+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
2110+ "license": "MIT",
2111+ "engines": {
2112+ "node": ">=6.6.0"
2113+ }
2114+ },
2115+ "node_modules/core-util-is": {
2116+ "version": "1.0.3",
2117+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
2118+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
2119+ "license": "MIT"
2120+ },
2121+ "node_modules/crelt": {
2122+ "version": "1.0.7",
2123+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
2124+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
2125+ "license": "MIT"
2126+ },
2127+ "node_modules/csstype": {
2128+ "version": "3.2.3",
2129+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2130+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2131+ "devOptional": true,
2132+ "license": "MIT"
2133+ },
2134+ "node_modules/data-uri-to-buffer": {
2135+ "version": "4.0.1",
2136+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
2137+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
2138+ "license": "MIT",
2139+ "engines": {
2140+ "node": ">= 12"
2141+ }
2142+ },
2143+ "node_modules/dct": {
2144+ "version": "0.1.0",
2145+ "resolved": "https://registry.npmjs.org/dct/-/dct-0.1.0.tgz",
2146+ "integrity": "sha512-/uUtEniuMq1aUxvLAoDtAduyl12oM1zhA/le2f83UFN/9+4KDHXFB6znEfoj5SDDLiTpUTr26NpxC7t8IFOYhQ==",
2147+ "license": "MIT",
2148+ "engines": {
2149+ "node": ">=0.12.0"
2150+ }
2151+ },
2152+ "node_modules/debug": {
2153+ "version": "4.4.3",
2154+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
2155+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
2156+ "license": "MIT",
2157+ "dependencies": {
2158+ "ms": "^2.1.3"
2159+ },
2160+ "engines": {
2161+ "node": ">=6.0"
2162+ },
2163+ "peerDependenciesMeta": {
2164+ "supports-color": {
2165+ "optional": true
2166+ }
2167+ }
2168+ },
2169+ "node_modules/deepmerge": {
2170+ "version": "4.3.1",
2171+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
2172+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
2173+ "license": "MIT",
2174+ "engines": {
2175+ "node": ">=0.10.0"
2176+ }
2177+ },
2178+ "node_modules/depd": {
2179+ "version": "2.0.0",
2180+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
2181+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
2182+ "license": "MIT",
2183+ "engines": {
2184+ "node": ">= 0.8"
2185+ }
2186+ },
2187+ "node_modules/detect-libc": {
2188+ "version": "2.1.2",
2189+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
2190+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
2191+ "dev": true,
2192+ "license": "Apache-2.0",
2193+ "engines": {
2194+ "node": ">=8"
2195+ }
2196+ },
2197+ "node_modules/djipevents": {
2198+ "version": "2.0.7",
2199+ "resolved": "https://registry.npmjs.org/djipevents/-/djipevents-2.0.7.tgz",
2200+ "integrity": "sha512-KNFYaU85imxOCKOUsIR70Iz9E19r96/X7LSH+u0tSoZdpWcBdzoqtTsU+wuLhc6GMpSFob+KInkZAbfKi01Bjg==",
2201+ "license": "Apache-2.0",
2202+ "dependencies": {
2203+ "@babel/runtime": "^7.20.6"
2204+ }
2205+ },
2206+ "node_modules/dunder-proto": {
2207+ "version": "1.0.1",
2208+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
2209+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
2210+ "license": "MIT",
2211+ "dependencies": {
2212+ "call-bind-apply-helpers": "^1.0.1",
2213+ "es-errors": "^1.3.0",
2214+ "gopd": "^1.2.0"
2215+ },
2216+ "engines": {
2217+ "node": ">= 0.4"
2218+ }
2219+ },
2220+ "node_modules/ee-first": {
2221+ "version": "1.1.1",
2222+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
2223+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
2224+ "license": "MIT"
2225+ },
2226+ "node_modules/emoji-regex": {
2227+ "version": "10.6.0",
2228+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
2229+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
2230+ "dev": true,
2231+ "license": "MIT"
2232+ },
2233+ "node_modules/encodeurl": {
2234+ "version": "2.0.0",
2235+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
2236+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
2237+ "license": "MIT",
2238+ "engines": {
2239+ "node": ">= 0.8"
2240+ }
2241+ },
2242+ "node_modules/environment": {
2243+ "version": "1.1.0",
2244+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
2245+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
2246+ "license": "MIT",
2247+ "engines": {
2248+ "node": ">=18"
2249+ },
2250+ "funding": {
2251+ "url": "https://github.com/sponsors/sindresorhus"
2252+ }
2253+ },
2254+ "node_modules/es-define-property": {
2255+ "version": "1.0.1",
2256+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
2257+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
2258+ "license": "MIT",
2259+ "engines": {
2260+ "node": ">= 0.4"
2261+ }
2262+ },
2263+ "node_modules/es-errors": {
2264+ "version": "1.3.0",
2265+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
2266+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
2267+ "license": "MIT",
2268+ "engines": {
2269+ "node": ">= 0.4"
2270+ }
2271+ },
2272+ "node_modules/es-object-atoms": {
2273+ "version": "1.1.2",
2274+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
2275+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
2276+ "license": "MIT",
2277+ "dependencies": {
2278+ "es-errors": "^1.3.0"
2279+ },
2280+ "engines": {
2281+ "node": ">= 0.4"
2282+ }
2283+ },
2284+ "node_modules/es-toolkit": {
2285+ "version": "1.50.0",
2286+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
2287+ "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==",
2288+ "license": "MIT",
2289+ "workspaces": [
2290+ "docs",
2291+ "benchmarks",
2292+ "tests/types"
2293+ ]
2294+ },
2295+ "node_modules/escalade": {
2296+ "version": "3.2.0",
2297+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
2298+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
2299+ "dev": true,
2300+ "license": "MIT",
2301+ "engines": {
2302+ "node": ">=6"
2303+ }
2304+ },
2305+ "node_modules/escape-html": {
2306+ "version": "1.0.3",
2307+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
2308+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
2309+ "license": "MIT"
2310+ },
2311+ "node_modules/escape-string-regexp": {
2312+ "version": "2.0.0",
2313+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
2314+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
2315+ "license": "MIT",
2316+ "engines": {
2317+ "node": ">=8"
2318+ }
2319+ },
2320+ "node_modules/escodegen": {
2321+ "version": "2.1.0",
2322+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
2323+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
2324+ "license": "BSD-2-Clause",
2325+ "dependencies": {
2326+ "esprima": "^4.0.1",
2327+ "estraverse": "^5.2.0",
2328+ "esutils": "^2.0.2"
2329+ },
2330+ "bin": {
2331+ "escodegen": "bin/escodegen.js",
2332+ "esgenerate": "bin/esgenerate.js"
2333+ },
2334+ "engines": {
2335+ "node": ">=6.0"
2336+ },
2337+ "optionalDependencies": {
2338+ "source-map": "~0.6.1"
2339+ }
2340+ },
2341+ "node_modules/esprima": {
2342+ "version": "4.0.1",
2343+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
2344+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
2345+ "license": "BSD-2-Clause",
2346+ "bin": {
2347+ "esparse": "bin/esparse.js",
2348+ "esvalidate": "bin/esvalidate.js"
2349+ },
2350+ "engines": {
2351+ "node": ">=4"
2352+ }
2353+ },
2354+ "node_modules/estraverse": {
2355+ "version": "5.3.0",
2356+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
2357+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
2358+ "license": "BSD-2-Clause",
2359+ "engines": {
2360+ "node": ">=4.0"
2361+ }
2362+ },
2363+ "node_modules/estree-walker": {
2364+ "version": "3.0.3",
2365+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
2366+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
2367+ "license": "MIT",
2368+ "dependencies": {
2369+ "@types/estree": "^1.0.0"
2370+ }
2371+ },
2372+ "node_modules/esutils": {
2373+ "version": "2.0.3",
2374+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
2375+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
2376+ "license": "BSD-2-Clause",
2377+ "engines": {
2378+ "node": ">=0.10.0"
2379+ }
2380+ },
2381+ "node_modules/etag": {
2382+ "version": "1.8.1",
2383+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
2384+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
2385+ "license": "MIT",
2386+ "engines": {
2387+ "node": ">= 0.6"
2388+ }
2389+ },
2390+ "node_modules/events": {
2391+ "version": "1.1.1",
2392+ "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
2393+ "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==",
2394+ "license": "MIT",
2395+ "engines": {
2396+ "node": ">=0.4.x"
2397+ }
2398+ },
2399+ "node_modules/express": {
2400+ "version": "5.2.1",
2401+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
2402+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
2403+ "license": "MIT",
2404+ "dependencies": {
2405+ "accepts": "^2.0.0",
2406+ "body-parser": "^2.2.1",
2407+ "content-disposition": "^1.0.0",
2408+ "content-type": "^1.0.5",
2409+ "cookie": "^0.7.1",
2410+ "cookie-signature": "^1.2.1",
2411+ "debug": "^4.4.0",
2412+ "depd": "^2.0.0",
2413+ "encodeurl": "^2.0.0",
2414+ "escape-html": "^1.0.3",
2415+ "etag": "^1.8.1",
2416+ "finalhandler": "^2.1.0",
2417+ "fresh": "^2.0.0",
2418+ "http-errors": "^2.0.0",
2419+ "merge-descriptors": "^2.0.0",
2420+ "mime-types": "^3.0.0",
2421+ "on-finished": "^2.4.1",
2422+ "once": "^1.4.0",
2423+ "parseurl": "^1.3.3",
2424+ "proxy-addr": "^2.0.7",
2425+ "qs": "^6.14.0",
2426+ "range-parser": "^1.2.1",
2427+ "router": "^2.2.0",
2428+ "send": "^1.1.0",
2429+ "serve-static": "^2.2.0",
2430+ "statuses": "^2.0.1",
2431+ "type-is": "^2.0.1",
2432+ "vary": "^1.1.2"
2433+ },
2434+ "engines": {
2435+ "node": ">= 18"
2436+ },
2437+ "funding": {
2438+ "type": "opencollective",
2439+ "url": "https://opencollective.com/express"
2440+ }
2441+ },
2442+ "node_modules/fast-sha256": {
2443+ "version": "1.3.0",
2444+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
2445+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
2446+ "license": "Unlicense"
2447+ },
2448+ "node_modules/fdir": {
2449+ "version": "6.5.0",
2450+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
2451+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
2452+ "dev": true,
2453+ "license": "MIT",
2454+ "engines": {
2455+ "node": ">=12.0.0"
2456+ },
2457+ "peerDependencies": {
2458+ "picomatch": "^3 || ^4"
2459+ },
2460+ "peerDependenciesMeta": {
2461+ "picomatch": {
2462+ "optional": true
2463+ }
2464+ }
2465+ },
2466+ "node_modules/fetch-blob": {
2467+ "version": "3.2.0",
2468+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
2469+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
2470+ "funding": [
2471+ {
2472+ "type": "github",
2473+ "url": "https://github.com/sponsors/jimmywarting"
2474+ },
2475+ {
2476+ "type": "paypal",
2477+ "url": "https://paypal.me/jimmywarting"
2478+ }
2479+ ],
2480+ "license": "MIT",
2481+ "dependencies": {
2482+ "node-domexception": "^1.0.0",
2483+ "web-streams-polyfill": "^3.0.3"
2484+ },
2485+ "engines": {
2486+ "node": "^12.20 || >= 14.13"
2487+ }
2488+ },
2489+ "node_modules/fftjs": {
2490+ "version": "0.0.4",
2491+ "resolved": "https://registry.npmjs.org/fftjs/-/fftjs-0.0.4.tgz",
2492+ "integrity": "sha512-nIWxQyth1LVD6NH8a+YZUv+McjzbOY6dMe4wv6Pq5cGfP+c8Rd1T8Dsd50DCWlNgzSqA3y9lOkpD6dZD3qHa1A==",
2493+ "license": "MIT",
2494+ "dependencies": {
2495+ "babel-plugin-add-module-exports": "^0.2.1"
2496+ }
2497+ },
2498+ "node_modules/finalhandler": {
2499+ "version": "2.1.1",
2500+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
2501+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
2502+ "license": "MIT",
2503+ "dependencies": {
2504+ "debug": "^4.4.0",
2505+ "encodeurl": "^2.0.0",
2506+ "escape-html": "^1.0.3",
2507+ "on-finished": "^2.4.1",
2508+ "parseurl": "^1.3.3",
2509+ "statuses": "^2.0.1"
2510+ },
2511+ "engines": {
2512+ "node": ">= 18.0.0"
2513+ },
2514+ "funding": {
2515+ "type": "opencollective",
2516+ "url": "https://opencollective.com/express"
2517+ }
2518+ },
2519+ "node_modules/formdata-polyfill": {
2520+ "version": "4.0.10",
2521+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
2522+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
2523+ "license": "MIT",
2524+ "dependencies": {
2525+ "fetch-blob": "^3.1.2"
2526+ },
2527+ "engines": {
2528+ "node": ">=12.20.0"
2529+ }
2530+ },
2531+ "node_modules/forwarded": {
2532+ "version": "0.2.0",
2533+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
2534+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
2535+ "license": "MIT",
2536+ "engines": {
2537+ "node": ">= 0.6"
2538+ }
2539+ },
2540+ "node_modules/fraction.js": {
2541+ "version": "5.3.4",
2542+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
2543+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
2544+ "license": "MIT",
2545+ "engines": {
2546+ "node": "*"
2547+ },
2548+ "funding": {
2549+ "type": "github",
2550+ "url": "https://github.com/sponsors/rawify"
2551+ }
2552+ },
2553+ "node_modules/fresh": {
2554+ "version": "2.0.0",
2555+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
2556+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
2557+ "license": "MIT",
2558+ "engines": {
2559+ "node": ">= 0.8"
2560+ }
2561+ },
2562+ "node_modules/fsevents": {
2563+ "version": "2.3.3",
2564+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
2565+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
2566+ "dev": true,
2567+ "hasInstallScript": true,
2568+ "license": "MIT",
2569+ "optional": true,
2570+ "os": [
2571+ "darwin"
2572+ ],
2573+ "engines": {
2574+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
2575+ }
2576+ },
2577+ "node_modules/function-bind": {
2578+ "version": "1.1.2",
2579+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
2580+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
2581+ "license": "MIT",
2582+ "funding": {
2583+ "url": "https://github.com/sponsors/ljharb"
2584+ }
2585+ },
2586+ "node_modules/get-caller-file": {
2587+ "version": "2.0.5",
2588+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
2589+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
2590+ "dev": true,
2591+ "license": "ISC",
2592+ "engines": {
2593+ "node": "6.* || 8.* || >= 10.*"
2594+ }
2595+ },
2596+ "node_modules/get-east-asian-width": {
2597+ "version": "1.6.0",
2598+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
2599+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
2600+ "license": "MIT",
2601+ "engines": {
2602+ "node": ">=18"
2603+ },
2604+ "funding": {
2605+ "url": "https://github.com/sponsors/sindresorhus"
2606+ }
2607+ },
2608+ "node_modules/get-intrinsic": {
2609+ "version": "1.3.0",
2610+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
2611+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
2612+ "license": "MIT",
2613+ "dependencies": {
2614+ "call-bind-apply-helpers": "^1.0.2",
2615+ "es-define-property": "^1.0.1",
2616+ "es-errors": "^1.3.0",
2617+ "es-object-atoms": "^1.1.1",
2618+ "function-bind": "^1.1.2",
2619+ "get-proto": "^1.0.1",
2620+ "gopd": "^1.2.0",
2621+ "has-symbols": "^1.1.0",
2622+ "hasown": "^2.0.2",
2623+ "math-intrinsics": "^1.1.0"
2624+ },
2625+ "engines": {
2626+ "node": ">= 0.4"
2627+ },
2628+ "funding": {
2629+ "url": "https://github.com/sponsors/ljharb"
2630+ }
2631+ },
2632+ "node_modules/get-proto": {
2633+ "version": "1.0.1",
2634+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
2635+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
2636+ "license": "MIT",
2637+ "dependencies": {
2638+ "dunder-proto": "^1.0.1",
2639+ "es-object-atoms": "^1.0.0"
2640+ },
2641+ "engines": {
2642+ "node": ">= 0.4"
2643+ }
2644+ },
2645+ "node_modules/gopd": {
2646+ "version": "1.2.0",
2647+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
2648+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
2649+ "license": "MIT",
2650+ "engines": {
2651+ "node": ">= 0.4"
2652+ },
2653+ "funding": {
2654+ "url": "https://github.com/sponsors/ljharb"
2655+ }
2656+ },
2657+ "node_modules/has-symbols": {
2658+ "version": "1.1.0",
2659+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
2660+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
2661+ "license": "MIT",
2662+ "engines": {
2663+ "node": ">= 0.4"
2664+ },
2665+ "funding": {
2666+ "url": "https://github.com/sponsors/ljharb"
2667+ }
2668+ },
2669+ "node_modules/hasown": {
2670+ "version": "2.0.4",
2671+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
2672+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
2673+ "license": "MIT",
2674+ "dependencies": {
2675+ "function-bind": "^1.1.2"
2676+ },
2677+ "engines": {
2678+ "node": ">= 0.4"
2679+ }
2680+ },
2681+ "node_modules/htm": {
2682+ "version": "3.1.1",
2683+ "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
2684+ "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
2685+ "license": "Apache-2.0"
2686+ },
2687+ "node_modules/http-errors": {
2688+ "version": "2.0.1",
2689+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
2690+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
2691+ "license": "MIT",
2692+ "dependencies": {
2693+ "depd": "~2.0.0",
2694+ "inherits": "~2.0.4",
2695+ "setprototypeof": "~1.2.0",
2696+ "statuses": "~2.0.2",
2697+ "toidentifier": "~1.0.1"
2698+ },
2699+ "engines": {
2700+ "node": ">= 0.8"
2701+ },
2702+ "funding": {
2703+ "type": "opencollective",
2704+ "url": "https://opencollective.com/express"
2705+ }
2706+ },
2707+ "node_modules/hydra-synth": {
2708+ "version": "1.4.0",
2709+ "resolved": "https://registry.npmjs.org/hydra-synth/-/hydra-synth-1.4.0.tgz",
2710+ "integrity": "sha512-suKn8wjx5Kqvj5Ev0aTByNzpT+7PTX2Qu83h9mIp7CArFn14VVNxlm6hBS0T/O4GG2E9P9SHvhyN2Ue0f1jH5A==",
2711+ "license": "AGPL",
2712+ "dependencies": {
2713+ "meyda": "^5.6.3",
2714+ "raf-loop": "^1.1.3",
2715+ "regl": "^1.3.9"
2716+ }
2717+ },
2718+ "node_modules/iconv-lite": {
2719+ "version": "0.7.3",
2720+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
2721+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
2722+ "license": "MIT",
2723+ "dependencies": {
2724+ "safer-buffer": ">= 2.1.2 < 3.0.0"
2725+ },
2726+ "engines": {
2727+ "node": ">=0.10.0"
2728+ },
2729+ "funding": {
2730+ "type": "opencollective",
2731+ "url": "https://opencollective.com/express"
2732+ }
2733+ },
2734+ "node_modules/indent-string": {
2735+ "version": "5.0.0",
2736+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
2737+ "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
2738+ "license": "MIT",
2739+ "engines": {
2740+ "node": ">=12"
2741+ },
2742+ "funding": {
2743+ "url": "https://github.com/sponsors/sindresorhus"
2744+ }
2745+ },
2746+ "node_modules/inherits": {
2747+ "version": "2.0.4",
2748+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
2749+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
2750+ "license": "ISC"
2751+ },
2752+ "node_modules/ink": {
2753+ "version": "7.1.1",
2754+ "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz",
2755+ "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==",
2756+ "license": "MIT",
2757+ "dependencies": {
2758+ "@alcalzone/ansi-tokenize": "^0.3.0",
2759+ "ansi-escapes": "^7.3.0",
2760+ "ansi-styles": "^6.2.3",
2761+ "auto-bind": "^5.0.1",
2762+ "chalk": "^5.6.2",
2763+ "cli-boxes": "^4.0.1",
2764+ "cli-cursor": "^4.0.0",
2765+ "cli-truncate": "^6.0.0",
2766+ "code-excerpt": "^4.0.0",
2767+ "es-toolkit": "^1.45.1",
2768+ "indent-string": "^5.0.0",
2769+ "is-in-ci": "^2.0.0",
2770+ "patch-console": "^2.0.0",
2771+ "react-reconciler": "^0.33.0",
2772+ "scheduler": "^0.27.0",
2773+ "signal-exit": "^3.0.7",
2774+ "slice-ansi": "^9.0.0",
2775+ "stack-utils": "^2.0.6",
2776+ "string-width": "^8.2.0",
2777+ "terminal-size": "^4.0.1",
2778+ "type-fest": "^5.5.0",
2779+ "widest-line": "^6.0.0",
2780+ "wrap-ansi": "^10.0.0",
2781+ "ws": "^8.20.0",
2782+ "yoga-layout": "~3.2.1"
2783+ },
2784+ "engines": {
2785+ "node": ">=22"
2786+ },
2787+ "peerDependencies": {
2788+ "@types/react": ">=19.2.0",
2789+ "react": ">=19.2.0",
2790+ "react-devtools-core": ">=6.1.2"
2791+ },
2792+ "peerDependenciesMeta": {
2793+ "@types/react": {
2794+ "optional": true
2795+ },
2796+ "react-devtools-core": {
2797+ "optional": true
2798+ }
2799+ }
2800+ },
2801+ "node_modules/ink/node_modules/string-width": {
2802+ "version": "8.2.2",
2803+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
2804+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
2805+ "license": "MIT",
2806+ "dependencies": {
2807+ "get-east-asian-width": "^1.5.0",
2808+ "strip-ansi": "^7.1.2"
2809+ },
2810+ "engines": {
2811+ "node": ">=20"
2812+ },
2813+ "funding": {
2814+ "url": "https://github.com/sponsors/sindresorhus"
2815+ }
2816+ },
2817+ "node_modules/ink/node_modules/wrap-ansi": {
2818+ "version": "10.0.0",
2819+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz",
2820+ "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==",
2821+ "license": "MIT",
2822+ "dependencies": {
2823+ "ansi-styles": "^6.2.3",
2824+ "string-width": "^8.2.0",
2825+ "strip-ansi": "^7.1.2"
2826+ },
2827+ "engines": {
2828+ "node": ">=20"
2829+ },
2830+ "funding": {
2831+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
2832+ }
2833+ },
2834+ "node_modules/ipaddr.js": {
2835+ "version": "1.9.1",
2836+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
2837+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
2838+ "license": "MIT",
2839+ "engines": {
2840+ "node": ">= 0.10"
2841+ }
2842+ },
2843+ "node_modules/is-core-module": {
2844+ "version": "2.16.2",
2845+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
2846+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
2847+ "license": "MIT",
2848+ "dependencies": {
2849+ "hasown": "^2.0.3"
2850+ },
2851+ "engines": {
2852+ "node": ">= 0.4"
2853+ },
2854+ "funding": {
2855+ "url": "https://github.com/sponsors/ljharb"
2856+ }
2857+ },
2858+ "node_modules/is-fullwidth-code-point": {
2859+ "version": "5.1.0",
2860+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
2861+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
2862+ "license": "MIT",
2863+ "dependencies": {
2864+ "get-east-asian-width": "^1.3.1"
2865+ },
2866+ "engines": {
2867+ "node": ">=18"
2868+ },
2869+ "funding": {
2870+ "url": "https://github.com/sponsors/sindresorhus"
2871+ }
2872+ },
2873+ "node_modules/is-in-ci": {
2874+ "version": "2.0.0",
2875+ "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
2876+ "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
2877+ "license": "MIT",
2878+ "bin": {
2879+ "is-in-ci": "cli.js"
2880+ },
2881+ "engines": {
2882+ "node": ">=20"
2883+ },
2884+ "funding": {
2885+ "url": "https://github.com/sponsors/sindresorhus"
2886+ }
2887+ },
2888+ "node_modules/is-module": {
2889+ "version": "1.0.0",
2890+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
2891+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
2892+ "license": "MIT"
2893+ },
2894+ "node_modules/is-promise": {
2895+ "version": "4.0.0",
2896+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
2897+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
2898+ "license": "MIT"
2899+ },
2900+ "node_modules/isarray": {
2901+ "version": "0.0.1",
2902+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
2903+ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
2904+ "license": "MIT"
2905+ },
2906+ "node_modules/jazz-midi": {
2907+ "version": "1.7.9",
2908+ "resolved": "https://registry.npmjs.org/jazz-midi/-/jazz-midi-1.7.9.tgz",
2909+ "integrity": "sha512-c8c4BBgwxdsIr1iVm53nadCrtH7BUlnX3V95ciK/gbvXN/ndE5+POskBalXgqlc/r9p2XUbdLTrgrC6fou5p9w==",
2910+ "license": "MIT",
2911+ "optional": true,
2912+ "engines": {
2913+ "node": ">=10.0.0"
2914+ }
2915+ },
2916+ "node_modules/json-schema-to-ts": {
2917+ "version": "3.1.1",
2918+ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
2919+ "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
2920+ "license": "MIT",
2921+ "dependencies": {
2922+ "@babel/runtime": "^7.18.3",
2923+ "ts-algebra": "^2.0.0"
2924+ },
2925+ "engines": {
2926+ "node": ">=16"
2927+ }
2928+ },
2929+ "node_modules/jzz": {
2930+ "version": "1.9.6",
2931+ "resolved": "https://registry.npmjs.org/jzz/-/jzz-1.9.6.tgz",
2932+ "integrity": "sha512-J7ENLhXwfm2BNDKRUrL8eKtPhUS/CtMBpiafxQHDBcOWSocLhearDKEdh+ylnZFcr5OXWTed0gj6l/txeQA9vg==",
2933+ "license": "MIT",
2934+ "optional": true,
2935+ "dependencies": {
2936+ "jazz-midi": "^1.7.9"
2937+ }
2938+ },
2939+ "node_modules/lightningcss": {
2940+ "version": "1.33.0",
2941+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
2942+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
2943+ "dev": true,
2944+ "license": "MPL-2.0",
2945+ "dependencies": {
2946+ "detect-libc": "^2.0.3"
2947+ },
2948+ "engines": {
2949+ "node": ">= 12.0.0"
2950+ },
2951+ "funding": {
2952+ "type": "opencollective",
2953+ "url": "https://opencollective.com/parcel"
2954+ },
2955+ "optionalDependencies": {
2956+ "lightningcss-android-arm64": "1.33.0",
2957+ "lightningcss-darwin-arm64": "1.33.0",
2958+ "lightningcss-darwin-x64": "1.33.0",
2959+ "lightningcss-freebsd-x64": "1.33.0",
2960+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
2961+ "lightningcss-linux-arm64-gnu": "1.33.0",
2962+ "lightningcss-linux-arm64-musl": "1.33.0",
2963+ "lightningcss-linux-x64-gnu": "1.33.0",
2964+ "lightningcss-linux-x64-musl": "1.33.0",
2965+ "lightningcss-win32-arm64-msvc": "1.33.0",
2966+ "lightningcss-win32-x64-msvc": "1.33.0"
2967+ }
2968+ },
2969+ "node_modules/lightningcss-android-arm64": {
2970+ "version": "1.33.0",
2971+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
2972+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
2973+ "cpu": [
2974+ "arm64"
2975+ ],
2976+ "dev": true,
2977+ "license": "MPL-2.0",
2978+ "optional": true,
2979+ "os": [
2980+ "android"
2981+ ],
2982+ "engines": {
2983+ "node": ">= 12.0.0"
2984+ },
2985+ "funding": {
2986+ "type": "opencollective",
2987+ "url": "https://opencollective.com/parcel"
2988+ }
2989+ },
2990+ "node_modules/lightningcss-darwin-arm64": {
2991+ "version": "1.33.0",
2992+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
2993+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
2994+ "cpu": [
2995+ "arm64"
2996+ ],
2997+ "dev": true,
2998+ "license": "MPL-2.0",
2999+ "optional": true,
3000+ "os": [
3001+ "darwin"
3002+ ],
3003+ "engines": {
3004+ "node": ">= 12.0.0"
3005+ },
3006+ "funding": {
3007+ "type": "opencollective",
3008+ "url": "https://opencollective.com/parcel"
3009+ }
3010+ },
3011+ "node_modules/lightningcss-darwin-x64": {
3012+ "version": "1.33.0",
3013+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
3014+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
3015+ "cpu": [
3016+ "x64"
3017+ ],
3018+ "dev": true,
3019+ "license": "MPL-2.0",
3020+ "optional": true,
3021+ "os": [
3022+ "darwin"
3023+ ],
3024+ "engines": {
3025+ "node": ">= 12.0.0"
3026+ },
3027+ "funding": {
3028+ "type": "opencollective",
3029+ "url": "https://opencollective.com/parcel"
3030+ }
3031+ },
3032+ "node_modules/lightningcss-freebsd-x64": {
3033+ "version": "1.33.0",
3034+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
3035+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
3036+ "cpu": [
3037+ "x64"
3038+ ],
3039+ "dev": true,
3040+ "license": "MPL-2.0",
3041+ "optional": true,
3042+ "os": [
3043+ "freebsd"
3044+ ],
3045+ "engines": {
3046+ "node": ">= 12.0.0"
3047+ },
3048+ "funding": {
3049+ "type": "opencollective",
3050+ "url": "https://opencollective.com/parcel"
3051+ }
3052+ },
3053+ "node_modules/lightningcss-linux-arm-gnueabihf": {
3054+ "version": "1.33.0",
3055+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
3056+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
3057+ "cpu": [
3058+ "arm"
3059+ ],
3060+ "dev": true,
3061+ "license": "MPL-2.0",
3062+ "optional": true,
3063+ "os": [
3064+ "linux"
3065+ ],
3066+ "engines": {
3067+ "node": ">= 12.0.0"
3068+ },
3069+ "funding": {
3070+ "type": "opencollective",
3071+ "url": "https://opencollective.com/parcel"
3072+ }
3073+ },
3074+ "node_modules/lightningcss-linux-arm64-gnu": {
3075+ "version": "1.33.0",
3076+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
3077+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
3078+ "cpu": [
3079+ "arm64"
3080+ ],
3081+ "dev": true,
3082+ "license": "MPL-2.0",
3083+ "optional": true,
3084+ "os": [
3085+ "linux"
3086+ ],
3087+ "engines": {
3088+ "node": ">= 12.0.0"
3089+ },
3090+ "funding": {
3091+ "type": "opencollective",
3092+ "url": "https://opencollective.com/parcel"
3093+ }
3094+ },
3095+ "node_modules/lightningcss-linux-arm64-musl": {
3096+ "version": "1.33.0",
3097+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
3098+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
3099+ "cpu": [
3100+ "arm64"
3101+ ],
3102+ "dev": true,
3103+ "license": "MPL-2.0",
3104+ "optional": true,
3105+ "os": [
3106+ "linux"
3107+ ],
3108+ "engines": {
3109+ "node": ">= 12.0.0"
3110+ },
3111+ "funding": {
3112+ "type": "opencollective",
3113+ "url": "https://opencollective.com/parcel"
3114+ }
3115+ },
3116+ "node_modules/lightningcss-linux-x64-gnu": {
3117+ "version": "1.33.0",
3118+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
3119+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
3120+ "cpu": [
3121+ "x64"
3122+ ],
3123+ "dev": true,
3124+ "license": "MPL-2.0",
3125+ "optional": true,
3126+ "os": [
3127+ "linux"
3128+ ],
3129+ "engines": {
3130+ "node": ">= 12.0.0"
3131+ },
3132+ "funding": {
3133+ "type": "opencollective",
3134+ "url": "https://opencollective.com/parcel"
3135+ }
3136+ },
3137+ "node_modules/lightningcss-linux-x64-musl": {
3138+ "version": "1.33.0",
3139+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
3140+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
3141+ "cpu": [
3142+ "x64"
3143+ ],
3144+ "dev": true,
3145+ "license": "MPL-2.0",
3146+ "optional": true,
3147+ "os": [
3148+ "linux"
3149+ ],
3150+ "engines": {
3151+ "node": ">= 12.0.0"
3152+ },
3153+ "funding": {
3154+ "type": "opencollective",
3155+ "url": "https://opencollective.com/parcel"
3156+ }
3157+ },
3158+ "node_modules/lightningcss-win32-arm64-msvc": {
3159+ "version": "1.33.0",
3160+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
3161+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
3162+ "cpu": [
3163+ "arm64"
3164+ ],
3165+ "dev": true,
3166+ "license": "MPL-2.0",
3167+ "optional": true,
3168+ "os": [
3169+ "win32"
3170+ ],
3171+ "engines": {
3172+ "node": ">= 12.0.0"
3173+ },
3174+ "funding": {
3175+ "type": "opencollective",
3176+ "url": "https://opencollective.com/parcel"
3177+ }
3178+ },
3179+ "node_modules/lightningcss-win32-x64-msvc": {
3180+ "version": "1.33.0",
3181+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
3182+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
3183+ "cpu": [
3184+ "x64"
3185+ ],
3186+ "dev": true,
3187+ "license": "MPL-2.0",
3188+ "optional": true,
3189+ "os": [
3190+ "win32"
3191+ ],
3192+ "engines": {
3193+ "node": ">= 12.0.0"
3194+ },
3195+ "funding": {
3196+ "type": "opencollective",
3197+ "url": "https://opencollective.com/parcel"
3198+ }
3199+ },
3200+ "node_modules/math-intrinsics": {
3201+ "version": "1.1.0",
3202+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
3203+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
3204+ "license": "MIT",
3205+ "engines": {
3206+ "node": ">= 0.4"
3207+ }
3208+ },
3209+ "node_modules/media-typer": {
3210+ "version": "1.1.1",
3211+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
3212+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
3213+ "license": "MIT",
3214+ "engines": {
3215+ "node": ">= 0.8"
3216+ },
3217+ "funding": {
3218+ "type": "opencollective",
3219+ "url": "https://opencollective.com/express"
3220+ }
3221+ },
3222+ "node_modules/merge-descriptors": {
3223+ "version": "2.0.0",
3224+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
3225+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
3226+ "license": "MIT",
3227+ "engines": {
3228+ "node": ">=18"
3229+ },
3230+ "funding": {
3231+ "url": "https://github.com/sponsors/sindresorhus"
3232+ }
3233+ },
3234+ "node_modules/meyda": {
3235+ "version": "5.6.3",
3236+ "resolved": "https://registry.npmjs.org/meyda/-/meyda-5.6.3.tgz",
3237+ "integrity": "sha512-fAdwfzIi1WDoL0idUQvCD7dZ7EN74FYH83G+jZQO3Nr9yOEBtzFvcMg2KLdLlu6psSP8XFlO0kYynG5o/E681Q==",
3238+ "license": "MIT",
3239+ "workspaces": [
3240+ "docs"
3241+ ],
3242+ "dependencies": {
3243+ "@rollup/plugin-node-resolve": "^15.2.3",
3244+ "dct": "0.1.0",
3245+ "fftjs": "0.0.4",
3246+ "node-getopt": "^0.3.2",
3247+ "wav": "^1.0.2"
3248+ },
3249+ "bin": {
3250+ "meyda": "bin/cli.js"
3251+ }
3252+ },
3253+ "node_modules/mime-db": {
3254+ "version": "1.54.0",
3255+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
3256+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
3257+ "license": "MIT",
3258+ "engines": {
3259+ "node": ">= 0.6"
3260+ }
3261+ },
3262+ "node_modules/mime-types": {
3263+ "version": "3.0.2",
3264+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
3265+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
3266+ "license": "MIT",
3267+ "dependencies": {
3268+ "mime-db": "^1.54.0"
3269+ },
3270+ "engines": {
3271+ "node": ">=18"
3272+ },
3273+ "funding": {
3274+ "type": "opencollective",
3275+ "url": "https://opencollective.com/express"
3276+ }
3277+ },
3278+ "node_modules/mimic-fn": {
3279+ "version": "2.1.0",
3280+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
3281+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
3282+ "license": "MIT",
3283+ "engines": {
3284+ "node": ">=6"
3285+ }
3286+ },
3287+ "node_modules/ms": {
3288+ "version": "2.1.3",
3289+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
3290+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
3291+ "license": "MIT"
3292+ },
3293+ "node_modules/nanoid": {
3294+ "version": "3.3.16",
3295+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
3296+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
3297+ "dev": true,
3298+ "funding": [
3299+ {
3300+ "type": "github",
3301+ "url": "https://github.com/sponsors/ai"
3302+ }
3303+ ],
3304+ "license": "MIT",
3305+ "bin": {
3306+ "nanoid": "bin/nanoid.cjs"
3307+ },
3308+ "engines": {
3309+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
3310+ }
3311+ },
3312+ "node_modules/nanostores": {
3313+ "version": "0.11.4",
3314+ "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-0.11.4.tgz",
3315+ "integrity": "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==",
3316+ "funding": [
3317+ {
3318+ "type": "github",
3319+ "url": "https://github.com/sponsors/ai"
3320+ }
3321+ ],
3322+ "license": "MIT",
3323+ "engines": {
3324+ "node": "^18.0.0 || >=20.0.0"
3325+ }
3326+ },
3327+ "node_modules/negotiator": {
3328+ "version": "1.0.0",
3329+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
3330+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
3331+ "license": "MIT",
3332+ "engines": {
3333+ "node": ">= 0.6"
3334+ }
3335+ },
3336+ "node_modules/node-domexception": {
3337+ "version": "1.0.0",
3338+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
3339+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
3340+ "deprecated": "Use your platform's native DOMException instead",
3341+ "funding": [
3342+ {
3343+ "type": "github",
3344+ "url": "https://github.com/sponsors/jimmywarting"
3345+ },
3346+ {
3347+ "type": "github",
3348+ "url": "https://paypal.me/jimmywarting"
3349+ }
3350+ ],
3351+ "license": "MIT",
3352+ "engines": {
3353+ "node": ">=10.5.0"
3354+ }
3355+ },
3356+ "node_modules/node-fetch": {
3357+ "version": "3.3.2",
3358+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
3359+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
3360+ "license": "MIT",
3361+ "dependencies": {
3362+ "data-uri-to-buffer": "^4.0.0",
3363+ "fetch-blob": "^3.1.4",
3364+ "formdata-polyfill": "^4.0.10"
3365+ },
3366+ "engines": {
3367+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
3368+ },
3369+ "funding": {
3370+ "type": "opencollective",
3371+ "url": "https://opencollective.com/node-fetch"
3372+ }
3373+ },
3374+ "node_modules/node-getopt": {
3375+ "version": "0.3.2",
3376+ "resolved": "https://registry.npmjs.org/node-getopt/-/node-getopt-0.3.2.tgz",
3377+ "integrity": "sha512-yqkmYrMbK1wPrfz7mgeYvA4tBperLg9FQ4S3Sau3nSAkpOA0x0zC8nQ1siBwozy1f4SE8vq2n1WKv99r+PCa1Q==",
3378+ "license": "MIT",
3379+ "engines": {
3380+ "node": ">= 0.6.0"
3381+ }
3382+ },
3383+ "node_modules/node-web-audio-api": {
3384+ "version": "2.2.0",
3385+ "resolved": "https://registry.npmjs.org/node-web-audio-api/-/node-web-audio-api-2.2.0.tgz",
3386+ "integrity": "sha512-qM3dW3Z5HIz+O+++Uiia8x0OSNHcfJGUlexpCTdAkw8JlM17Ru1nV9a94wlxNRXqxT0Lr/ewwEnBXUwGMt24AA==",
3387+ "license": "BSD-3-Clause",
3388+ "dependencies": {
3389+ "caller": "^1.1.0",
3390+ "node-fetch": "^3.3.2",
3391+ "webidl-conversions": "^8.0.1"
3392+ },
3393+ "engines": {
3394+ "node": ">= 22"
3395+ }
3396+ },
3397+ "node_modules/object-inspect": {
3398+ "version": "1.13.4",
3399+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
3400+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
3401+ "license": "MIT",
3402+ "engines": {
3403+ "node": ">= 0.4"
3404+ },
3405+ "funding": {
3406+ "url": "https://github.com/sponsors/ljharb"
3407+ }
3408+ },
3409+ "node_modules/on-finished": {
3410+ "version": "2.4.1",
3411+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
3412+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
3413+ "license": "MIT",
3414+ "dependencies": {
3415+ "ee-first": "1.1.1"
3416+ },
3417+ "engines": {
3418+ "node": ">= 0.8"
3419+ }
3420+ },
3421+ "node_modules/once": {
3422+ "version": "1.4.0",
3423+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
3424+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
3425+ "license": "ISC",
3426+ "dependencies": {
3427+ "wrappy": "1"
3428+ }
3429+ },
3430+ "node_modules/onetime": {
3431+ "version": "5.1.2",
3432+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
3433+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
3434+ "license": "MIT",
3435+ "dependencies": {
3436+ "mimic-fn": "^2.1.0"
3437+ },
3438+ "engines": {
3439+ "node": ">=6"
3440+ },
3441+ "funding": {
3442+ "url": "https://github.com/sponsors/sindresorhus"
3443+ }
3444+ },
3445+ "node_modules/parseurl": {
3446+ "version": "1.3.3",
3447+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
3448+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
3449+ "license": "MIT",
3450+ "engines": {
3451+ "node": ">= 0.8"
3452+ }
3453+ },
3454+ "node_modules/patch-console": {
3455+ "version": "2.0.0",
3456+ "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
3457+ "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
3458+ "license": "MIT",
3459+ "engines": {
3460+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
3461+ }
3462+ },
3463+ "node_modules/path-parse": {
3464+ "version": "1.0.7",
3465+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
3466+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
3467+ "license": "MIT"
3468+ },
3469+ "node_modules/path-to-regexp": {
3470+ "version": "8.4.2",
3471+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
3472+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
3473+ "license": "MIT",
3474+ "funding": {
3475+ "type": "opencollective",
3476+ "url": "https://opencollective.com/express"
3477+ }
3478+ },
3479+ "node_modules/performance-now": {
3480+ "version": "2.1.0",
3481+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
3482+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
3483+ "license": "MIT"
3484+ },
3485+ "node_modules/picocolors": {
3486+ "version": "1.1.1",
3487+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
3488+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
3489+ "dev": true,
3490+ "license": "ISC"
3491+ },
3492+ "node_modules/picomatch": {
3493+ "version": "4.0.5",
3494+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
3495+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
3496+ "license": "MIT",
3497+ "engines": {
3498+ "node": ">=12"
3499+ },
3500+ "funding": {
3501+ "url": "https://github.com/sponsors/jonschlinkert"
3502+ }
3503+ },
3504+ "node_modules/postcss": {
3505+ "version": "8.5.23",
3506+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
3507+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
3508+ "dev": true,
3509+ "funding": [
3510+ {
3511+ "type": "opencollective",
3512+ "url": "https://opencollective.com/postcss/"
3513+ },
3514+ {
3515+ "type": "tidelift",
3516+ "url": "https://tidelift.com/funding/github/npm/postcss"
3517+ },
3518+ {
3519+ "type": "github",
3520+ "url": "https://github.com/sponsors/ai"
3521+ }
3522+ ],
3523+ "license": "MIT",
3524+ "dependencies": {
3525+ "nanoid": "^3.3.16",
3526+ "picocolors": "^1.1.1",
3527+ "source-map-js": "^1.2.1"
3528+ },
3529+ "engines": {
3530+ "node": "^10 || ^12 || >=14"
3531+ }
3532+ },
3533+ "node_modules/proxy-addr": {
3534+ "version": "2.0.7",
3535+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
3536+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
3537+ "license": "MIT",
3538+ "dependencies": {
3539+ "forwarded": "0.2.0",
3540+ "ipaddr.js": "1.9.1"
3541+ },
3542+ "engines": {
3543+ "node": ">= 0.10"
3544+ }
3545+ },
3546+ "node_modules/qs": {
3547+ "version": "6.15.3",
3548+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
3549+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
3550+ "license": "BSD-3-Clause",
3551+ "dependencies": {
3552+ "es-define-property": "^1.0.1",
3553+ "side-channel": "^1.1.1"
3554+ },
3555+ "engines": {
3556+ "node": ">=0.6"
3557+ },
3558+ "funding": {
3559+ "url": "https://github.com/sponsors/ljharb"
3560+ }
3561+ },
3562+ "node_modules/raf": {
3563+ "version": "3.4.1",
3564+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
3565+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
3566+ "license": "MIT",
3567+ "dependencies": {
3568+ "performance-now": "^2.1.0"
3569+ }
3570+ },
3571+ "node_modules/raf-loop": {
3572+ "version": "1.1.3",
3573+ "resolved": "https://registry.npmjs.org/raf-loop/-/raf-loop-1.1.3.tgz",
3574+ "integrity": "sha512-fcIuuIdjbD6OB0IFw4d+cjqdrzDorKkIpwOiSnfU4Tht5PTFiJutR8hnCOGslYqZDyIzwpF5WnwbnTTuo9uUUA==",
3575+ "license": "MIT",
3576+ "dependencies": {
3577+ "events": "^1.0.2",
3578+ "inherits": "^2.0.1",
3579+ "raf": "^3.0.0",
3580+ "right-now": "^1.0.0"
3581+ }
3582+ },
3583+ "node_modules/range-parser": {
3584+ "version": "1.3.0",
3585+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
3586+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
3587+ "license": "MIT",
3588+ "engines": {
3589+ "node": ">= 0.6"
3590+ },
3591+ "funding": {
3592+ "type": "opencollective",
3593+ "url": "https://opencollective.com/express"
3594+ }
3595+ },
3596+ "node_modules/raw-body": {
3597+ "version": "3.0.2",
3598+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
3599+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
3600+ "license": "MIT",
3601+ "dependencies": {
3602+ "bytes": "~3.1.2",
3603+ "http-errors": "~2.0.1",
3604+ "iconv-lite": "~0.7.0",
3605+ "unpipe": "~1.0.0"
3606+ },
3607+ "engines": {
3608+ "node": ">= 0.10"
3609+ }
3610+ },
3611+ "node_modules/react": {
3612+ "version": "19.2.8",
3613+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
3614+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
3615+ "license": "MIT",
3616+ "engines": {
3617+ "node": ">=0.10.0"
3618+ }
3619+ },
3620+ "node_modules/react-reconciler": {
3621+ "version": "0.33.0",
3622+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
3623+ "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
3624+ "license": "MIT",
3625+ "dependencies": {
3626+ "scheduler": "^0.27.0"
3627+ },
3628+ "engines": {
3629+ "node": ">=0.10.0"
3630+ },
3631+ "peerDependencies": {
3632+ "react": "^19.2.0"
3633+ }
3634+ },
3635+ "node_modules/readable-stream": {
3636+ "version": "1.1.14",
3637+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
3638+ "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
3639+ "license": "MIT",
3640+ "dependencies": {
3641+ "core-util-is": "~1.0.0",
3642+ "inherits": "~2.0.1",
3643+ "isarray": "0.0.1",
3644+ "string_decoder": "~0.10.x"
3645+ }
3646+ },
3647+ "node_modules/regl": {
3648+ "version": "1.7.0",
3649+ "resolved": "https://registry.npmjs.org/regl/-/regl-1.7.0.tgz",
3650+ "integrity": "sha512-bEAtp/qrtKucxXSJkD4ebopFZYP0q1+3Vb2WECWv/T8yQEgKxDxJ7ztO285tAMaYZVR6mM1GgI6CCn8FROtL1w==",
3651+ "license": "MIT"
3652+ },
3653+ "node_modules/resolve": {
3654+ "version": "1.22.12",
3655+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
3656+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
3657+ "license": "MIT",
3658+ "dependencies": {
3659+ "es-errors": "^1.3.0",
3660+ "is-core-module": "^2.16.1",
3661+ "path-parse": "^1.0.7",
3662+ "supports-preserve-symlinks-flag": "^1.0.0"
3663+ },
3664+ "bin": {
3665+ "resolve": "bin/resolve"
3666+ },
3667+ "engines": {
3668+ "node": ">= 0.4"
3669+ },
3670+ "funding": {
3671+ "url": "https://github.com/sponsors/ljharb"
3672+ }
3673+ },
3674+ "node_modules/restore-cursor": {
3675+ "version": "4.0.0",
3676+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
3677+ "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
3678+ "license": "MIT",
3679+ "dependencies": {
3680+ "onetime": "^5.1.0",
3681+ "signal-exit": "^3.0.2"
3682+ },
3683+ "engines": {
3684+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
3685+ },
3686+ "funding": {
3687+ "url": "https://github.com/sponsors/sindresorhus"
3688+ }
3689+ },
3690+ "node_modules/right-now": {
3691+ "version": "1.0.0",
3692+ "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz",
3693+ "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==",
3694+ "license": "MIT"
3695+ },
3696+ "node_modules/rolldown": {
3697+ "version": "1.1.5",
3698+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
3699+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
3700+ "dev": true,
3701+ "license": "MIT",
3702+ "dependencies": {
3703+ "@oxc-project/types": "=0.139.0",
3704+ "@rolldown/pluginutils": "^1.0.0"
3705+ },
3706+ "bin": {
3707+ "rolldown": "bin/cli.mjs"
3708+ },
3709+ "engines": {
3710+ "node": "^20.19.0 || >=22.12.0"
3711+ },
3712+ "optionalDependencies": {
3713+ "@rolldown/binding-android-arm64": "1.1.5",
3714+ "@rolldown/binding-darwin-arm64": "1.1.5",
3715+ "@rolldown/binding-darwin-x64": "1.1.5",
3716+ "@rolldown/binding-freebsd-x64": "1.1.5",
3717+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
3718+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
3719+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
3720+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
3721+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
3722+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
3723+ "@rolldown/binding-linux-x64-musl": "1.1.5",
3724+ "@rolldown/binding-openharmony-arm64": "1.1.5",
3725+ "@rolldown/binding-wasm32-wasi": "1.1.5",
3726+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
3727+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
3728+ }
3729+ },
3730+ "node_modules/router": {
3731+ "version": "2.2.0",
3732+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
3733+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
3734+ "license": "MIT",
3735+ "dependencies": {
3736+ "debug": "^4.4.0",
3737+ "depd": "^2.0.0",
3738+ "is-promise": "^4.0.0",
3739+ "parseurl": "^1.3.3",
3740+ "path-to-regexp": "^8.0.0"
3741+ },
3742+ "engines": {
3743+ "node": ">= 18"
3744+ }
3745+ },
3746+ "node_modules/rxjs": {
3747+ "version": "7.8.2",
3748+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
3749+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
3750+ "dev": true,
3751+ "license": "Apache-2.0",
3752+ "dependencies": {
3753+ "tslib": "^2.1.0"
3754+ }
3755+ },
3756+ "node_modules/safer-buffer": {
3757+ "version": "2.1.2",
3758+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
3759+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
3760+ "license": "MIT"
3761+ },
3762+ "node_modules/scheduler": {
3763+ "version": "0.27.0",
3764+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
3765+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
3766+ "license": "MIT"
3767+ },
3768+ "node_modules/send": {
3769+ "version": "1.2.1",
3770+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
3771+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
3772+ "license": "MIT",
3773+ "dependencies": {
3774+ "debug": "^4.4.3",
3775+ "encodeurl": "^2.0.0",
3776+ "escape-html": "^1.0.3",
3777+ "etag": "^1.8.1",
3778+ "fresh": "^2.0.0",
3779+ "http-errors": "^2.0.1",
3780+ "mime-types": "^3.0.2",
3781+ "ms": "^2.1.3",
3782+ "on-finished": "^2.4.1",
3783+ "range-parser": "^1.2.1",
3784+ "statuses": "^2.0.2"
3785+ },
3786+ "engines": {
3787+ "node": ">= 18"
3788+ },
3789+ "funding": {
3790+ "type": "opencollective",
3791+ "url": "https://opencollective.com/express"
3792+ }
3793+ },
3794+ "node_modules/serve-static": {
3795+ "version": "2.2.1",
3796+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
3797+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
3798+ "license": "MIT",
3799+ "dependencies": {
3800+ "encodeurl": "^2.0.0",
3801+ "escape-html": "^1.0.3",
3802+ "parseurl": "^1.3.3",
3803+ "send": "^1.2.0"
3804+ },
3805+ "engines": {
3806+ "node": ">= 18"
3807+ },
3808+ "funding": {
3809+ "type": "opencollective",
3810+ "url": "https://opencollective.com/express"
3811+ }
3812+ },
3813+ "node_modules/setprototypeof": {
3814+ "version": "1.2.0",
3815+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
3816+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
3817+ "license": "ISC"
3818+ },
3819+ "node_modules/sfumato": {
3820+ "version": "0.1.2",
3821+ "resolved": "https://registry.npmjs.org/sfumato/-/sfumato-0.1.2.tgz",
3822+ "integrity": "sha512-j2s5BLUS5VUNtaK1l+v+yal3XjjV7JXCQIwE5Xs4yiQ3HJ+2Fc/dd3IkkrVHn0AJO2epShSWVoP3GnE0TvPdMg==",
3823+ "license": "ISC",
3824+ "dependencies": {
3825+ "soundfont2": "^0.4.0"
3826+ }
3827+ },
3828+ "node_modules/sfumato/node_modules/soundfont2": {
3829+ "version": "0.4.0",
3830+ "resolved": "https://registry.npmjs.org/soundfont2/-/soundfont2-0.4.0.tgz",
3831+ "integrity": "sha512-537WiurDBRbDLVhJMxXLE06D6yWxJCidfPClnibZ0f8dKMDpv+0fIfwCQ8pELE0JqKX05SOJosNJgKzQobaAEA==",
3832+ "license": "MIT"
3833+ },
3834+ "node_modules/shell-quote": {
3835+ "version": "1.9.0",
3836+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
3837+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
3838+ "dev": true,
3839+ "license": "MIT",
3840+ "engines": {
3841+ "node": ">= 0.4"
3842+ },
3843+ "funding": {
3844+ "url": "https://github.com/sponsors/ljharb"
3845+ }
3846+ },
3847+ "node_modules/side-channel": {
3848+ "version": "1.1.1",
3849+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
3850+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
3851+ "license": "MIT",
3852+ "dependencies": {
3853+ "es-errors": "^1.3.0",
3854+ "object-inspect": "^1.13.4",
3855+ "side-channel-list": "^1.0.1",
3856+ "side-channel-map": "^1.0.1",
3857+ "side-channel-weakmap": "^1.0.2"
3858+ },
3859+ "engines": {
3860+ "node": ">= 0.4"
3861+ },
3862+ "funding": {
3863+ "url": "https://github.com/sponsors/ljharb"
3864+ }
3865+ },
3866+ "node_modules/side-channel-list": {
3867+ "version": "1.0.1",
3868+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
3869+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
3870+ "license": "MIT",
3871+ "dependencies": {
3872+ "es-errors": "^1.3.0",
3873+ "object-inspect": "^1.13.4"
3874+ },
3875+ "engines": {
3876+ "node": ">= 0.4"
3877+ },
3878+ "funding": {
3879+ "url": "https://github.com/sponsors/ljharb"
3880+ }
3881+ },
3882+ "node_modules/side-channel-map": {
3883+ "version": "1.0.1",
3884+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
3885+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
3886+ "license": "MIT",
3887+ "dependencies": {
3888+ "call-bound": "^1.0.2",
3889+ "es-errors": "^1.3.0",
3890+ "get-intrinsic": "^1.2.5",
3891+ "object-inspect": "^1.13.3"
3892+ },
3893+ "engines": {
3894+ "node": ">= 0.4"
3895+ },
3896+ "funding": {
3897+ "url": "https://github.com/sponsors/ljharb"
3898+ }
3899+ },
3900+ "node_modules/side-channel-weakmap": {
3901+ "version": "1.0.2",
3902+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
3903+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
3904+ "license": "MIT",
3905+ "dependencies": {
3906+ "call-bound": "^1.0.2",
3907+ "es-errors": "^1.3.0",
3908+ "get-intrinsic": "^1.2.5",
3909+ "object-inspect": "^1.13.3",
3910+ "side-channel-map": "^1.0.1"
3911+ },
3912+ "engines": {
3913+ "node": ">= 0.4"
3914+ },
3915+ "funding": {
3916+ "url": "https://github.com/sponsors/ljharb"
3917+ }
3918+ },
3919+ "node_modules/signal-exit": {
3920+ "version": "3.0.7",
3921+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
3922+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
3923+ "license": "ISC"
3924+ },
3925+ "node_modules/slice-ansi": {
3926+ "version": "9.0.0",
3927+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
3928+ "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==",
3929+ "license": "MIT",
3930+ "dependencies": {
3931+ "ansi-styles": "^6.2.3",
3932+ "is-fullwidth-code-point": "^5.1.0"
3933+ },
3934+ "engines": {
3935+ "node": ">=22"
3936+ },
3937+ "funding": {
3938+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
3939+ }
3940+ },
3941+ "node_modules/soundfont2": {
3942+ "version": "0.5.0",
3943+ "resolved": "https://registry.npmjs.org/soundfont2/-/soundfont2-0.5.0.tgz",
3944+ "integrity": "sha512-dcmNVtHT/Y8BOOrtmjt7hn6Bk6bB1g+O2bWB9fa6emW7kfwiEiEL4VvGQfwVt8g0m58LyoqVyuQ4ZFukMLwGHQ==",
3945+ "license": "MIT"
3946+ },
3947+ "node_modules/source-map": {
3948+ "version": "0.6.1",
3949+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
3950+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
3951+ "license": "BSD-3-Clause",
3952+ "optional": true,
3953+ "engines": {
3954+ "node": ">=0.10.0"
3955+ }
3956+ },
3957+ "node_modules/source-map-js": {
3958+ "version": "1.2.1",
3959+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
3960+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
3961+ "dev": true,
3962+ "license": "BSD-3-Clause",
3963+ "engines": {
3964+ "node": ">=0.10.0"
3965+ }
3966+ },
3967+ "node_modules/stack-utils": {
3968+ "version": "2.0.6",
3969+ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
3970+ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
3971+ "license": "MIT",
3972+ "dependencies": {
3973+ "escape-string-regexp": "^2.0.0"
3974+ },
3975+ "engines": {
3976+ "node": ">=10"
3977+ }
3978+ },
3979+ "node_modules/standardwebhooks": {
3980+ "version": "1.0.0",
3981+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
3982+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
3983+ "license": "MIT",
3984+ "dependencies": {
3985+ "@stablelib/base64": "^1.0.0",
3986+ "fast-sha256": "^1.3.0"
3987+ }
3988+ },
3989+ "node_modules/statuses": {
3990+ "version": "2.0.2",
3991+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
3992+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
3993+ "license": "MIT",
3994+ "engines": {
3995+ "node": ">= 0.8"
3996+ }
3997+ },
3998+ "node_modules/stream-parser": {
3999+ "version": "0.3.1",
4000+ "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
4001+ "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==",
4002+ "license": "MIT",
4003+ "dependencies": {
4004+ "debug": "2"
4005+ }
4006+ },
4007+ "node_modules/stream-parser/node_modules/debug": {
4008+ "version": "2.6.9",
4009+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
4010+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
4011+ "license": "MIT",
4012+ "dependencies": {
4013+ "ms": "2.0.0"
4014+ }
4015+ },
4016+ "node_modules/stream-parser/node_modules/ms": {
4017+ "version": "2.0.0",
4018+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
4019+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
4020+ "license": "MIT"
4021+ },
4022+ "node_modules/string_decoder": {
4023+ "version": "0.10.31",
4024+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
4025+ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
4026+ "license": "MIT"
4027+ },
4028+ "node_modules/string-width": {
4029+ "version": "7.2.0",
4030+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
4031+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
4032+ "dev": true,
4033+ "license": "MIT",
4034+ "dependencies": {
4035+ "emoji-regex": "^10.3.0",
4036+ "get-east-asian-width": "^1.0.0",
4037+ "strip-ansi": "^7.1.0"
4038+ },
4039+ "engines": {
4040+ "node": ">=18"
4041+ },
4042+ "funding": {
4043+ "url": "https://github.com/sponsors/sindresorhus"
4044+ }
4045+ },
4046+ "node_modules/strip-ansi": {
4047+ "version": "7.2.0",
4048+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
4049+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
4050+ "license": "MIT",
4051+ "dependencies": {
4052+ "ansi-regex": "^6.2.2"
4053+ },
4054+ "engines": {
4055+ "node": ">=12"
4056+ },
4057+ "funding": {
4058+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
4059+ }
4060+ },
4061+ "node_modules/style-mod": {
4062+ "version": "4.1.3",
4063+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
4064+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
4065+ "license": "MIT"
4066+ },
4067+ "node_modules/superdough": {
4068+ "version": "1.3.0",
4069+ "resolved": "https://registry.npmjs.org/superdough/-/superdough-1.3.0.tgz",
4070+ "integrity": "sha512-LhmsrErcv9EMFNb79+3tvn6h7WgzKp4yRe1XtT2+eRY9oEpV4Jz0nb9Dxil3OLaNhQ6V2FuwPWBLQJb2aVtwuQ==",
4071+ "license": "AGPL-3.0-or-later",
4072+ "dependencies": {
4073+ "@kabelsalat/lib": "^0.4.1",
4074+ "nanostores": "^0.11.3"
4075+ },
4076+ "engines": {
4077+ "node": ">=18.0.0"
4078+ }
4079+ },
4080+ "node_modules/supports-color": {
4081+ "version": "10.2.2",
4082+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
4083+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
4084+ "dev": true,
4085+ "license": "MIT",
4086+ "engines": {
4087+ "node": ">=18"
4088+ },
4089+ "funding": {
4090+ "url": "https://github.com/chalk/supports-color?sponsor=1"
4091+ }
4092+ },
4093+ "node_modules/supports-preserve-symlinks-flag": {
4094+ "version": "1.0.0",
4095+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
4096+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
4097+ "license": "MIT",
4098+ "engines": {
4099+ "node": ">= 0.4"
4100+ },
4101+ "funding": {
4102+ "url": "https://github.com/sponsors/ljharb"
4103+ }
4104+ },
4105+ "node_modules/supradough": {
4106+ "version": "1.2.4",
4107+ "resolved": "https://registry.npmjs.org/supradough/-/supradough-1.2.4.tgz",
4108+ "integrity": "sha512-/v3cL9fq05QCQVwbttLHNZcjyyFFz5wdKvg3j6V6moRXpDlQG89l8dQtUIpQvHsxYI5sXNod9rh5bf+Y4NMGeA==",
4109+ "license": "AGPL-3.0-or-later"
4110+ },
4111+ "node_modules/tagged-tag": {
4112+ "version": "1.0.0",
4113+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
4114+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
4115+ "license": "MIT",
4116+ "engines": {
4117+ "node": ">=20"
4118+ },
4119+ "funding": {
4120+ "url": "https://github.com/sponsors/sindresorhus"
4121+ }
4122+ },
4123+ "node_modules/terminal-size": {
4124+ "version": "4.0.1",
4125+ "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz",
4126+ "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==",
4127+ "license": "MIT",
4128+ "engines": {
4129+ "node": ">=18"
4130+ },
4131+ "funding": {
4132+ "url": "https://github.com/sponsors/sindresorhus"
4133+ }
4134+ },
4135+ "node_modules/tinyglobby": {
4136+ "version": "0.2.17",
4137+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
4138+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
4139+ "dev": true,
4140+ "license": "MIT",
4141+ "dependencies": {
4142+ "fdir": "^6.5.0",
4143+ "picomatch": "^4.0.4"
4144+ },
4145+ "engines": {
4146+ "node": ">=12.0.0"
4147+ },
4148+ "funding": {
4149+ "url": "https://github.com/sponsors/SuperchupuDev"
4150+ }
4151+ },
4152+ "node_modules/toidentifier": {
4153+ "version": "1.0.1",
4154+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
4155+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
4156+ "license": "MIT",
4157+ "engines": {
4158+ "node": ">=0.6"
4159+ }
4160+ },
4161+ "node_modules/tree-kill": {
4162+ "version": "1.2.2",
4163+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
4164+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
4165+ "dev": true,
4166+ "license": "MIT",
4167+ "bin": {
4168+ "tree-kill": "cli.js"
4169+ }
4170+ },
4171+ "node_modules/ts-algebra": {
4172+ "version": "2.0.0",
4173+ "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
4174+ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
4175+ "license": "MIT"
4176+ },
4177+ "node_modules/tslib": {
4178+ "version": "2.8.1",
4179+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
4180+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
4181+ "dev": true,
4182+ "license": "0BSD"
4183+ },
4184+ "node_modules/type-fest": {
4185+ "version": "5.8.0",
4186+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
4187+ "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
4188+ "license": "(MIT OR CC0-1.0)",
4189+ "dependencies": {
4190+ "tagged-tag": "^1.0.0"
4191+ },
4192+ "engines": {
4193+ "node": ">=20"
4194+ },
4195+ "funding": {
4196+ "url": "https://github.com/sponsors/sindresorhus"
4197+ }
4198+ },
4199+ "node_modules/type-is": {
4200+ "version": "2.1.0",
4201+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
4202+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
4203+ "license": "MIT",
4204+ "dependencies": {
4205+ "content-type": "^2.0.0",
4206+ "media-typer": "^1.1.0",
4207+ "mime-types": "^3.0.0"
4208+ },
4209+ "engines": {
4210+ "node": ">= 18"
4211+ },
4212+ "funding": {
4213+ "type": "opencollective",
4214+ "url": "https://opencollective.com/express"
4215+ }
4216+ },
4217+ "node_modules/type-is/node_modules/content-type": {
4218+ "version": "2.0.0",
4219+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
4220+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
4221+ "license": "MIT",
4222+ "engines": {
4223+ "node": ">=18"
4224+ },
4225+ "funding": {
4226+ "type": "opencollective",
4227+ "url": "https://opencollective.com/express"
4228+ }
4229+ },
4230+ "node_modules/typescript": {
4231+ "version": "7.0.2",
4232+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
4233+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
4234+ "dev": true,
4235+ "license": "Apache-2.0",
4236+ "bin": {
4237+ "tsc": "bin/tsc"
4238+ },
4239+ "engines": {
4240+ "node": ">=16.20.0"
4241+ },
4242+ "optionalDependencies": {
4243+ "@typescript/typescript-aix-ppc64": "7.0.2",
4244+ "@typescript/typescript-darwin-arm64": "7.0.2",
4245+ "@typescript/typescript-darwin-x64": "7.0.2",
4246+ "@typescript/typescript-freebsd-arm64": "7.0.2",
4247+ "@typescript/typescript-freebsd-x64": "7.0.2",
4248+ "@typescript/typescript-linux-arm": "7.0.2",
4249+ "@typescript/typescript-linux-arm64": "7.0.2",
4250+ "@typescript/typescript-linux-loong64": "7.0.2",
4251+ "@typescript/typescript-linux-mips64el": "7.0.2",
4252+ "@typescript/typescript-linux-ppc64": "7.0.2",
4253+ "@typescript/typescript-linux-riscv64": "7.0.2",
4254+ "@typescript/typescript-linux-s390x": "7.0.2",
4255+ "@typescript/typescript-linux-x64": "7.0.2",
4256+ "@typescript/typescript-netbsd-arm64": "7.0.2",
4257+ "@typescript/typescript-netbsd-x64": "7.0.2",
4258+ "@typescript/typescript-openbsd-arm64": "7.0.2",
4259+ "@typescript/typescript-openbsd-x64": "7.0.2",
4260+ "@typescript/typescript-sunos-x64": "7.0.2",
4261+ "@typescript/typescript-win32-arm64": "7.0.2",
4262+ "@typescript/typescript-win32-x64": "7.0.2"
4263+ }
4264+ },
4265+ "node_modules/undici-types": {
4266+ "version": "8.3.0",
4267+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
4268+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
4269+ "dev": true,
4270+ "license": "MIT"
4271+ },
4272+ "node_modules/unpipe": {
4273+ "version": "1.0.0",
4274+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
4275+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
4276+ "license": "MIT",
4277+ "engines": {
4278+ "node": ">= 0.8"
4279+ }
4280+ },
4281+ "node_modules/vary": {
4282+ "version": "1.1.2",
4283+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
4284+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
4285+ "license": "MIT",
4286+ "engines": {
4287+ "node": ">= 0.8"
4288+ }
4289+ },
4290+ "node_modules/vite": {
4291+ "version": "8.1.5",
4292+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
4293+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
4294+ "dev": true,
4295+ "license": "MIT",
4296+ "dependencies": {
4297+ "lightningcss": "^1.32.0",
4298+ "picomatch": "^4.0.5",
4299+ "postcss": "^8.5.17",
4300+ "rolldown": "~1.1.5",
4301+ "tinyglobby": "^0.2.17"
4302+ },
4303+ "bin": {
4304+ "vite": "bin/vite.js"
4305+ },
4306+ "engines": {
4307+ "node": "^20.19.0 || >=22.12.0"
4308+ },
4309+ "funding": {
4310+ "url": "https://github.com/vitejs/vite?sponsor=1"
4311+ },
4312+ "optionalDependencies": {
4313+ "fsevents": "~2.3.3"
4314+ },
4315+ "peerDependencies": {
4316+ "@types/node": "^20.19.0 || >=22.12.0",
4317+ "@vitejs/devtools": "^0.3.0",
4318+ "esbuild": "^0.27.0 || ^0.28.0",
4319+ "jiti": ">=1.21.0",
4320+ "less": "^4.0.0",
4321+ "sass": "^1.70.0",
4322+ "sass-embedded": "^1.70.0",
4323+ "stylus": ">=0.54.8",
4324+ "sugarss": "^5.0.0",
4325+ "terser": "^5.16.0",
4326+ "tsx": "^4.8.1",
4327+ "yaml": "^2.4.2"
4328+ },
4329+ "peerDependenciesMeta": {
4330+ "@types/node": {
4331+ "optional": true
4332+ },
4333+ "@vitejs/devtools": {
4334+ "optional": true
4335+ },
4336+ "esbuild": {
4337+ "optional": true
4338+ },
4339+ "jiti": {
4340+ "optional": true
4341+ },
4342+ "less": {
4343+ "optional": true
4344+ },
4345+ "sass": {
4346+ "optional": true
4347+ },
4348+ "sass-embedded": {
4349+ "optional": true
4350+ },
4351+ "stylus": {
4352+ "optional": true
4353+ },
4354+ "sugarss": {
4355+ "optional": true
4356+ },
4357+ "terser": {
4358+ "optional": true
4359+ },
4360+ "tsx": {
4361+ "optional": true
4362+ },
4363+ "yaml": {
4364+ "optional": true
4365+ }
4366+ }
4367+ },
4368+ "node_modules/w3c-keyname": {
4369+ "version": "2.2.8",
4370+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
4371+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
4372+ "license": "MIT"
4373+ },
4374+ "node_modules/wav": {
4375+ "version": "1.0.2",
4376+ "resolved": "https://registry.npmjs.org/wav/-/wav-1.0.2.tgz",
4377+ "integrity": "sha512-viHtz3cDd/Tcr/HbNqzQCofKdF6kWUymH9LGDdskfWFoIy/HJ+RTihgjEcHfnsy1PO4e9B+y4HwgTwMrByquhg==",
4378+ "license": "MIT",
4379+ "dependencies": {
4380+ "buffer-alloc": "^1.1.0",
4381+ "buffer-from": "^1.0.0",
4382+ "debug": "^2.2.0",
4383+ "readable-stream": "^1.1.14",
4384+ "stream-parser": "^0.3.1"
4385+ }
4386+ },
4387+ "node_modules/wav/node_modules/debug": {
4388+ "version": "2.6.9",
4389+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
4390+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
4391+ "license": "MIT",
4392+ "dependencies": {
4393+ "ms": "2.0.0"
4394+ }
4395+ },
4396+ "node_modules/wav/node_modules/ms": {
4397+ "version": "2.0.0",
4398+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
4399+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
4400+ "license": "MIT"
4401+ },
4402+ "node_modules/web-streams-polyfill": {
4403+ "version": "3.3.3",
4404+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
4405+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
4406+ "license": "MIT",
4407+ "engines": {
4408+ "node": ">= 8"
4409+ }
4410+ },
4411+ "node_modules/webidl-conversions": {
4412+ "version": "8.0.1",
4413+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
4414+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
4415+ "license": "BSD-2-Clause",
4416+ "engines": {
4417+ "node": ">=20"
4418+ }
4419+ },
4420+ "node_modules/webmidi": {
4421+ "version": "3.1.16",
4422+ "resolved": "https://registry.npmjs.org/webmidi/-/webmidi-3.1.16.tgz",
4423+ "integrity": "sha512-I6NnCfBUXNbV8q6EU+DPtr1u1EgjzDrFKHQQS2OgaHzcuQRFhRHgogfKpKSpxD0IgXhZHz8+k/OpGfZoWbvWAQ==",
4424+ "license": "Apache-2.0",
4425+ "dependencies": {
4426+ "djipevents": "^2.0.7"
4427+ },
4428+ "engines": {
4429+ "node": ">=8.5"
4430+ },
4431+ "optionalDependencies": {
4432+ "jzz": "^1.8.5"
4433+ }
4434+ },
4435+ "node_modules/widest-line": {
4436+ "version": "6.0.0",
4437+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz",
4438+ "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==",
4439+ "license": "MIT",
4440+ "dependencies": {
4441+ "string-width": "^8.1.0"
4442+ },
4443+ "engines": {
4444+ "node": ">=20"
4445+ },
4446+ "funding": {
4447+ "url": "https://github.com/sponsors/sindresorhus"
4448+ }
4449+ },
4450+ "node_modules/widest-line/node_modules/string-width": {
4451+ "version": "8.2.2",
4452+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
4453+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
4454+ "license": "MIT",
4455+ "dependencies": {
4456+ "get-east-asian-width": "^1.5.0",
4457+ "strip-ansi": "^7.1.2"
4458+ },
4459+ "engines": {
4460+ "node": ">=20"
4461+ },
4462+ "funding": {
4463+ "url": "https://github.com/sponsors/sindresorhus"
4464+ }
4465+ },
4466+ "node_modules/wrap-ansi": {
4467+ "version": "9.0.2",
4468+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
4469+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
4470+ "dev": true,
4471+ "license": "MIT",
4472+ "dependencies": {
4473+ "ansi-styles": "^6.2.1",
4474+ "string-width": "^7.0.0",
4475+ "strip-ansi": "^7.1.0"
4476+ },
4477+ "engines": {
4478+ "node": ">=18"
4479+ },
4480+ "funding": {
4481+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
4482+ }
4483+ },
4484+ "node_modules/wrappy": {
4485+ "version": "1.0.2",
4486+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
4487+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
4488+ "license": "ISC"
4489+ },
4490+ "node_modules/ws": {
4491+ "version": "8.21.2",
4492+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
4493+ "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
4494+ "license": "MIT",
4495+ "engines": {
4496+ "node": ">=10.0.0"
4497+ },
4498+ "peerDependencies": {
4499+ "bufferutil": "^4.0.1",
4500+ "utf-8-validate": ">=5.0.2"
4501+ },
4502+ "peerDependenciesMeta": {
4503+ "bufferutil": {
4504+ "optional": true
4505+ },
4506+ "utf-8-validate": {
4507+ "optional": true
4508+ }
4509+ }
4510+ },
4511+ "node_modules/y18n": {
4512+ "version": "5.0.8",
4513+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
4514+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
4515+ "dev": true,
4516+ "license": "ISC",
4517+ "engines": {
4518+ "node": ">=10"
4519+ }
4520+ },
4521+ "node_modules/yargs": {
4522+ "version": "18.0.0",
4523+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
4524+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
4525+ "dev": true,
4526+ "license": "MIT",
4527+ "dependencies": {
4528+ "cliui": "^9.0.1",
4529+ "escalade": "^3.1.1",
4530+ "get-caller-file": "^2.0.5",
4531+ "string-width": "^7.2.0",
4532+ "y18n": "^5.0.5",
4533+ "yargs-parser": "^22.0.0"
4534+ },
4535+ "engines": {
4536+ "node": "^20.19.0 || ^22.12.0 || >=23"
4537+ }
4538+ },
4539+ "node_modules/yargs-parser": {
4540+ "version": "22.0.0",
4541+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
4542+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
4543+ "dev": true,
4544+ "license": "ISC",
4545+ "engines": {
4546+ "node": "^20.19.0 || ^22.12.0 || >=23"
4547+ }
4548+ },
4549+ "node_modules/yoga-layout": {
4550+ "version": "3.2.1",
4551+ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
4552+ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
4553+ "license": "MIT"
4554+ }
4555+ }
4556+}
addedpackage.json+45 −0
1+{
2+ "name": "strudel-claude",
3+ "version": "1.0.0",
4+ "description": "",
5+ "main": "index.js",
6+ "scripts": {
7+ "test": "echo \"Error: no test specified\" && exit 1",
8+ "dev": "concurrently -n web,api -c cyan,magenta \"vite\" \"node --env-file-if-exists=.env server/index.js\"",
9+ "web": "vite",
10+ "api": "node --env-file-if-exists=.env server/index.js",
11+ "tui": "python3 tui/strudel-tui.py",
12+ "build": "vite build",
13+ "start": "vite build && node --env-file-if-exists=.env server/index.js",
14+ "node-tui": "node --env-file-if-exists=.env node-tui/boot.ts",
15+ "typecheck:tui": "tsc --noEmit -p node-tui/tsconfig.json",
16+ "test:tui": "node --test node-tui/*.test.ts"
17+ },
18+ "keywords": [],
19+ "author": "",
20+ "license": "ISC",
21+ "type": "module",
22+ "dependencies": {
23+ "@anthropic-ai/sdk": "^0.115.0",
24+ "@codemirror/lang-javascript": "^6.2.5",
25+ "@codemirror/state": "^6.7.1",
26+ "@codemirror/theme-one-dark": "^6.1.3",
27+ "@codemirror/view": "^6.43.6",
28+ "@strudel/repl": "^1.3.0",
29+ "@strudel/web": "^1.3.0",
30+ "codemirror": "^6.0.2",
31+ "express": "^5.2.1",
32+ "htm": "^3.1.1",
33+ "ink": "^7.1.1",
34+ "node-web-audio-api": "^2.2.0",
35+ "react": "^19.2.8",
36+ "ws": "^8.21.2"
37+ },
38+ "devDependencies": {
39+ "@types/node": "^26.2.0",
40+ "@types/react": "^19.2.18",
41+ "concurrently": "^10.0.4",
42+ "typescript": "^7.0.2",
43+ "vite": "^8.1.5"
44+ }
45+}
addedserver/broadcast.js+196 −0
1+// Timing broadcast hub.
2+//
3+// The browser tab that owns the audio is the *producer*: it pushes the current
4+// source code and, a few times a second, the window of upcoming events (haps)
5+// with their character ranges in that source. Everyone else is a *viewer* —
6+// the terminal client, another browser, whatever — and just receives.
7+//
8+// Viewers animate from their own clock rather than from a per-frame feed: each
9+// `haps` message carries `now` (the cycle position at send time) and `cps`, so
10+// a viewer can advance the playhead locally at whatever framerate it likes and
11+// stay smooth across a laggy link. See tui/strudel-tui.py for a consumer.
12+import { WebSocketServer } from 'ws';
13+
14+// The last thing each kind of state message said, replayed to anyone who joins
15+// mid-stream so a fresh viewer isn't staring at a blank screen until the next
16+// eval. `haps` is deliberately absent — stale event windows are worse than none.
17+const snapshot = {
18+ code: null,
19+ transport: null,
20+ chat: null,
21+};
22+
23+// Viewers are receive-only with exactly one exception: they may ask Claude for
24+// a pattern, which is the point of the terminal's prompt line. Nothing else
25+// they send is relayed, so a viewer cannot drive the editor, the transport, or
26+// anyone else's screen.
27+const MAX_PROMPT = 4000;
28+
29+const viewers = new Set();
30+
31+// Exactly one producer is live at a time; any other open tab waits its turn in
32+// `standby` and stays silent. The incumbent keeps the slot rather than being
33+// kicked out by whoever connected last — an earlier version handed the slot to
34+// the newest tab and closed the old one, which reconnected 500ms later and
35+// took it back, so two open tabs swapped the terminal's view twice a second
36+// forever. A slot that only changes hands when it is actually free cannot
37+// oscillate.
38+let producer = null;
39+let producerSeen = 0;
40+const standby = new Set();
41+
42+// How long an incumbent may go quiet before a newcomer may take over. The
43+// producer ticks every 500ms, so this is several missed beats — long enough
44+// not to trip on a slow frame, short enough that reloading a page doesn't
45+// leave the new tab waiting on a socket the old one never cleanly closed.
46+const STALE_MS = 3000;
47+
48+function send(ws, msg) {
49+ if (ws.readyState === 1) ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
50+}
51+
52+function fanout(raw) {
53+ for (const ws of viewers) send(ws, raw);
54+}
55+
56+function announceSource() {
57+ fanout(JSON.stringify({
58+ type: 'source',
59+ connected: Boolean(producer),
60+ standby: standby.size,
61+ }));
62+}
63+
64+// Hand the slot to `ws`. Whatever the previous producer said described *its*
65+// session, so the snapshot goes with it — keeping it would pair a dead tab's
66+// transcript with the new tab's code, which reads as real and isn't.
67+function promote(ws) {
68+ producer = ws;
69+ producerSeen = Date.now();
70+ standby.delete(ws);
71+ snapshot.code = snapshot.transport = snapshot.chat = null;
72+ send(ws, { type: 'active' });
73+ announceSource();
74+}
75+
76+export function attachBroadcast(server, { path = '/ws' } = {}) {
77+ const wss = new WebSocketServer({ server, path });
78+
79+ wss.on('connection', (ws, req) => {
80+ // Role comes from the query string so it is known before the first message:
81+ // /ws?role=producer from the app, anything else is a viewer.
82+ const role = new URL(req.url, 'http://x').searchParams.get('role') === 'producer'
83+ ? 'producer'
84+ : 'viewer';
85+ ws.__role = role;
86+
87+ if (role === 'producer') {
88+ const held = producer && producer !== ws && producer.readyState === 1
89+ && Date.now() - producerSeen < STALE_MS;
90+ if (held) {
91+ // Someone else is live. Wait, quietly — `standby` tells the page to
92+ // stop sending, and it gets promoted if the incumbent goes away.
93+ standby.add(ws);
94+ send(ws, { type: 'standby' });
95+ announceSource();
96+ } else {
97+ // The incumbent is gone or has stopped talking (a tab closed without a
98+ // clean close, usually). Park it and take over.
99+ if (producer && producer !== ws) {
100+ standby.add(producer);
101+ send(producer, { type: 'standby' });
102+ }
103+ promote(ws);
104+ }
105+ } else {
106+ viewers.add(ws);
107+ send(ws, {
108+ type: 'hello',
109+ viewers: viewers.size,
110+ source: Boolean(producer),
111+ standby: standby.size,
112+ });
113+ if (snapshot.code) send(ws, snapshot.code);
114+ if (snapshot.transport) send(ws, snapshot.transport);
115+ if (snapshot.chat) send(ws, snapshot.chat);
116+ // The snapshot only covers what this hub has seen the producer say. Ask
117+ // it to re-announce everything as well, so a viewer joining a session
118+ // that has been quiet — or one whose state this hub predates — gets a
119+ // full picture instead of waiting for the next change.
120+ if (producer?.readyState === 1) send(producer, { type: 'refresh' });
121+ }
122+
123+ ws.on('message', (data) => {
124+ const raw = data.toString();
125+ let msg;
126+ try {
127+ msg = JSON.parse(raw);
128+ } catch {
129+ return;
130+ }
131+
132+ if (role !== 'producer') {
133+ // See MAX_PROMPT above: a prompt is the only thing a viewer may send.
134+ if (msg.type !== 'prompt' || typeof msg.text !== 'string') return;
135+ const text = msg.text.slice(0, MAX_PROMPT).trim();
136+ if (!text) return;
137+ if (producer?.readyState === 1) send(producer, { type: 'prompt', text });
138+ else send(ws, { type: 'notice', text: 'no browser is connected to send that to' });
139+ return;
140+ }
141+
142+ // A tab that is on standby has been told to stop, but may have had a
143+ // message already in flight. Only the live producer reaches the viewers.
144+ if (ws !== producer) return;
145+ producerSeen = Date.now();
146+
147+ // Keep the join-snapshot current, but relay the original bytes — no
148+ // reserialization of the hot `haps` path.
149+ if (msg.type === 'code') snapshot.code = raw;
150+ else if (msg.type === 'transport') snapshot.transport = raw;
151+ else if (msg.type === 'chat') snapshot.chat = raw;
152+ fanout(raw);
153+ });
154+
155+ const drop = () => {
156+ viewers.delete(ws);
157+ standby.delete(ws);
158+ if (producer !== ws) {
159+ if (role === 'producer') announceSource(); // the standby count changed
160+ return;
161+ }
162+ producer = null;
163+ snapshot.transport = null;
164+ // Hand the slot to whoever is still waiting — most recent first, since
165+ // that is the tab most likely to be the one in use.
166+ const next = [...standby].reverse().find((c) => c.readyState === 1);
167+ if (next) promote(next);
168+ else announceSource();
169+ };
170+
171+ ws.on('close', drop);
172+ ws.on('error', drop);
173+ });
174+
175+ // Drop half-open connections (laptop closed, tunnel died) so `viewers` and
176+ // the producer slot reflect reality.
177+ const heartbeat = setInterval(() => {
178+ for (const ws of wss.clients) {
179+ if (ws.__alive === false) {
180+ ws.terminate();
181+ continue;
182+ }
183+ ws.__alive = false;
184+ ws.ping();
185+ }
186+ }, 30_000);
187+ wss.on('connection', (ws) => {
188+ ws.__alive = true;
189+ ws.on('pong', () => {
190+ ws.__alive = true;
191+ });
192+ });
193+ wss.on('close', () => clearInterval(heartbeat));
194+
195+ return wss;
196+}
addedserver/index.js+360 −0
1+import express from 'express';
2+import Anthropic from '@anthropic-ai/sdk';
3+import http from 'node:http';
4+import path from 'node:path';
5+import fs from 'node:fs';
6+import { fileURLToPath } from 'node:url';
7+import { attachBroadcast } from './broadcast.js';
8+
9+const __dirname = path.dirname(fileURLToPath(import.meta.url));
10+const DIST_DIR = path.join(__dirname, '..', 'dist');
11+const PROMPT_PATH = path.join(__dirname, '..', 'PROMPT.md');
12+const TUI_PATH = path.join(__dirname, '..', 'tui', 'strudel-tui.py');
13+
14+const app = express();
15+app.use(express.json({ limit: '1mb' }));
16+
17+// Resolves ANTHROPIC_API_KEY from the environment (or an `ant auth login`
18+// profile). The key stays server-side and never reaches the browser. It is
19+// optional: users can bring their own key from the app instead, in which case
20+// it arrives per request in the x-anthropic-key header and is used for that
21+// request only — never logged, never stored.
22+let serverClient = null;
23+try {
24+ serverClient = new Anthropic();
25+} catch {
26+ console.log(' No server-side key — the app will ask each user for their own.');
27+}
28+
29+const clientFor = (key) => (key ? new Anthropic({ apiKey: key }) : serverClient);
30+
31+// The models this app is allowed to use. The client chooses by id from this
32+// list and nothing else: an id that isn't here is refused rather than quietly
33+// swapped for a default, so a hand-edited request can't point the backend at an
34+// arbitrary (or expensive) model. Adding one is a deploy, not a client change.
35+//
36+// `blurb` is user-facing — it goes in the picker. `thinks` and `effort` are the
37+// per-model API differences, not preferences; see where the request is built.
38+const MODELS = [
39+ {
40+ id: 'claude-opus-4-8',
41+ label: 'Opus 4.8',
42+ blurb: 'The default. Richest, most musical patterns.',
43+ },
44+ {
45+ id: 'claude-opus-5',
46+ label: 'Opus 5',
47+ blurb: 'Newest and strongest, but it thinks first — expect a longer wait.',
48+ // Thinking is on by default on this one and is billed against max_tokens,
49+ // so it needs headroom for the reasoning as well as the pattern.
50+ thinks: true,
51+ },
52+ {
53+ id: 'claude-sonnet-5',
54+ label: 'Sonnet 5',
55+ blurb: 'Close behind Opus and noticeably quicker.',
56+ },
57+ {
58+ id: 'claude-haiku-4-5',
59+ label: 'Haiku 4.5',
60+ blurb: 'Fastest. Good for small tweaks to a pattern that already works.',
61+ // Haiku 4.5 rejects output_config.effort outright — sending it is a 400.
62+ effort: false,
63+ // Its minimum cacheable prefix is 4096 tokens. The PROMPT.md system prompt
64+ // clears that on its own, so unlike the old inline primer it does get cached
65+ // on this model too.
66+ },
67+];
68+
69+const DEFAULT_MODEL = MODELS[0].id;
70+const modelById = new Map(MODELS.map((m) => [m.id, m]));
71+
72+// Lets the app tell the user whether a key of their own is required, and which
73+// models it may offer. Only the display fields go out — the capability flags are
74+// the server's business.
75+app.get('/api/config', (_req, res) => {
76+ res.json({
77+ hasServerKey: Boolean(serverClient),
78+ models: MODELS.map(({ id, label, blurb }) => ({ id, label, blurb })),
79+ defaultModel: DEFAULT_MODEL,
80+ });
81+});
82+
83+// The default system prompt, so the app can show it and offer it back as the
84+// "reset" state. A user's own edits live in their browser and ride along with
85+// the request — the file on disk is the server's, and stays untouched.
86+app.get('/api/prompt', (_req, res) => {
87+ res.json({ prompt: SYSTEM });
88+});
89+
90+// The terminal visualiser, served as source. It is a single stdlib-only Python
91+// file on purpose: `curl <host>/tui.py | less` to read it, `> strudel-tui.py`
92+// to keep it, `python3 strudel-tui.py <host>` to run it. text/plain so a
93+// browser shows it instead of downloading it.
94+app.get(['/tui.py', '/strudel-tui.py'], (_req, res) => {
95+ fs.readFile(TUI_PATH, 'utf8', (err, src) => {
96+ if (err) return res.status(404).type('text/plain').send('# tui/strudel-tui.py not found\n');
97+ res.type('text/plain; charset=utf-8').send(src);
98+ });
99+});
100+
101+// Thinking is off on this model unless asked for, but `effort` still governs how
102+// much the model deliberates and how many tokens it spends. The default is
103+// "high"; a four-bar drum pattern does not need it, and dropping a level is the
104+// cheapest latency win available.
105+const EFFORT = process.env.STRUDEL_EFFORT || 'medium';
106+
107+// The system prompt lives in PROMPT.md so it can be edited — and diffed — without
108+// touching server code. It is read once at boot: the prompt is cached upstream
109+// (see the cache_control on the system block), and re-reading per request would
110+// let the file change mid-session and silently invalidate that cache.
111+//
112+// The sound inventory in that file is not decorative. It has to match what
113+// src/strudel.js actually prebakes, because the prompt tells the model not to
114+// invent names — and a name that isn't loaded plays silence rather than erroring.
115+// If you change the prebake, change section 3.
116+let SYSTEM;
117+try {
118+ SYSTEM = fs.readFileSync(PROMPT_PATH, 'utf8').trim();
119+} catch (err) {
120+ console.error(`Could not read the system prompt at ${PROMPT_PATH}: ${err.message}`);
121+ process.exit(1);
122+}
123+if (!SYSTEM) {
124+ console.error(`The system prompt at ${PROMPT_PATH} is empty.`);
125+ process.exit(1);
126+}
127+
128+const OUTPUT_SCHEMA = {
129+ type: 'object',
130+ properties: {
131+ message: {
132+ type: 'string',
133+ description: 'A short, friendly reply to the user (1-2 sentences).',
134+ },
135+ code: {
136+ type: 'string',
137+ description:
138+ 'The full runnable Strudel pattern, or an empty string if no music change is needed.',
139+ },
140+ },
141+ required: ['message', 'code'],
142+ additionalProperties: false,
143+};
144+
145+// The reply is spec'd as 1-2 sentences. When a session runs long and repetitive
146+// ("more twinkles", "more sparkle"), sampling can collapse into a loop that
147+// splices its own phrasings together and drifts into stray HTML (<br>, </p>).
148+// Never let that reach the chat — or the history, where it would feed the loop.
149+const MAX_MESSAGE_LEN = 400;
150+
151+function cleanMessage(raw) {
152+ if (typeof raw !== 'string') return '';
153+ const looksLikeMarkup = /<\/?(br|p|div|span)\b[^>]*>/i.test(raw);
154+
155+ const text = raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
156+
157+ // A phrase repeated three times over is the signature of the loop, not style.
158+ const sentences = text.split(/(?<=[.!?])\s+/).map((s) => s.toLowerCase().trim()).filter(Boolean);
159+ const repeats = sentences.length - new Set(sentences).size;
160+
161+ if (looksLikeMarkup || repeats >= 2) return 'Updated the pattern.';
162+ if (text.length <= MAX_MESSAGE_LEN) return text;
163+ return text.slice(0, MAX_MESSAGE_LEN).replace(/\s+\S*$/, '') + '…';
164+}
165+
166+// Cap how much transcript goes back to the API; the client keeps the full record
167+// for display and revert regardless. Trim to a user message so the first turn
168+// sent is a user turn, as the API requires.
169+//
170+// Trimming happens in chunks rather than one turn at a time on purpose. Prompt
171+// caching is a prefix match, so dropping the oldest turn on every request would
172+// move the start of the prompt each time and throw the whole cache away. Cutting
173+// back to HISTORY_KEEP only when HISTORY_LIMIT is passed means one cold request
174+// every eight turns instead of every single one.
175+// A user may edit the system prompt in the app; their version arrives with each
176+// request. Ceiling is generosity, not policy — PROMPT.md is ~23 KB and an edited
177+// copy should have room to grow, but a request body isn't a place to park a
178+// novel. Anything blank or oversized falls back to the file on disk.
179+const MAX_SYSTEM_LEN = 200_000;
180+
181+function systemFor(override) {
182+ if (typeof override !== 'string') return SYSTEM;
183+ const text = override.trim();
184+ if (!text || text.length > MAX_SYSTEM_LEN) return SYSTEM;
185+ return text;
186+}
187+
188+const HISTORY_LIMIT = 40;
189+const HISTORY_KEEP = 24;
190+
191+function trimHistory(messages) {
192+ if (messages.length <= HISTORY_LIMIT) return messages;
193+ const tail = messages.slice(-HISTORY_KEEP);
194+ const firstUser = tail.findIndex((m) => m.role === 'user');
195+ return firstUser <= 0 ? tail : tail.slice(firstUser);
196+}
197+
198+// How a stored turn is rendered for the API. Assistant turns carry the pattern
199+// they produced, in the same JSON shape the model emits — without it a long
200+// session shows the model nothing but its own content-free replies ("Added more
201+// sparkle.") with no record of what it actually wrote, and the patterns drift.
202+//
203+// This rendering must be stable: once a turn has been sent one way it has to
204+// keep being sent that way, or the cached prefix breaks on the next request.
205+const renderTurn = (m) =>
206+ m.role === 'assistant' && m.code
207+ ? JSON.stringify({ message: m.content || '', code: m.code })
208+ : m.content || '';
209+
210+app.post('/api/generate', async (req, res) => {
211+ try {
212+ const {
213+ messages = [],
214+ currentCode = '',
215+ model: requestedModel,
216+ system: systemOverride,
217+ } = req.body;
218+ if (!Array.isArray(messages) || messages.length === 0) {
219+ return res.status(400).json({ error: 'messages array required' });
220+ }
221+
222+ // Refuse an unknown id outright. Falling back to the default would be
223+ // friendlier to a stale client but would also mean the picker could say
224+ // "Opus" while something else answered — the app resets an unavailable
225+ // choice at load instead, so this should only fire on a forged request.
226+ const model = modelById.get(requestedModel || DEFAULT_MODEL);
227+ if (!model) {
228+ return res.status(400).json({
229+ error: `unknown model — this server offers ${MODELS.map((m) => m.label).join(', ')}`,
230+ });
231+ }
232+
233+ const client = clientFor(req.get('x-anthropic-key')?.trim());
234+ if (!client) {
235+ return res.status(401).json({
236+ error: 'no API key — add your Anthropic key with the 🔑 button, or set ANTHROPIC_API_KEY on the server',
237+ });
238+ }
239+
240+ // Only role/content go to the API — the client also carries per-message
241+ // bookkeeping (e.g. codeBefore for revert) that the API would reject.
242+ const recent = trimHistory(messages);
243+ const withContext = recent.map((m) => ({
244+ role: m.role,
245+ content: [{ type: 'text', text: renderTurn(m) }],
246+ }));
247+
248+ // Everything up to and including the user's request is byte-identical to
249+ // what the next request will send, so it is worth caching; the editor
250+ // contents are not, and go after the breakpoint as their own turn. Merging
251+ // them into the user's message instead (as this used to) rewrites that turn
252+ // every time the pattern changes and costs a turn's worth of cache each go.
253+ if (withContext.length) {
254+ withContext[withContext.length - 1].content[0].cache_control = { type: 'ephemeral' };
255+ }
256+ withContext.push({
257+ role: 'user',
258+ content: [
259+ {
260+ type: 'text',
261+ text: `Current pattern in the editor:\n\`\`\`\n${currentCode || '(empty)'}\n\`\`\``,
262+ },
263+ ],
264+ });
265+
266+ const response = await client.messages.create({
267+ model: model.id,
268+ // Headroom so a big pattern + message can't truncate the JSON. Models that
269+ // think spend the same budget on reasoning first, so they get more.
270+ max_tokens: model.thinks ? 8192 : 4096,
271+ // Still cached when the user brought their own: a hand-edited prompt is
272+ // stable across a session too, it just warms a different cache entry.
273+ system: [
274+ { type: 'text', text: systemFor(systemOverride), cache_control: { type: 'ephemeral' } },
275+ ],
276+ messages: withContext,
277+ output_config: {
278+ ...(model.effort === false ? {} : { effort: EFFORT }),
279+ format: { type: 'json_schema', schema: OUTPUT_SCHEMA },
280+ },
281+ });
282+
283+ if (process.env.STRUDEL_DEBUG_USAGE) {
284+ const u = response.usage;
285+ console.log(
286+ `usage[${model.id}]: in=${u.input_tokens} cache_read=${u.cache_read_input_tokens} ` +
287+ `cache_write=${u.cache_creation_input_tokens} out=${u.output_tokens}`,
288+ );
289+ }
290+
291+ // If the model hit the token cap the JSON is incomplete — never ship a
292+ // half-parsed pattern to the editor.
293+ if (response.stop_reason === 'max_tokens') {
294+ return res.json({
295+ message: 'That got a bit long and I ran out of room — try again, maybe a touch simpler.',
296+ code: '',
297+ });
298+ }
299+
300+ const text = response.content.find((b) => b.type === 'text')?.text ?? '{}';
301+ let parsed;
302+ try {
303+ parsed = JSON.parse(text);
304+ } catch {
305+ return res.json({
306+ message: "I couldn't format that cleanly — mind trying again?",
307+ code: '',
308+ });
309+ }
310+ // Only pass through a string code field; anything else means "no change".
311+ let code = typeof parsed.code === 'string' ? parsed.code : '';
312+ // Defensively strip a markdown code fence if the model added one — a stray
313+ // ``` at char 0 is exactly the "Unexpected token (1:0)" Strudel parse error.
314+ code = code
315+ .replace(/^\s*```[a-zA-Z]*\s*\n?/, '')
316+ .replace(/\n?```\s*$/, '')
317+ .trim();
318+ res.json({ message: cleanMessage(parsed.message), code });
319+ } catch (err) {
320+ console.error('generate error:', err.status, err.message);
321+ // Point auth failures at the key rather than at a generic server error.
322+ if (err.status === 401 || err.status === 403) {
323+ return res.status(401).json({ error: 'that API key was rejected — check it with the 🔑 button' });
324+ }
325+ if (err.status === 429) {
326+ return res.status(429).json({ error: 'rate limited by the API — give it a moment and try again' });
327+ }
328+ res.status(500).json({ error: err.message || 'generation failed' });
329+ }
330+});
331+
332+// Serve the built frontend from the same origin (so one port / one ngrok
333+// tunnel serves both the app and the API). In dev, dist/ may not exist yet —
334+// Vite serves the frontend and proxies /api here instead.
335+if (fs.existsSync(DIST_DIR)) {
336+ app.use(express.static(DIST_DIR));
337+ // SPA fallback for any non-/api GET (Express 5-safe: middleware, not a wildcard route).
338+ app.use((req, res, next) => {
339+ if (req.method === 'GET' && !req.path.startsWith('/api')) {
340+ return res.sendFile(path.join(DIST_DIR, 'index.html'));
341+ }
342+ next();
343+ });
344+}
345+
346+const PORT = process.env.PORT || 8787;
347+// An explicit http.Server so the WebSocket hub can share the port (and any
348+// tunnel) with the app and the API.
349+const server = http.createServer(app);
350+attachBroadcast(server);
351+server.listen(PORT, () => {
352+ console.log(`🎛 Strudel×Claude backend on http://localhost:${PORT}`);
353+ console.log(` Prompt: PROMPT.md (${(SYSTEM.length / 1024).toFixed(1)} KB)`);
354+ console.log(` Timings: ws://localhost:${PORT}/ws · terminal view: curl -s http://localhost:${PORT}/tui.py | python3 -`);
355+ if (!process.env.ANTHROPIC_API_KEY) {
356+ console.log(
357+ ' Note: ANTHROPIC_API_KEY not set — relying on an `ant auth login` profile if present.',
358+ );
359+ }
360+});
addedsessions/breakbeat.json+5 −0
1+{
2+ "name": "breakbeat",
3+ "code": "setcpm(160/4)\n$: s(\"bd ~ ~ bd, ~ sd\").bank(\"RolandTR808\")\n$: s(\"hh*16\").gain(rand.range(0.2,0.5))\n",
4+ "history": []
5+}
addedsessions/breakbeat.str+3 −0
1+setcpm(160/4)
2+$: s("bd ~ ~ bd, ~ sd").bank("RolandTR808")
3+$: s("hh*16").gain(rand.range(0.2,0.5))
addedsessions/drone.json+15 −0
1+{
2+ "name": "drone",
3+ "code": "setcpm(90/4)\nstack(\n s(\"bd ~ [~ bd] ~ ~ bd ~ bd\").bank(\"RolandTR808\").gain(.9), // syncopated kicks\n s(\"~ sd ~ ~ ~ sd ~ [~ sd]\").bank(\"RolandTR808\").gain(\"[.9 .4]*4\").room(.15), // snappy snare + ghosts\n s(\"hh*8\").bank(\"RolandTR808\").gain(\"[.4 .7 .3 .6]*2\").swingBy(1/3, 4), // swung offbeat hats\n s(\"~ ~ rim ~\").bank(\"RolandTR808\").gain(.35) // subtle rim accent\n).sometimesBy(.12, ply(2)).crush(12).lpf(6500) // lo-fi grit + stutter",
4+ "history": [
5+ {
6+ "role": "user",
7+ "content": "I'm trying to mimic the drum line in steve lacy's new song oh yeah. what can I do to match better"
8+ },
9+ {
10+ "role": "assistant",
11+ "content": "That Steve Lacy groove leans on a loose, dusty lo-fi feel with syncopated kicks, snappy snares, and offbeat hats — try this bed with swing, ghost hats, and a touch of grit. Tweak the euclidean hats or gain accents to dial in the exact bounce.",
12+ "code": "setcpm(90/4)\nstack(\n s(\"bd ~ [~ bd] ~ ~ bd ~ bd\").bank(\"RolandTR808\").gain(.9), // syncopated kicks\n s(\"~ sd ~ ~ ~ sd ~ [~ sd]\").bank(\"RolandTR808\").gain(\"[.9 .4]*4\").room(.15), // snappy snare + ghosts\n s(\"hh*8\").bank(\"RolandTR808\").gain(\"[.4 .7 .3 .6]*2\").swingBy(1/3, 4), // swung offbeat hats\n s(\"~ ~ rim ~\").bank(\"RolandTR808\").gain(.35) // subtle rim accent\n).sometimesBy(.12, ply(2)).crush(12).lpf(6500) // lo-fi grit + stutter"
13+ }
14+ ]
15+}
addedsessions/drone.str+2 −0
1+setcpm(60/4)
2+$: note("<c2 g2>").s("sawtooth").cutoff(sine.range(200,900).slow(8)).room(0.8)
addedsrc/apikey.js+95 −0
1+// Bring-your-own Anthropic key. The key is kept in this browser's
2+// localStorage and sent to the local backend with each request, which uses it
3+// for that request only. If the backend has its own ANTHROPIC_API_KEY, a
4+// personal key is optional and simply takes precedence.
5+import { getConfig } from './config.js';
6+import { closeAll, bindPanel } from './panel.js';
7+
8+const KEY = 'strudel-claude-api-key';
9+
10+export const getKey = () => {
11+ try {
12+ return localStorage.getItem(KEY) || '';
13+ } catch {
14+ return '';
15+ }
16+};
17+
18+const setKey = (value) => {
19+ try {
20+ if (value) localStorage.setItem(KEY, value);
21+ else localStorage.removeItem(KEY);
22+ } catch {
23+ /* storage blocked — the key just won't survive a reload */
24+ }
25+};
26+
27+// Headers for a backend call: the key rides along only when the user set one.
28+export function authHeaders() {
29+ const key = getKey();
30+ return key ? { 'x-anthropic-key': key } : {};
31+}
32+
33+// Show enough of a key to recognise it, never the whole thing.
34+const mask = (key) => (key.length > 12 ? `${key.slice(0, 7)}…${key.slice(-4)}` : 'key set');
35+
36+export function initApiKeyUI({ onChange } = {}) {
37+ const button = document.getElementById('key-btn');
38+ const label = document.getElementById('key-state');
39+ const panel = document.getElementById('key-panel');
40+ const field = document.getElementById('key-input');
41+ const note = document.getElementById('key-note');
42+ const saveBtn = document.getElementById('key-save');
43+ const clearBtn = document.getElementById('key-clear');
44+
45+ // Until /api/config answers we don't know whether the server has a key, and
46+ // we'd rather let a request through than block it on a guess.
47+ let hasServerKey = true;
48+ let configKnown = false;
49+
50+ const render = () => {
51+ const key = getKey();
52+ label.textContent = key ? mask(key) : 'Add API key';
53+ button.classList.toggle('key-missing', !key && configKnown && !hasServerKey);
54+ clearBtn.hidden = !key;
55+ note.textContent =
56+ configKnown && hasServerKey
57+ ? 'Optional — the server already has a key. Yours takes precedence.'
58+ : 'Stored in this browser only, sent to the local server with each request.';
59+ };
60+
61+ bindPanel(button, panel, () => {
62+ field.value = getKey();
63+ field.focus();
64+ field.select();
65+ });
66+
67+ const commit = (value) => {
68+ setKey(value.trim());
69+ render();
70+ closeAll();
71+ onChange?.(getKey());
72+ };
73+
74+ saveBtn.addEventListener('click', () => commit(field.value));
75+ clearBtn.addEventListener('click', () => commit(''));
76+ field.addEventListener('keydown', (e) => {
77+ e.stopPropagation();
78+ if (e.key === 'Enter') commit(field.value);
79+ else if (e.key === 'Escape') closeAll();
80+ });
81+
82+ render();
83+
84+ // Ask the backend whether a key of our own is actually needed. A null config
85+ // means the backend is down or predates the endpoint — the chat itself will
86+ // report that, so leave the optimistic default in place.
87+ getConfig().then((cfg) => {
88+ if (!cfg) return;
89+ hasServerKey = Boolean(cfg.hasServerKey);
90+ configKnown = true;
91+ render();
92+ });
93+
94+ return { needsKey: () => !getKey() && configKnown && !hasServerKey };
95+}
addedsrc/broadcast.js+196 −0
1+// Pushes what the editor is playing to the backend's /ws hub, so other clients
2+// (the terminal visualiser in tui/strudel-tui.py, mainly) can draw the same
3+// code with the same events lighting up.
4+//
5+// What goes over the wire:
6+// code — the source, whenever it changes
7+// chat — the transcript, whenever it changes
8+// haps — the *upcoming* window of events, twice a second
9+// and in the other direction, the only thing a viewer may send:
10+// prompt — a message for Claude, handed to the same code path as the
11+// composer in the page, so a terminal turn and a browser turn
12+// are indistinguishable afterwards.
13+//
14+// Deliberately not a per-frame feed, for two reasons.
15+//
16+// The first is the link: each `haps` message says where the playhead was
17+// (`now`, in cycles) and how fast it moves (`cps`), and every hap carries its
18+// own begin/end, so a viewer advances its own clock and redraws at whatever
19+// rate suits it — smooth over a connection that would make 60 messages a
20+// second jitter, and cheap over a tunnel.
21+//
22+// The second is that the browser tab will be in the background. You background
23+// it *because* you are watching the terminal. Strudel's own highlighting runs
24+// on requestAnimationFrame, which a hidden tab does not fire at all, so
25+// anything hung off the draw loop goes silent exactly when it is needed. This
26+// polls the scheduler instead. Hidden tabs also clamp timers to ~1s, which
27+// costs nothing here: a lookahead measured in seconds of music covers the gap,
28+// and the viewer interpolates across it.
29+
30+const SEND_MS = 500;
31+// Seconds of music each message covers. Comfortably more than the ~1s a
32+// background tab will clamp our interval to.
33+const LOOKAHEAD_S = 2.5;
34+
35+// Reconnect backoff: quick at first (a dev-server restart), then patient.
36+const RETRY_MS = [500, 1000, 2000, 5000, 10_000];
37+
38+function wsUrl() {
39+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
40+ return `${proto}//${location.host}/ws?role=producer`;
41+}
42+
43+export function createBroadcaster(editor, { getChat, onPrompt } = {}) {
44+ let ws = null;
45+ let attempt = 0;
46+ let closed = false;
47+ // Whether this tab currently holds the producer slot. Assume yes until the
48+ // hub says otherwise: a hub that never sends `standby` (an older one) should
49+ // get a page that talks, not one that waits forever for permission.
50+ let live = true;
51+ const last = { code: null, transport: null, chat: null };
52+
53+ function connect() {
54+ if (closed) return;
55+ try {
56+ ws = new WebSocket(wsUrl());
57+ } catch {
58+ return schedule();
59+ }
60+ ws.onopen = () => {
61+ attempt = 0;
62+ live = true; // the hub re-decides on every connection
63+
64+ // A reconnected hub knows nothing about us; re-announce the state it
65+ // replays to viewers who join later.
66+ last.code = last.transport = last.chat = null;
67+ tick();
68+ };
69+ ws.onclose = () => {
70+ ws = null;
71+ schedule();
72+ };
73+ ws.onerror = () => ws?.close();
74+ ws.onmessage = (ev) => {
75+ let msg;
76+ try {
77+ msg = JSON.parse(ev.data);
78+ } catch {
79+ return;
80+ }
81+ // The hub only ever forwards `prompt` to us, but check anyway rather
82+ // than trusting the far end of a socket to have stayed honest.
83+ if (msg.type === 'prompt' && typeof msg.text === 'string' && msg.text.trim()) {
84+ onPrompt?.(msg.text.trim());
85+ } else if (msg.type === 'standby') {
86+ // Another tab is the live source. Go quiet rather than fight over it:
87+ // two tabs both insisting is what made the terminal flip between them.
88+ live = false;
89+ } else if (msg.type === 'active') {
90+ live = true;
91+ last.code = last.transport = last.chat = null;
92+ tick();
93+ } else if (msg.type === 'refresh') {
94+ // A viewer joined. Forget what we have already said so the next tick
95+ // re-sends all of it — otherwise a session that hasn't changed since
96+ // the last viewer left would leave this one with a blank screen.
97+ last.code = last.transport = last.chat = null;
98+ tick();
99+ }
100+ };
101+ }
102+
103+ function schedule() {
104+ if (closed) return;
105+ setTimeout(connect, RETRY_MS[Math.min(attempt++, RETRY_MS.length - 1)]);
106+ }
107+
108+ function send(obj) {
109+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
110+ }
111+
112+ // Only when it changed — the poll runs regardless, and viewers do not need
113+ // the same source text and transcript twice a second.
114+ function sendOnce(key, obj) {
115+ const json = JSON.stringify(obj);
116+ if (json === last[key]) return;
117+ last[key] = json;
118+ if (ws?.readyState === WebSocket.OPEN) ws.send(json);
119+ }
120+
121+ function tick() {
122+ if (!live || ws?.readyState !== WebSocket.OPEN) return;
123+ const scheduler = editor.repl?.scheduler;
124+ const cps = scheduler?.cps ?? 0.5;
125+ const playing = Boolean(scheduler?.started);
126+
127+ sendOnce('code', { type: 'code', code: editor.code ?? '' });
128+ sendOnce('transport', { type: 'transport', playing, cps });
129+ // Only role and content: `codeBefore` and `code` on a turn are there for
130+ // revert and for Claude's own context, and would multiply the size of a
131+ // message sent on every edit for something no viewer displays.
132+ if (getChat) {
133+ sendOnce('chat', {
134+ type: 'chat',
135+ turns: getChat().map(({ role, content }) => ({ role, content })),
136+ });
137+ }
138+ if (!playing || !scheduler.pattern) return;
139+
140+ const now = scheduler.now();
141+ const lookahead = Math.max(LOOKAHEAD_S * cps, 0.5);
142+ let haps;
143+ try {
144+ // From a cycle back, not from `now`: a sustained note that began before
145+ // this window is still sounding, and a viewer that only heard about
146+ // events with an onset inside the window would drop its highlight.
147+ haps = scheduler.pattern.queryArc(now - 1, now + lookahead);
148+ } catch {
149+ return; // mid-eval, or a pattern that can't be queried — try again next tick
150+ }
151+
152+ const out = [];
153+ for (const hap of haps) {
154+ const locs = hap.context?.locations;
155+ if (!locs?.length || !hap.whole) continue;
156+ const e = hap.whole.end.valueOf();
157+ if (e <= now) continue; // already over
158+ out.push({
159+ b: hap.whole.begin.valueOf(),
160+ e,
161+ // Character offsets into the same source we send as `code`, so a
162+ // viewer highlights exactly the characters strudel.cc would flash.
163+ l: locs.map(({ start, end }) => [start, end]),
164+ s: labelOf(hap.value),
165+ c: hap.value?.color,
166+ });
167+ }
168+ send({ type: 'haps', now, cps, haps: out });
169+ }
170+
171+ connect();
172+ const timer = setInterval(tick, SEND_MS);
173+
174+ return {
175+ // A line of feedback for the terminal — why a prompt was ignored, mostly.
176+ // It reaches every viewer, which is fine for something this rare.
177+ notice(text) {
178+ send({ type: 'notice', text });
179+ },
180+
181+ close() {
182+ closed = true;
183+ clearInterval(timer);
184+ ws?.close();
185+ },
186+ };
187+}
188+
189+// A short human label for the event — the sound or note name if there is one,
190+// so the terminal can print what is actually sounding, not a value dump.
191+function labelOf(value) {
192+ if (value == null) return '';
193+ if (typeof value !== 'object') return String(value);
194+ const v = value.s ?? value.sound ?? value.note ?? value.n ?? value.value;
195+ return v == null ? '' : String(v);
196+}
addedsrc/config.js+14 −0
1+// Backend capabilities: whether the server holds an API key, and which models
2+// it will accept. Fetched once and shared — several bits of UI want it, and the
3+// answer doesn't change while the page is open.
4+//
5+// Resolves to null if the backend is down or predates the endpoint; every caller
6+// has to cope with not knowing rather than block on it.
7+let pending = null;
8+
9+export function getConfig() {
10+ pending ??= fetch('/api/config', { headers: { 'ngrok-skip-browser-warning': 'true' } })
11+ .then((r) => (r.ok ? r.json() : null))
12+ .catch(() => null);
13+ return pending;
14+}
addedsrc/main.js+633 −0
1+import { createEditor, playPattern } from './strudel.js';
2+import * as Sessions from './sessions.js';
3+import { authHeaders, initApiKeyUI } from './apikey.js';
4+import { getModel, initModelUI } from './models.js';
5+import { getSystemPrompt, initPromptUI } from './prompt.js';
6+import * as Themes from './themes.js';
7+import { bindPanel } from './panel.js';
8+import { createBroadcaster } from './broadcast.js';
9+
10+const STARTER = `// Press ▶ Play, or ask Claude on the right to make something.
11+// While it plays, watch the notes flash in the code.
12+stack(
13+ s("bd*4").gain(0.9),
14+ s("~ cp").room(0.3),
15+ s("hh*8").gain(0.4),
16+ note("<c2 c2 g1 a1>").s("sawtooth").lpf(600).gain(0.7)
17+).slow(2)`;
18+
19+// ---------- Session store ----------
20+Sessions.initStore(STARTER);
21+const active = Sessions.getActive();
22+// The session this tab has loaded. Saves target this id, not whatever session
23+// is active in localStorage — another tab may have moved that.
24+let loadedId = active.id;
25+
26+// The API requires messages to alternate user/assistant, so a turn whose reply
27+// never arrived (network error, server restart) must not linger in the history.
28+const dropDanglingTurn = (h) =>
29+ h.length && h[h.length - 1].role === 'user' ? h.slice(0, -1) : h;
30+
31+// Conversation history for the active session (sent to the backend each turn).
32+let history_ = dropDanglingTurn(Array.isArray(active.history) ? [...active.history] : []);
33+
34+// ---------- Status line ----------
35+const statusEl = document.getElementById('ide-status');
36+// `action` appends a trailing link — used to offer the one-shot way back out of
37+// whatever the message just announced (undo a revert, leave a past state).
38+const setStatus = (msg, kind = '', action = null) => {
39+ statusEl.className = kind; // 'ok' | 'err' | ''
40+ statusEl.replaceChildren(document.createTextNode(msg));
41+ if (action) {
42+ const btn = document.createElement('button');
43+ btn.type = 'button';
44+ btn.className = 'undo-link';
45+ btn.textContent = action.label;
46+ btn.addEventListener('click', action.onClick);
47+ statusEl.append(' · ', btn);
48+ }
49+};
50+
51+// ---------- Strudel editor (left pane, with flash highlighting) ----------
52+const editor = createEditor(document.getElementById('editor'), active.code || STARTER, {
53+ onEval: () => setStatus('playing…', 'ok'),
54+ onError: (err) => setStatus(`error: ${err.message || err}`, 'err'),
55+});
56+
57+// Mirrors the code, its event timings and the transcript to /ws, where the
58+// terminal client (curl <host>/tui.py) picks them up. A prompt typed there
59+// comes back through `onPrompt` and goes into sendMessage — the same path the
60+// composer below uses, so a turn started from the terminal reverts, saves and
61+// reads back exactly like one started in the page.
62+const broadcast = createBroadcaster(editor, {
63+ getChat: () => history_,
64+ onPrompt: (text) => {
65+ // Dropping beats interleaving — the API rejects two user messages in a
66+ // row — but say so, or the terminal is left waiting on a reply that is
67+ // never coming.
68+ if (chatBusy) return broadcast.notice('still finishing the previous prompt');
69+ sendMessage(text);
70+ },
71+});
72+
73+const saveActive = () =>
74+ Sessions.saveSession(loadedId, { code: editor.code, history: history_ });
75+
76+// ---------- Transport (one button that toggles) ----------
77+const transportBtn = document.getElementById('playstop');
78+const isPlaying = () => Boolean(editor.repl?.scheduler?.started);
79+
80+function syncTransport() {
81+ const playing = isPlaying();
82+ transportBtn.classList.toggle('playing', playing);
83+ // Drives the ambient haze in style.css — the room only lights up when a
84+ // pattern is actually running.
85+ document.body.classList.toggle('live', playing);
86+ transportBtn.textContent = playing ? '■ Stop' : '▶ Play';
87+ transportBtn.title = playing ? 'Stop (Ctrl+.)' : 'Play (Ctrl+Enter)';
88+}
89+
90+transportBtn.addEventListener('click', async () => {
91+ if (isPlaying()) {
92+ editor.stop();
93+ setStatus('stopped');
94+ } else {
95+ await playPattern(editor);
96+ saveActive();
97+ }
98+ syncTransport();
99+});
100+
101+// The editor's own Ctrl+Enter / Ctrl+. shortcuts start and stop the scheduler
102+// behind our back, so keep the button honest by watching its state.
103+setInterval(syncTransport, 250);
104+syncTransport();
105+
106+setInterval(saveActive, 4000);
107+window.addEventListener('beforeunload', saveActive);
108+// Persist manual edits as soon as focus leaves the editor.
109+editor.editor.contentDOM.addEventListener('blur', saveActive);
110+
111+// ---------- Chat (right pane) ----------
112+const messagesEl = document.getElementById('messages');
113+const form = document.getElementById('chat-form');
114+const input = document.getElementById('chat-input');
115+const sessionListEl = document.getElementById('session-list');
116+const songTitleEl = document.getElementById('song-title');
117+
118+// `index` is the message's position in history_; assistant bubbles that know it
119+// get a revert button that undoes their turn (see revertTo).
120+function addBubble(role, text, index = null) {
121+ const el = document.createElement('div');
122+ el.className = `bubble ${role}`;
123+ const body = document.createElement('span');
124+ body.textContent = text;
125+ el.appendChild(body);
126+
127+ // A turn starts at the user message this reply answers — that's where the
128+ // "before" snapshot lives, so revert from the reply rewinds to there.
129+ const turnStart = role === 'assistant' && index !== null ? userTurnStart(index) : -1;
130+ if (turnStart >= 0) {
131+ const btn = document.createElement('button');
132+ btn.type = 'button';
133+ btn.className = 'revert';
134+ btn.textContent = '↩';
135+ btn.title = 'Undo this reply — restore the pattern and chat from before it';
136+ btn.addEventListener('click', (e) => {
137+ e.stopPropagation(); // the bubble itself time-travels; the button rewrites
138+ revertTo(turnStart);
139+ });
140+ el.appendChild(btn);
141+ }
142+
143+ // Clicking the bubble jumps the editor to the pattern from that moment.
144+ if (index !== null && codeAtMessage(index) !== null) {
145+ el.classList.add('travelable');
146+ el.tabIndex = 0;
147+ el.setAttribute('role', 'button');
148+ el.title = 'Jump the editor to the pattern from this point';
149+ if (travel?.index === index) el.classList.add('travelled');
150+ el.addEventListener('click', () => {
151+ // Don't hijack a click that was someone selecting text to copy.
152+ if (window.getSelection()?.toString()) return;
153+ timeTravelTo(index);
154+ });
155+ el.addEventListener('keydown', (e) => {
156+ if (e.key === 'Enter' || e.key === ' ') {
157+ e.preventDefault();
158+ timeTravelTo(index);
159+ }
160+ });
161+ }
162+
163+ messagesEl.appendChild(el);
164+ messagesEl.scrollTop = messagesEl.scrollHeight;
165+ return el;
166+}
167+
168+// Rebuild the whole transcript from history_ (indices shift after a revert).
169+function renderMessages() {
170+ messagesEl.replaceChildren();
171+ history_.forEach((m, i) => addBubble(m.role, m.content, i));
172+}
173+
174+function addTypingBubble() {
175+ const el = document.createElement('div');
176+ el.className = 'bubble assistant typing';
177+ el.innerHTML =
178+ '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
179+ messagesEl.appendChild(el);
180+ messagesEl.scrollTop = messagesEl.scrollHeight;
181+ return el;
182+}
183+
184+function resolveBubble(el, text, isError = false) {
185+ el.classList.remove('typing');
186+ el.textContent = text;
187+ if (isError) el.classList.add('err');
188+ messagesEl.scrollTop = messagesEl.scrollHeight;
189+}
190+
191+// The track's name doubles as the IDE header — the pane holds one song, so its
192+// title is the song's. Auto-naming moves it too, so it re-renders alongside the
193+// sidebar list rather than only when the name is typed.
194+function renderSongTitle() {
195+ const name = Sessions.get(loadedId)?.name ?? 'Untitled';
196+ songTitleEl.textContent = name;
197+ songTitleEl.title = `${name} — click to rename`;
198+}
199+
200+// Rebuild the sidebar list from the store.
201+function renderSessionList() {
202+ renderSongTitle();
203+ sessionListEl.replaceChildren();
204+ for (const s of Sessions.list()) {
205+ const row = document.createElement('div');
206+ row.className = `session-row${s.active ? ' active' : ''}`;
207+
208+ const open = document.createElement('button');
209+ open.type = 'button';
210+ open.className = 'session-open';
211+ open.textContent = s.name;
212+ open.title = s.name;
213+ open.addEventListener('click', () => switchToSession(s.id));
214+ open.addEventListener('dblclick', () => startRename(row, s));
215+ row.appendChild(open);
216+
217+ const actions = document.createElement('div');
218+ actions.className = 'session-actions';
219+ actions.append(
220+ rowAction('✎', 'Rename', () => startRename(row, s)),
221+ rowAction('🗑', 'Delete', () => deleteSession(s.id), 'danger'),
222+ );
223+ row.appendChild(actions);
224+
225+ sessionListEl.appendChild(row);
226+ }
227+}
228+
229+function rowAction(glyph, label, onClick, extra = '') {
230+ const btn = document.createElement('button');
231+ btn.type = 'button';
232+ btn.className = `icon-btn ${extra}`.trim();
233+ btn.textContent = glyph;
234+ btn.title = label;
235+ btn.setAttribute('aria-label', label);
236+ btn.addEventListener('click', (e) => {
237+ e.stopPropagation();
238+ onClick();
239+ });
240+ return btn;
241+}
242+
243+// Rename in place: the row's label becomes a text field.
244+function startRename(row, session) {
245+ const field = document.createElement('input');
246+ field.className = 'session-rename';
247+ field.value = session.name;
248+ field.setAttribute('aria-label', 'Track name');
249+
250+ const commit = (save) => {
251+ if (!field.isConnected) return;
252+ if (save) Sessions.rename(session.id, field.value);
253+ renderSessionList();
254+ };
255+ field.addEventListener('blur', () => commit(true));
256+ field.addEventListener('keydown', (e) => {
257+ e.stopPropagation();
258+ if (e.key === 'Enter') commit(true);
259+ else if (e.key === 'Escape') commit(false);
260+ });
261+
262+ row.replaceChildren(field);
263+ field.focus();
264+ field.select();
265+}
266+
267+// Same rename, from the IDE header: the title swaps itself for a field in
268+// place. Clearing it hands the name back to auto-naming (see Sessions.rename).
269+songTitleEl.addEventListener('click', () => {
270+ const session = Sessions.get(loadedId);
271+ if (!session) return;
272+
273+ const field = document.createElement('input');
274+ field.className = 'song-title-edit';
275+ field.value = session.name;
276+ field.setAttribute('aria-label', 'Track name');
277+
278+ // `refocus` only on a keyboard finish — on blur the user has already clicked
279+ // somewhere else, and pulling focus back would fight them.
280+ const commit = (save, refocus = false) => {
281+ if (!field.isConnected) return;
282+ if (save) Sessions.rename(session.id, field.value);
283+ field.replaceWith(songTitleEl);
284+ renderSessionList();
285+ if (refocus) songTitleEl.focus();
286+ };
287+ field.addEventListener('blur', () => commit(true));
288+ field.addEventListener('keydown', (e) => {
289+ // The editor and the panels both listen on document for keys; this field
290+ // owns Enter and Escape while it's open.
291+ e.stopPropagation();
292+ if (e.key === 'Enter') commit(true, true);
293+ else if (e.key === 'Escape') commit(false, true);
294+ });
295+
296+ songTitleEl.replaceWith(field);
297+ field.focus();
298+ field.select();
299+});
300+
301+function switchToSession(id) {
302+ // Compare against what this tab has loaded, not the store's activeId — if
303+ // another tab moved that, clicking the row you're looking at must still work.
304+ if (id === loadedId) return;
305+ saveActive(); // persist the one we're leaving
306+ Sessions.setActive(id);
307+ loadSessionIntoUI(Sessions.getActive());
308+}
309+
310+function deleteSession(id) {
311+ const wasActive = id === loadedId;
312+ const nextActive = Sessions.remove(id, STARTER);
313+ if (wasActive) loadSessionIntoUI(nextActive);
314+ else renderSessionList();
315+ setStatus('track deleted');
316+}
317+
318+// Render a session's chat + code into the UI (does not itself change the store).
319+// Switching while the music runs hands the transport over to the new session's
320+// pattern rather than dropping to silence.
321+async function loadSessionIntoUI(session) {
322+ const wasPlaying = isPlaying();
323+ editor.stop();
324+ loadedId = session.id; // saves from here on belong to this session
325+ history_ = dropDanglingTurn(Array.isArray(session.history) ? [...session.history] : []);
326+ undoState = null;
327+ travel = null;
328+ renderMessages();
329+ editor.setCode(session.code || STARTER);
330+ renderSessionList();
331+ setStatus(`switched to “${session.name}”`);
332+ if (wasPlaying) await playPattern(editor);
333+ syncTransport();
334+}
335+
336+// ---------- Time travel ----------
337+// Every turn carries the pattern it belongs to: a user message stores the code
338+// as it stood before it was sent, an assistant reply stores the code it
339+// produced. That makes each message a point on the song's timeline, and
340+// clicking one moves the editor back to it.
341+//
342+// Unlike revert this destroys nothing — the chat is left whole and you can step
343+// back out again. While parked in the past the editor genuinely holds that old
344+// pattern, so sending a new message from there continues from that version.
345+// `null` means we're at the newest state.
346+let travel = null; // { index, latestCode }
347+
348+// The pattern this message represents, or null if the session predates the
349+// snapshot it needs.
350+function codeAtMessage(index) {
351+ const m = history_[index];
352+ if (!m) return null;
353+ const code = m.role === 'assistant' ? m.code : m.codeBefore;
354+ return typeof code === 'string' && code.trim() ? code : null;
355+}
356+
357+async function timeTravelTo(index) {
358+ const code = codeAtMessage(index);
359+ if (code === null) {
360+ setStatus('no pattern saved at that message', 'err');
361+ return;
362+ }
363+ if (travel?.index === index) return;
364+
365+ // The newest message is "now", not a past state — landing there ends the trip
366+ // rather than parking on a marker that says you are somewhere you aren't.
367+ // Otherwise remember where "now" is the first time we leave it, so returning
368+ // is exact even after hopping between several past states.
369+ const atEnd = index >= history_.length - 1;
370+ travel = atEnd ? null : { index, latestCode: travel ? travel.latestCode : editor.code };
371+
372+ await applyCode(code);
373+ renderMessages();
374+ saveActive();
375+
376+ setStatus(
377+ atEnd ? 'back at the latest pattern' : `viewing the pattern from message ${index + 1}`,
378+ '',
379+ atEnd ? null : { label: 'Back to latest', onClick: returnToLatest },
380+ );
381+}
382+
383+async function returnToLatest() {
384+ if (!travel) return;
385+ const { latestCode } = travel;
386+ travel = null;
387+ await applyCode(latestCode);
388+ renderMessages();
389+ saveActive();
390+ setStatus('back at the latest pattern');
391+}
392+
393+// ---------- Revert ----------
394+// Snapshot taken before the last revert, so it can be undone once.
395+let undoState = null;
396+
397+// Index of the user message that opens the turn containing `index`, or -1.
398+function userTurnStart(index) {
399+ for (let i = index; i >= 0; i--) if (history_[i].role === 'user') return i;
400+ return -1;
401+}
402+
403+// Drop message `index` and everything after it, and restore the code as it was
404+// before that message was sent.
405+async function revertTo(index) {
406+ const msg = history_[index];
407+ if (!msg) return;
408+
409+ undoState = { history: [...history_], code: editor.code };
410+ const dropped = history_.length - index;
411+ history_ = history_.slice(0, index);
412+ renderMessages();
413+
414+ // Sessions saved before this feature have no snapshot — rewind the chat only.
415+ if (typeof msg.codeBefore === 'string') await applyCode(msg.codeBefore);
416+
417+ saveActive();
418+ renderSessionList();
419+ setStatus(`reverted ${dropped} message${dropped === 1 ? '' : 's'}`, '', {
420+ label: 'Undo',
421+ onClick: undoRevert,
422+ });
423+}
424+
425+function undoRevert() {
426+ if (!undoState) return;
427+ const { history, code } = undoState;
428+ undoState = null;
429+ travel = null;
430+ history_ = [...history];
431+ renderMessages();
432+ applyCode(code);
433+ saveActive();
434+ renderSessionList();
435+ setStatus('revert undone');
436+}
437+
438+// Set the editor's code, re-evaluating only if a pattern is already playing.
439+async function applyCode(code) {
440+ editor.setCode(code);
441+ if (editor.repl?.scheduler?.started) await playPattern(editor);
442+}
443+
444+// Initial render of restored session.
445+renderMessages();
446+renderSessionList();
447+setStatus(history_.length ? 'restored your last track' : 'ready');
448+
449+// New session.
450+document.getElementById('new-session').addEventListener('click', () => {
451+ saveActive();
452+ const sess = Sessions.create(STARTER);
453+ loadSessionIntoUI(sess);
454+ setStatus('new track');
455+ input.focus();
456+});
457+
458+// ---------- Theme picker ----------
459+{
460+ const button = document.getElementById('theme-btn');
461+ const panel = document.getElementById('theme-panel');
462+ const siteSel = document.getElementById('site-theme');
463+ const codeSel = document.getElementById('code-theme');
464+
465+ const fill = (sel, entries) => {
466+ sel.replaceChildren();
467+ for (const [value, label] of entries) {
468+ const opt = document.createElement('option');
469+ opt.value = value;
470+ opt.textContent = label;
471+ sel.appendChild(opt);
472+ }
473+ };
474+ fill(siteSel, Themes.SITE_THEMES.map((t) => [t.id, t.label]));
475+ fill(codeSel, Themes.CODE_THEMES.map((c) => [c, c]));
476+
477+ // Reflect state into the controls — the site picker also changes the code
478+ // theme, so both are re-read after every change.
479+ const sync = () => {
480+ const { site, code } = Themes.currentTheme();
481+ siteSel.value = site;
482+ codeSel.value = code;
483+ document.getElementById('theme-state').textContent =
484+ Themes.SITE_THEMES.find((t) => t.id === site)?.label ?? 'Theme';
485+ };
486+
487+ bindPanel(button, panel);
488+
489+ siteSel.addEventListener('change', () => {
490+ Themes.setSiteTheme(editor, siteSel.value);
491+ sync();
492+ setStatus(`lighting: ${siteSel.selectedOptions[0].textContent}`);
493+ });
494+ codeSel.addEventListener('change', () => {
495+ Themes.setCodeTheme(editor, codeSel.value);
496+ sync();
497+ setStatus(`code colors: ${codeSel.value}`);
498+ });
499+
500+ Themes.initThemes(editor);
501+ sync();
502+}
503+
504+// ---------- Model picker ----------
505+initModelUI();
506+
507+// ---------- System prompt (view / edit) ----------
508+initPromptUI();
509+
510+// ---------- API key (bring your own) ----------
511+const apiKeyUI = initApiKeyUI({
512+ onChange: (key) => setStatus(key ? 'API key saved' : 'API key removed'),
513+});
514+
515+// ---------- Sidebar collapse ----------
516+const SIDEBAR_KEY = 'strudel-claude-sidebar';
517+const hideBtn = document.getElementById('sidebar-hide');
518+const showBtn = document.getElementById('sidebar-show');
519+
520+function setSidebar(collapsed) {
521+ document.getElementById('app').classList.toggle('sidebar-collapsed', collapsed);
522+ showBtn.hidden = !collapsed;
523+ try {
524+ localStorage.setItem(SIDEBAR_KEY, collapsed ? 'collapsed' : 'open');
525+ } catch {
526+ /* storage blocked — the layout still works, it just won't persist */
527+ }
528+}
529+
530+hideBtn.addEventListener('click', () => setSidebar(true));
531+showBtn.addEventListener('click', () => setSidebar(false));
532+setSidebar(localStorage.getItem(SIDEBAR_KEY) === 'collapsed');
533+
534+// True while a generate request is in flight. The page prevents a second turn
535+// by clearing the composer; the terminal has no such interlock, so it needs an
536+// explicit flag to check.
537+let chatBusy = false;
538+
539+async function sendMessage(text) {
540+ // Nothing can be generated without a key — say so instead of round-tripping.
541+ if (apiKeyUI.needsKey()) {
542+ addBubble('assistant', '⚠ add your Anthropic API key (🔑, bottom left) to start generating.').classList.add('err');
543+ input.value = text;
544+ return;
545+ }
546+
547+ // Snapshot the code as it stands *before* this turn so it can be reverted to.
548+ history_.push({ role: 'user', content: text, codeBefore: editor.code });
549+ addBubble('user', text, history_.length - 1);
550+ undoState = null;
551+ travel = null;
552+ chatBusy = true;
553+ const thinking = addTypingBubble();
554+
555+ try {
556+ const res = await fetch('/api/generate', {
557+ method: 'POST',
558+ headers: {
559+ 'Content-Type': 'application/json',
560+ // Skip ngrok's free-tier browser-warning page so the API returns JSON,
561+ // not HTML, when the app is served through an ngrok tunnel. Harmless
562+ // when running locally.
563+ 'ngrok-skip-browser-warning': 'true',
564+ // Your own key, if you set one (see apikey.js).
565+ ...authHeaders(),
566+ },
567+ body: JSON.stringify({
568+ messages: history_,
569+ currentCode: editor.code,
570+ // The backend only accepts ids from its own whitelist; empty means
571+ // "your default", which is what we send before /api/config answers.
572+ model: getModel(),
573+ // Only set if the user edited the system prompt; otherwise the server
574+ // uses its own PROMPT.md (see prompt.js).
575+ ...(getSystemPrompt() ? { system: getSystemPrompt() } : {}),
576+ }),
577+ });
578+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
579+ const { message, code } = await res.json();
580+
581+ resolveBubble(thinking, message || 'Here you go.');
582+ // Keep the pattern next to the reply that introduced it. The chat only shows
583+ // `content`, but the backend feeds `code` back to Claude so it can see what
584+ // it actually wrote earlier in the session, not just what it said about it.
585+ history_.push({ role: 'assistant', content: message || '', code: code || '' });
586+ renderMessages(); // re-render so the new reply gets its revert control
587+ // The turn is complete as far as the conversation is concerned, so release
588+ // the interlock here rather than in `finally`. What follows can block for
589+ // a long time — playPattern waits on the AudioContext, which never resumes
590+ // until someone clicks the page — and a terminal that had to wait for that
591+ // would look broken with nothing to show for it.
592+ chatBusy = false;
593+
594+ if (code && code.trim()) {
595+ const prev = editor.code; // keep the last working pattern
596+ editor.setCode(code);
597+ const ok = await playPattern(editor);
598+ if (!ok) {
599+ editor.setCode(prev);
600+ await playPattern(editor);
601+ addBubble('assistant', '⚠ that pattern had an error, so I kept your previous one. Try again?');
602+ }
603+ }
604+ saveActive();
605+ renderSessionList(); // reflect auto-naming from the first message
606+ } catch (err) {
607+ // Drop the turn that failed — leaving a user message with no reply would
608+ // make the next request send two user messages in a row, which the API
609+ // rejects. The text goes back in the composer so it can be retried.
610+ if (history_[history_.length - 1]?.role === 'user') history_.pop();
611+ renderMessages();
612+ addBubble('assistant', `⚠ ${err.message}`, null).classList.add('err');
613+ if (!input.value.trim()) input.value = text;
614+ } finally {
615+ chatBusy = false;
616+ }
617+}
618+
619+form.addEventListener('submit', (e) => {
620+ e.preventDefault();
621+ const text = input.value.trim();
622+ if (!text) return;
623+ input.value = '';
624+ sendMessage(text);
625+});
626+
627+// Enter sends; Shift+Enter inserts a newline.
628+input.addEventListener('keydown', (e) => {
629+ if (e.key === 'Enter' && !e.shiftKey) {
630+ e.preventDefault();
631+ form.requestSubmit();
632+ }
633+});
addedsrc/models.js+85 −0
1+// Model picker. The list of models is the backend's to decide — this only
2+// remembers which of the offered ones the user prefers, and sends its id along
3+// with each request. An unrecognised id is refused server-side, so a stale
4+// choice is cleared here as soon as we learn what's actually on offer.
5+import { getConfig } from './config.js';
6+import { bindPanel } from './panel.js';
7+
8+const KEY = 'strudel-claude-model';
9+
10+const stored = () => {
11+ try {
12+ return localStorage.getItem(KEY) || '';
13+ } catch {
14+ return '';
15+ }
16+};
17+
18+const store = (id) => {
19+ try {
20+ if (id) localStorage.setItem(KEY, id);
21+ else localStorage.removeItem(KEY);
22+ } catch {
23+ /* storage blocked — the choice just won't survive a reload */
24+ }
25+};
26+
27+// What goes in the request body. Empty until the backend has answered, which
28+// means "use whatever you consider the default" — the same thing an old client
29+// that doesn't know about models sends.
30+let chosen = '';
31+let models = [];
32+
33+export const getModel = () => chosen;
34+
35+export function initModelUI() {
36+ const button = document.getElementById('model-btn');
37+ const label = document.getElementById('model-state');
38+ const panel = document.getElementById('model-panel');
39+ const select = document.getElementById('model-select');
40+ const note = document.getElementById('model-note');
41+
42+ const current = () => models.find((m) => m.id === chosen);
43+
44+ const render = () => {
45+ const model = current();
46+ label.textContent = model ? model.label : 'Model';
47+ note.textContent = model?.blurb || '';
48+ select.value = chosen;
49+ // Nothing to choose between until the backend answers, or if it only
50+ // offers one model.
51+ button.disabled = models.length < 2;
52+ };
53+
54+ bindPanel(button, panel, () => select.focus());
55+
56+ select.addEventListener('change', () => {
57+ chosen = select.value;
58+ store(chosen);
59+ render();
60+ openPanel(false);
61+ });
62+
63+ render();
64+
65+ getConfig().then((cfg) => {
66+ if (!Array.isArray(cfg?.models) || !cfg.models.length) return;
67+ models = cfg.models;
68+
69+ // Honour the saved choice only if this backend still offers it; otherwise
70+ // fall back to its default rather than sending an id it will refuse.
71+ const saved = stored();
72+ chosen = models.some((m) => m.id === saved) ? saved : cfg.defaultModel || models[0].id;
73+ if (chosen !== saved) store(chosen);
74+
75+ select.replaceChildren(
76+ ...models.map((m) => {
77+ const opt = document.createElement('option');
78+ opt.value = m.id;
79+ opt.textContent = m.label;
80+ return opt;
81+ }),
82+ );
83+ render();
84+ });
85+}
addedsrc/panel.js+41 −0
1+// The three settings popovers (theme, model, API key) all behave the same way:
2+// a chip toggles a panel, clicking outside or pressing Escape closes it, and
3+// opening one closes the others. That last part matters now that the model and
4+// key panels are anchored side by side in the chat header — they occupy the
5+// same strip, so two open at once would stack.
6+//
7+// Registered panels are tracked here rather than broadcast on the DOM, since
8+// they're all bound at startup and never move.
9+const panels = [];
10+
11+/**
12+ * Wire a chip to its panel. `onOpen` runs after the panel is shown — use it to
13+ * seed fields or move focus.
14+ */
15+export function bindPanel(button, panel, onOpen) {
16+ const toggle = (open) => {
17+ panel.hidden = !open;
18+ button.setAttribute('aria-expanded', String(open));
19+ if (open) onOpen?.();
20+ };
21+ panels.push(toggle);
22+
23+ button.addEventListener('click', (e) => {
24+ e.stopPropagation();
25+ if (button.disabled) return;
26+ const open = panel.hidden;
27+ closeAll();
28+ toggle(open);
29+ });
30+ // Clicks inside a panel are for the controls in it, not a signal to dismiss.
31+ panel.addEventListener('click', (e) => e.stopPropagation());
32+}
33+
34+export function closeAll() {
35+ for (const toggle of panels) toggle(false);
36+}
37+
38+document.addEventListener('click', closeAll);
39+document.addEventListener('keydown', (e) => {
40+ if (e.key === 'Escape') closeAll();
41+});
addedsrc/prompt.js+127 −0
1+// The system prompt, opened up for editing. The server's PROMPT.md is the
2+// default and stays the server's; an edit made here is this browser's alone,
3+// kept in localStorage and sent with each request. Nothing is sent while the
4+// text still matches the default, so the common case remains "whatever the
5+// server ships" and a server-side prompt update isn't frozen out by a stale copy.
6+//
7+// It's a full-screen editor rather than one of the header popovers because the
8+// prompt is ~23 KB of Markdown — the popovers are sized for a key or a dropdown.
9+import { closeAll } from './panel.js';
10+
11+const KEY = 'strudel-claude-system-prompt';
12+
13+// The server's default, once fetched. Null until then — which is also the
14+// "backend is down" answer, so the editor stays read-only rather than offering
15+// a Reset it can't perform.
16+let defaultPrompt = null;
17+
18+const stored = () => {
19+ try {
20+ return localStorage.getItem(KEY) || '';
21+ } catch {
22+ return '';
23+ }
24+};
25+
26+const store = (text) => {
27+ try {
28+ if (text) localStorage.setItem(KEY, text);
29+ else localStorage.removeItem(KEY);
30+ } catch {
31+ /* storage blocked — the edit just won't survive a reload */
32+ }
33+};
34+
35+/** The override to send, or '' to let the server use PROMPT.md. */
36+export const getSystemPrompt = () => stored();
37+
38+export function initPromptUI() {
39+ const button = document.getElementById('prompt-btn');
40+ const label = document.getElementById('prompt-state');
41+ const modal = document.getElementById('prompt-modal');
42+ const field = document.getElementById('prompt-text');
43+ const note = document.getElementById('prompt-note');
44+ const saveBtn = document.getElementById('prompt-save');
45+ const resetBtn = document.getElementById('prompt-reset');
46+ const closeBtn = document.getElementById('prompt-close');
47+
48+ const render = () => {
49+ const custom = Boolean(stored());
50+ label.textContent = custom ? 'Prompt (edited)' : 'System prompt';
51+ button.classList.toggle('prompt-custom', custom);
52+ resetBtn.hidden = !custom || defaultPrompt === null;
53+ note.textContent =
54+ defaultPrompt === null
55+ ? "Couldn't load the default prompt from the server — save will still work, but there's nothing to reset to."
56+ : custom
57+ ? 'Edited — this copy is stored in this browser and sent with each request.'
58+ : "The server's PROMPT.md. Edit and save to use your own version instead.";
59+ };
60+
61+ const open = () => {
62+ closeAll(); // a header popover left open would float over the overlay
63+ field.value = stored() || defaultPrompt || '';
64+ modal.hidden = false;
65+ button.setAttribute('aria-expanded', 'true');
66+ field.focus();
67+ // Focusing a textarea leaves the caret at the end, which for a document this
68+ // long means opening on the last line. Start at the top, where the prompt does.
69+ field.setSelectionRange(0, 0);
70+ field.scrollTop = 0;
71+ render();
72+ };
73+
74+ const close = () => {
75+ modal.hidden = true;
76+ button.setAttribute('aria-expanded', 'false');
77+ button.focus();
78+ };
79+
80+ const save = () => {
81+ const text = field.value.trim();
82+ // Saving the default verbatim means "no override" — otherwise the copy
83+ // would silently pin this browser to today's PROMPT.md forever.
84+ store(text && text !== defaultPrompt?.trim() ? text : '');
85+ render();
86+ close();
87+ };
88+
89+ button.addEventListener('click', (e) => {
90+ e.stopPropagation();
91+ if (modal.hidden) open();
92+ else close();
93+ });
94+
95+ saveBtn.addEventListener('click', save);
96+ closeBtn.addEventListener('click', close);
97+ resetBtn.addEventListener('click', () => {
98+ store('');
99+ field.value = defaultPrompt || '';
100+ render();
101+ });
102+
103+ // Click the backdrop (not the dialog) to dismiss.
104+ modal.addEventListener('click', (e) => {
105+ if (e.target === modal) close();
106+ });
107+ modal.addEventListener('keydown', (e) => {
108+ // The global Escape handler closes popovers; this is a modal, so it swallows
109+ // the key rather than letting both fire.
110+ e.stopPropagation();
111+ if (e.key === 'Escape') close();
112+ else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save();
113+ });
114+
115+ render();
116+
117+ fetch('/api/prompt', { headers: { 'ngrok-skip-browser-warning': 'true' } })
118+ .then((r) => (r.ok ? r.json() : null))
119+ .then((data) => {
120+ if (typeof data?.prompt !== 'string') return;
121+ defaultPrompt = data.prompt;
122+ // If the editor is already open showing an empty default, fill it in.
123+ if (!modal.hidden && !field.value) field.value = defaultPrompt;
124+ render();
125+ })
126+ .catch(() => render());
127+}
addedsrc/sessions.js+159 −0
1+// Multi-session store backed by localStorage. Each session holds its own
2+// editor code + chat history. One session is "active" at a time.
3+const KEY = 'strudel-claude-sessions-v1';
4+const OLD_KEY = 'strudel-claude-v1'; // single-session format, migrated on first load
5+
6+const read = () => {
7+ try {
8+ return JSON.parse(localStorage.getItem(KEY) || 'null');
9+ } catch {
10+ return null;
11+ }
12+};
13+const write = (state) => {
14+ try {
15+ localStorage.setItem(KEY, JSON.stringify(state));
16+ } catch {
17+ /* storage full/blocked — non-fatal */
18+ }
19+};
20+
21+let seq = 0;
22+const makeId = () => 's' + Date.now().toString(36) + (seq++).toString(36);
23+
24+const newSession = (name, code, history = []) => ({
25+ id: makeId(),
26+ name: name || 'Untitled',
27+ code,
28+ history,
29+ updatedAt: Date.now(),
30+});
31+
32+// Load (and if needed, initialize/migrate) the store. `starter` is the default
33+// code for a brand-new session.
34+export function initStore(starter) {
35+ let state = read();
36+
37+ // Drop order entries with no session behind them, and repair an activeId that
38+ // points at a session that is gone — otherwise list() and getActive() throw on
39+ // load and the restore appears to fail.
40+ if (state?.sessions && Array.isArray(state.order)) {
41+ const order = state.order.filter((id) => state.sessions[id]);
42+ const activeId = state.sessions[state.activeId] ? state.activeId : order[order.length - 1];
43+ if (order.length !== state.order.length || activeId !== state.activeId) {
44+ state = { ...state, order, activeId };
45+ write(state);
46+ }
47+ }
48+
49+ if (!state || !state.sessions || !state.order?.length) {
50+ // Migrate an old single-session save if present.
51+ let migrated = null;
52+ try {
53+ migrated = JSON.parse(localStorage.getItem(OLD_KEY) || 'null');
54+ } catch {
55+ /* ignore */
56+ }
57+ const first = migrated
58+ ? newSession(nameFrom(migrated.history) || 'Restored', migrated.code || starter, migrated.history || [])
59+ : newSession('Session 1', starter, []);
60+ state = { activeId: first.id, order: [first.id], sessions: { [first.id]: first } };
61+ localStorage.removeItem(OLD_KEY);
62+ write(state);
63+ }
64+ return state;
65+}
66+
67+// Derive a session name from the first user message.
68+export function nameFrom(history) {
69+ const firstUser = history?.find((m) => m.role === 'user')?.content?.trim();
70+ if (!firstUser) return null;
71+ return firstUser.length > 32 ? firstUser.slice(0, 32) + '…' : firstUser;
72+}
73+
74+export function list() {
75+ const s = read();
76+ if (!s) return [];
77+ return s.order.map((id) => ({ id, name: s.sessions[id].name, active: id === s.activeId }));
78+}
79+
80+export const activeId = () => read()?.activeId;
81+export const get = (id) => read()?.sessions[id];
82+export const getActive = () => {
83+ const s = read();
84+ return s?.sessions[s.activeId];
85+};
86+
87+export function setActive(id) {
88+ const s = read();
89+ if (s?.sessions[id]) {
90+ s.activeId = id;
91+ write(s);
92+ }
93+}
94+
95+// Persist a session's code + history. The caller passes the id of the session
96+// it actually has loaded rather than letting this read `activeId`: another tab
97+// (or a delete racing the autosave) can move `activeId` out from under us, and
98+// writing to it would drop this editor's work into someone else's session.
99+export function saveSession(id, { code, history }) {
100+ const s = read();
101+ const sess = s?.sessions[id];
102+ if (!sess) return; // deleted elsewhere — nothing left to save into
103+ sess.code = code;
104+ sess.history = history;
105+ // Auto-name from the first message while still untitled/default; a name the
106+ // user typed (`named`) is never touched. Reverting the whole chat away drops
107+ // back to Untitled so the next message can name the session again.
108+ if (!sess.named) {
109+ if (!history.length) {
110+ if (!/^Session \d+$/.test(sess.name)) sess.name = 'Untitled';
111+ } else if (/^(Untitled|Session \d+)$/.test(sess.name)) {
112+ const derived = nameFrom(history);
113+ if (derived) sess.name = derived;
114+ }
115+ }
116+ sess.updatedAt = Date.now();
117+ write(s);
118+}
119+
120+// Rename a session. `named` pins it so auto-naming stops overwriting it;
121+// clearing the name hands it back to auto-naming.
122+export function rename(id, name) {
123+ const s = read();
124+ const sess = s?.sessions[id];
125+ if (!sess) return;
126+ const trimmed = name.trim();
127+ sess.name = trimmed || nameFrom(sess.history) || 'Untitled';
128+ sess.named = Boolean(trimmed);
129+ write(s);
130+}
131+
132+// Create a new (empty) session and make it active.
133+export function create(starter) {
134+ const s = read() ?? initStore(starter);
135+ const sess = newSession('Untitled', starter, []);
136+ s.sessions[sess.id] = sess;
137+ s.order.push(sess.id);
138+ s.activeId = sess.id;
139+ write(s);
140+ return sess;
141+}
142+
143+// Delete a session; returns the session that becomes active afterward.
144+export function remove(id, starter) {
145+ const s = read() ?? initStore(starter);
146+ if (!s.sessions[id]) return getActive();
147+ delete s.sessions[id];
148+ s.order = s.order.filter((x) => x !== id);
149+ if (!s.order.length) {
150+ const sess = newSession('Session 1', starter, []);
151+ s.sessions[sess.id] = sess;
152+ s.order.push(sess.id);
153+ s.activeId = sess.id;
154+ } else if (s.activeId === id) {
155+ s.activeId = s.order[s.order.length - 1];
156+ }
157+ write(s);
158+ return s.sessions[s.activeId];
159+}
addedsrc/strudel.js+74 −0
1+// Builds the *real* Strudel editor — the same StrudelMirror that powers
2+// strudel.cc. Unlike a plain CodeMirror, it wires the audio scheduler to the
3+// source: while a pattern plays, each active event flashes the characters that
4+// produced it (the "cursor follow-along" highlighting).
5+import { silence } from '@strudel/core';
6+import { getDrawContext } from '@strudel/draw';
7+import { transpiler } from '@strudel/transpiler';
8+import {
9+ getAudioContext,
10+ webaudioOutput,
11+ initAudioOnFirstClick,
12+} from '@strudel/webaudio';
13+import { StrudelMirror } from '@strudel/codemirror';
14+import { prebake } from '@strudel/repl/prebake.mjs';
15+
16+// Browsers only allow audio to start after a user gesture; this arms it.
17+initAudioOnFirstClick();
18+
19+// Resume the AudioContext and wait until it is actually running. Call this
20+// from a click handler *before* evaluate(), otherwise the very first play can
21+// fire while the context is still suspended and the scheduler won't start.
22+export async function resumeAudio() {
23+ const ctx = getAudioContext();
24+ if (ctx.state !== 'running') {
25+ try {
26+ await ctx.resume();
27+ } catch {
28+ /* ignore — will retry on the next gesture */
29+ }
30+ }
31+ return ctx.state === 'running';
32+}
33+
34+export function createEditor(root, initialCode, handlers = {}) {
35+ const editor = new StrudelMirror({
36+ defaultOutput: webaudioOutput,
37+ getTime: () => getAudioContext().currentTime,
38+ transpiler,
39+ root,
40+ initialCode,
41+ pattern: silence,
42+ // Draw a small window around "now" so highlights lead/trail naturally.
43+ drawTime: [-2, 2],
44+ drawContext: getDrawContext(),
45+ // Loads the default strudel.cc sound set (drum machines, piano, GM, ...).
46+ prebake,
47+ afterEval: () => {
48+ editor.__evalError = null;
49+ handlers.onEval?.();
50+ },
51+ onEvalError: (err) => {
52+ editor.__evalError = err;
53+ handlers.onError?.(err);
54+ },
55+ });
56+ editor.setCode(initialCode);
57+ if (import.meta.env.DEV) {
58+ window.__strudel = { editor, getAudioContext };
59+ }
60+ return editor;
61+}
62+
63+// Play the editor's current code. Resumes audio first, and if the very first
64+// evaluate after page load doesn't start the scheduler (a known cold-start
65+// quirk), evaluates once more — the second pass reliably starts it.
66+export async function playPattern(editor) {
67+ await resumeAudio();
68+ editor.__evalError = null;
69+ await editor.evaluate();
70+ if (!editor.repl?.scheduler?.started && !editor.__evalError) {
71+ await editor.evaluate();
72+ }
73+ return !editor.__evalError; // false → the code had a syntax/eval error
74+}
addedsrc/style.css+702 −0
1+/* ---------- Site themes ----------
2+ Each theme is one lighting rig: a palette, plus the --haze gradient stack
3+ that lights the room. Backgrounds are never neutral grey — they carry the
4+ rig's own hue. Switch with data-site-theme on <html> (see themes.js). */
5+
6+:root {
7+ --glow: 0 0 24px rgba(var(--glow-rgb), 0.32);
8+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
9+}
10+
11+/* Purple haze — a club rig on a violet wash with a magenta second gel. */
12+:root,
13+:root[data-site-theme='haze'] {
14+ --bg: #0c0912;
15+ --panel: rgba(21, 16, 34, 0.72);
16+ --panel-2: rgba(30, 22, 48, 0.72);
17+ --border: #2f2450;
18+ --text: #ece7f6;
19+ --muted: #8f86ab;
20+ --accent: #b07cff;
21+ --accent-2: #ff8ede;
22+ --ok: #5fe0b0;
23+ --err: #ff6b8f;
24+ --on-accent: #140d20;
25+ --glow-rgb: 176, 124, 255;
26+ --haze:
27+ radial-gradient(70vw 55vh at 20% 0%, rgba(150, 95, 255, 0.5), transparent 70%),
28+ radial-gradient(50vw 45vh at 92% 12%, rgba(255, 105, 205, 0.26), transparent 68%),
29+ radial-gradient(85vw 60vh at 55% 108%, rgba(110, 70, 220, 0.38), transparent 72%);
30+}
31+
32+/* Ember — low warm wash, the room at 4am. */
33+:root[data-site-theme='ember'] {
34+ --bg: #100a09;
35+ --panel: rgba(31, 18, 15, 0.72);
36+ --panel-2: rgba(44, 25, 20, 0.72);
37+ --border: #4a2a20;
38+ --text: #f6e9e2;
39+ --muted: #ab938a;
40+ --accent: #ff9d5c;
41+ --accent-2: #ff6b8f;
42+ --ok: #d9d15f;
43+ --err: #ff5f6b;
44+ --on-accent: #1f0f08;
45+ --glow-rgb: 255, 157, 92;
46+ --haze:
47+ radial-gradient(70vw 55vh at 18% 0%, rgba(255, 120, 50, 0.42), transparent 70%),
48+ radial-gradient(50vw 45vh at 92% 12%, rgba(255, 55, 105, 0.26), transparent 66%),
49+ radial-gradient(85vw 60vh at 58% 108%, rgba(190, 65, 40, 0.34), transparent 72%);
50+}
51+
52+/* Laser — a single green beam cutting the smoke. */
53+:root[data-site-theme='laser'] {
54+ --bg: #06100c;
55+ --panel: rgba(10, 27, 21, 0.72);
56+ --panel-2: rgba(15, 38, 30, 0.72);
57+ --border: #1d4a3a;
58+ --text: #e2f6ec;
59+ --muted: #7fa695;
60+ --accent: #4fe6a0;
61+ --accent-2: #6fd9ff;
62+ --ok: #4fe6a0;
63+ --err: #ff7a8f;
64+ --on-accent: #04170f;
65+ --glow-rgb: 79, 230, 160;
66+ --haze:
67+ radial-gradient(70vw 55vh at 22% 0%, rgba(45, 220, 150, 0.36), transparent 70%),
68+ radial-gradient(50vw 45vh at 90% 12%, rgba(60, 190, 255, 0.22), transparent 66%),
69+ radial-gradient(85vw 60vh at 55% 108%, rgba(25, 150, 115, 0.32), transparent 72%);
70+}
71+
72+/* Blackout — house lights off, no wash at all. For when the glow is too much. */
73+:root[data-site-theme='blackout'] {
74+ --bg: #0b0b0d;
75+ --panel: rgba(20, 20, 24, 0.82);
76+ --panel-2: rgba(28, 28, 33, 0.82);
77+ --border: #32323a;
78+ --text: #e8e8ee;
79+ --muted: #8b8b98;
80+ --accent: #9aa4ff;
81+ --accent-2: #ff9ec4;
82+ --ok: #6bd68a;
83+ --err: #ff6b6b;
84+ --on-accent: #101018;
85+ --glow-rgb: 154, 164, 255;
86+ --haze: none;
87+}
88+
89+* { box-sizing: border-box; }
90+html, body { height: 100%; margin: 0; }
91+body { background: var(--bg); color: var(--text); }
92+
93+/* ---------- The haze ----------
94+ The signature: light in a club is in the air, not on the surfaces. One fixed
95+ atmospheric layer sits behind every panel; the panels are translucent, so the
96+ same wash reads through each one at a different strength. Nothing else in the
97+ UI carries a drop shadow — this layer is where the glow lives. */
98+body::before {
99+ content: '';
100+ position: fixed;
101+ /* Exactly the viewport: the gradients' percentage positions are relative to
102+ this box, so any inset would slide the beams off-screen. They fall off well
103+ before the edges, so nothing needs to bleed past it. */
104+ inset: 0;
105+ z-index: -1;
106+ pointer-events: none;
107+ background: var(--haze);
108+ transition: opacity 0.6s ease;
109+ opacity: 0.8;
110+}
111+
112+/* Lights follow the track: the haze only breathes while a pattern is running.
113+ Slow enough to read as atmosphere rather than a strobe. */
114+body.live::before {
115+ opacity: 1;
116+ animation: haze-breathe 6.4s ease-in-out infinite;
117+}
118+@keyframes haze-breathe {
119+ 0%, 100% { opacity: 0.82; transform: scale(1); }
120+ 50% { opacity: 1; transform: scale(1.045); }
121+}
122+@media (prefers-reduced-motion: reduce) {
123+ body.live::before { animation: none; }
124+}
125+
126+/* Visualizer layer. Strudel's draw context creates this canvas itself and
127+ prepends it to <body>, fixed over the whole viewport with no z-index of its
128+ own — so .punchcard(), .scope(), .pianoroll() and friends all draw here.
129+ Without an explicit layer it lands in the same stacking context as an
130+ unpositioned #app and paints inconsistently: over the panels, but under the
131+ bubbles (which are position: relative), depending only on source order.
132+ Pinning both settles it — the canvas sits above the haze and below the UI, so
133+ a visualization fills the transparent editor column and reads softly through
134+ the translucent chat and sidebar. Same arrangement strudel.cc uses.
135+ Note: the .code-light themes give #editor an opaque background (see below),
136+ which covers the canvas in the editor column. */
137+#test-canvas {
138+ z-index: 0;
139+ /* Strudel draws these at full strength, which is right on a bare page but
140+ here puts near-white punchcard cells directly under the code. Screen blend
141+ drops the dark half of every visualizer out entirely and lets the lit half
142+ glow through the background, so it reads as part of the room's lighting
143+ rather than a sheet laid over the editor. The opacity is what keeps the
144+ code legible on top of a busy frame. */
145+ opacity: 0.55;
146+ mix-blend-mode: screen;
147+}
148+/* Screen blows out to white against a light code theme — darken instead so the
149+ visualizer still registers. */
150+body.code-light #test-canvas {
151+ mix-blend-mode: multiply;
152+}
153+
154+#app {
155+ position: relative;
156+ z-index: 1;
157+ display: grid;
158+ /* minmax(0, 1fr) lets the editor column shrink below its content width
159+ (CodeMirror scrolls internally) instead of pushing the chat off-screen. */
160+ grid-template-columns: 232px minmax(0, 1fr) 420px;
161+ height: 100vh;
162+}
163+/* Collapsed: the sidebar keeps its grid slot at zero width — `display: none`
164+ would drop it from the grid and slide the editor into that empty column.
165+ `visibility: hidden` also takes its buttons out of the tab order. */
166+#app.sidebar-collapsed { grid-template-columns: 0 minmax(0, 1fr) 420px; }
167+#app.sidebar-collapsed #sidebar { visibility: hidden; border-right: none; }
168+
169+section,
170+aside {
171+ display: flex;
172+ flex-direction: column;
173+ min-height: 0;
174+}
175+#ide { border-right: 1px solid var(--border); }
176+#chat { background: var(--panel); backdrop-filter: blur(12px); }
177+
178+.pane-header {
179+ display: flex;
180+ align-items: center;
181+ justify-content: space-between;
182+ gap: 12px;
183+ /* Both panes' headers share this height so their bottom borders line up. */
184+ min-height: 57px;
185+ padding: 12px 16px;
186+ border-bottom: 1px solid var(--border);
187+ background: var(--panel-2);
188+ backdrop-filter: blur(12px);
189+}
190+.logo { font-weight: 700; letter-spacing: 0.2px; }
191+.subtitle { color: var(--muted); font-size: 13px; }
192+
193+/* Editor — StrudelMirror mounts its CodeMirror inside #editor */
194+#editor { flex: 1; min-height: 0; overflow: auto; position: relative; }
195+/* The code pane shares the site's background instead of the code theme's, so
196+ the haze runs unbroken across all three panes. StrudelMirror sets
197+ `background-color: var(--background)` inline and defines --background on
198+ <html> from JS, so only !important can clear it.
199+ Exception: a light code theme needs its own light background to stay
200+ readable — .code-light (set in themes.js) opts back out. */
201+body:not(.code-light) #editor { background-color: transparent !important; }
202+body:not(.code-light) #editor .cm-editor,
203+body:not(.code-light) #editor .cm-editor .cm-scroller,
204+body:not(.code-light) #editor .cm-gutters { background: transparent; }
205+#editor .cm-editor { height: 100%; font-size: 15px; }
206+#editor .cm-scroller { font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
207+
208+.status {
209+ display: flex;
210+ justify-content: space-between;
211+ align-items: center;
212+ gap: 12px;
213+ padding: 6px 16px;
214+ /* Rack-unit lettering: small, tracked, uppercase — the vernacular of the
215+ gear this thing stands in for. */
216+ font-size: 11px;
217+ letter-spacing: 0.09em;
218+ text-transform: uppercase;
219+ color: var(--muted);
220+ border-top: 1px solid var(--border);
221+ background: var(--panel-2);
222+ backdrop-filter: blur(12px);
223+}
224+#ide-status { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
225+#ide-status.ok { color: var(--ok); }
226+#ide-status.err { color: var(--err); }
227+
228+/* Buttons */
229+.transport { display: flex; gap: 8px; }
230+.btn {
231+ background: var(--panel);
232+ color: var(--text);
233+ border: 1px solid var(--border);
234+ border-radius: 8px;
235+ padding: 7px 14px;
236+ font-size: 13px;
237+ font-weight: 600;
238+ cursor: pointer;
239+ transition: background 0.15s, border-color 0.15s;
240+}
241+.btn:hover { background: var(--panel-2); border-color: var(--accent); }
242+.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
243+/* One transport button: mint "Play" when idle, pink "Stop" while running.
244+ Running is the only state that lights up — it is the one the room reacts to. */
245+.btn-transport { min-width: 92px; color: var(--ok); }
246+.btn-transport:hover { border-color: var(--ok); }
247+.btn-transport.playing {
248+ color: var(--err);
249+ border-color: var(--err);
250+ box-shadow: 0 0 20px color-mix(in srgb, var(--err) 30%, transparent);
251+}
252+.btn-send { background: var(--accent); color: var(--on-accent); border: none; }
253+.btn-send:hover { background: #c398ff; box-shadow: var(--glow); }
254+.btn-ghost { padding: 5px 9px; font-size: 13px; color: var(--muted); }
255+.btn-ghost:hover { color: var(--text); border-color: var(--accent); }
256+
257+/* ---------- Session sidebar ---------- */
258+#sidebar {
259+ background: var(--panel-2);
260+ backdrop-filter: blur(12px);
261+ border-right: 1px solid var(--border);
262+ overflow: hidden;
263+}
264+.sidebar-header { background: none; }
265+.sidebar-header .logo { font-size: 14px; white-space: nowrap; }
266+.ide-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
267+
268+/* The song's name, in the pane that holds the song. Reads as a plain title
269+ until hovered, where a faint field outline reveals it's the rename control. */
270+.song-title {
271+ min-width: 0;
272+ max-width: 46ch;
273+ padding: 5px 9px;
274+ background: none;
275+ border: 1px solid transparent;
276+ border-radius: 8px;
277+ color: var(--text);
278+ font: 700 15px inherit;
279+ letter-spacing: 0.2px;
280+ text-align: left;
281+ white-space: nowrap;
282+ overflow: hidden;
283+ text-overflow: ellipsis;
284+ cursor: text;
285+}
286+.song-title:hover { border-color: var(--border); background: var(--panel); }
287+.song-title:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
288+
289+.song-title-edit {
290+ min-width: 0;
291+ width: 46ch;
292+ max-width: 100%;
293+ padding: 5px 9px;
294+ background: var(--bg);
295+ color: var(--text);
296+ border: 1px solid var(--accent);
297+ border-radius: 8px;
298+ font: 700 15px inherit;
299+ letter-spacing: 0.2px;
300+}
301+.song-title-edit:focus { outline: none; box-shadow: var(--glow); }
302+
303+.new-session {
304+ margin: 10px 10px 6px;
305+ padding: 9px 12px;
306+ display: flex;
307+ align-items: center;
308+ gap: 8px;
309+ background: var(--panel);
310+ color: var(--text);
311+ border: 1px solid var(--border);
312+ border-radius: 10px;
313+ font: 600 13px inherit;
314+ cursor: pointer;
315+ transition: background 0.15s, border-color 0.15s;
316+}
317+.new-session:hover { background: var(--bg); border-color: var(--accent); }
318+
319+.session-list {
320+ flex: 1;
321+ min-height: 0;
322+ overflow-y: auto;
323+ padding: 4px 6px 12px;
324+ display: flex;
325+ flex-direction: column;
326+ gap: 1px;
327+}
328+
329+.session-row {
330+ position: relative;
331+ display: flex;
332+ align-items: center;
333+ border-radius: 8px;
334+}
335+.session-row:hover { background: var(--panel); }
336+.session-row.active { background: var(--panel); }
337+/* The active track gets a lit channel-strip LED down its left edge. */
338+.session-row.active::before {
339+ content: '';
340+ position: absolute;
341+ left: 0;
342+ top: 6px;
343+ bottom: 6px;
344+ width: 2px;
345+ border-radius: 2px;
346+ background: var(--accent);
347+ box-shadow: 0 0 10px rgba(var(--glow-rgb), 0.7);
348+}
349+
350+.session-open {
351+ flex: 1;
352+ min-width: 0;
353+ padding: 9px 6px 9px 12px;
354+ background: none;
355+ border: none;
356+ color: var(--muted);
357+ font: 400 13px inherit;
358+ text-align: left;
359+ white-space: nowrap;
360+ overflow: hidden;
361+ text-overflow: ellipsis;
362+ cursor: pointer;
363+}
364+.session-row.active .session-open { color: var(--text); font-weight: 600; }
365+.session-open:focus-visible { outline: 1px solid var(--accent); outline-offset: -2px; }
366+
367+/* Per-row actions appear on hover / keyboard focus only */
368+.session-actions { display: flex; gap: 0; padding-right: 4px; opacity: 0; }
369+.session-row:hover .session-actions,
370+.session-actions:focus-within { opacity: 1; }
371+.session-actions .icon-btn { width: 24px; height: 24px; font-size: 12px; opacity: 0.7; }
372+
373+.session-rename {
374+ flex: 1;
375+ min-width: 0;
376+ margin: 4px;
377+ padding: 5px 8px;
378+ background: var(--bg);
379+ color: var(--text);
380+ border: 1px solid var(--accent);
381+ border-radius: 6px;
382+ font: 400 13px inherit;
383+}
384+.session-rename:focus { outline: none; }
385+
386+/* Theme control, pinned to the bottom of the sidebar. The model and key
387+ controls use the same chip + dropdown parts, but live in the chat header
388+ (see .chat-header below) — the two settings the chat depends on sit above
389+ the conversation they govern. */
390+.sidebar-footer { position: relative; padding: 8px; border-top: 1px solid var(--border); }
391+.sidebar-footer .key-btn { width: 100%; }
392+.key-btn {
393+ display: flex;
394+ align-items: center;
395+ gap: 8px;
396+ padding: 8px 10px;
397+ background: none;
398+ border: 1px solid transparent;
399+ border-radius: 8px;
400+ color: var(--muted);
401+ font: 400 12px inherit;
402+ text-align: left;
403+ cursor: pointer;
404+}
405+.key-btn:hover { background: var(--panel); color: var(--text); }
406+.key-btn #key-state { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
407+/* No key anywhere — nothing will work until one is set, so say so */
408+.key-btn.key-missing { color: var(--accent-2); border-color: var(--accent-2); }
409+
410+.key-panel {
411+ position: absolute;
412+ left: 8px;
413+ right: 8px;
414+ bottom: calc(100% - 2px);
415+ z-index: 20;
416+ padding: 12px;
417+ /* The panel tint over an opaque base: every other surface here is
418+ translucent so the haze reads through, but a popover floats over live
419+ content (a transcript, a track list) and needs to hide it. */
420+ background: linear-gradient(var(--panel-2), var(--panel-2)), var(--bg);
421+ border: 1px solid var(--border);
422+ border-radius: 10px;
423+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.5);
424+}
425+.key-label { display: block; margin-bottom: 6px; font-size: 12px; font-weight: 600; }
426+.key-input {
427+ width: 100%;
428+ padding: 8px;
429+ background: var(--bg);
430+ color: var(--text);
431+ border: 1px solid var(--border);
432+ border-radius: 8px;
433+ font: 400 12px ui-monospace, monospace;
434+}
435+.key-input:focus { outline: none; border-color: var(--accent); }
436+.key-note { margin: 8px 0 10px; font-size: 11px; line-height: 1.4; color: var(--muted); }
437+.key-label-spaced { margin-top: 12px; }
438+
439+.key-select {
440+ width: 100%;
441+ padding: 7px 8px;
442+ background: var(--bg);
443+ color: var(--text);
444+ border: 1px solid var(--border);
445+ border-radius: 8px;
446+ font: 400 12px inherit;
447+ cursor: pointer;
448+}
449+.key-select:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--glow); }
450+/* Native option lists paint on the OS surface, not ours — keep them legible. */
451+.key-select option { background: var(--bg); color: var(--text); }
452+.key-actions { display: flex; gap: 6px; }
453+.key-actions .btn { flex: 1; padding: 7px 10px; font-size: 12px; }
454+
455+/* ---------- Chat header ----------
456+ Two chips sharing the row, each opening its panel downward over the
457+ transcript. Anchored on the header itself so the panels line up with the
458+ chips rather than with the chat column's edges. */
459+.chat-header {
460+ position: relative;
461+ /* The header's backdrop-filter makes it a stacking context, so the panels'
462+ own z-index can't lift them past the transcript — the header has to be
463+ raised as a whole (bubbles are positioned, hence in the same layer). */
464+ z-index: 5;
465+ justify-content: flex-start;
466+ gap: 8px;
467+}
468+.chat-header .key-btn {
469+ flex: 1;
470+ min-width: 0;
471+ border-color: var(--border);
472+ background: var(--panel);
473+}
474+.chat-header .key-btn:hover { background: var(--bg); color: var(--text); }
475+/* Restated here: the border-color above would otherwise win on source order
476+ and swallow the "no key set" warning state. */
477+.chat-header .key-btn.key-missing { color: var(--accent-2); border-color: var(--accent-2); }
478+.chat-header .key-btn span:last-child {
479+ min-width: 0;
480+ overflow: hidden;
481+ text-overflow: ellipsis;
482+ white-space: nowrap;
483+}
484+.chat-header .key-panel {
485+ top: calc(100% - 1px);
486+ bottom: auto;
487+ left: 16px;
488+ right: 16px;
489+}
490+
491+/* Quiet icon buttons — they surface when their container is hovered */
492+.icon-btn {
493+ width: 30px;
494+ height: 30px;
495+ display: grid;
496+ place-items: center;
497+ background: none;
498+ border: none;
499+ border-radius: 8px;
500+ color: var(--muted);
501+ font-size: 15px;
502+ line-height: 1;
503+ cursor: pointer;
504+ opacity: 0.5;
505+ transition: opacity 0.15s, background 0.15s, color 0.15s;
506+}
507+.sidebar-header:hover .icon-btn,
508+.icon-btn:focus-visible { opacity: 1; }
509+.icon-btn:hover { background: var(--panel); color: var(--text); }
510+.icon-btn[hidden] { display: none; } /* display:grid above would otherwise win */
511+.icon-btn.danger:hover { color: var(--err); }
512+
513+/* Chat */
514+#messages {
515+ flex: 1;
516+ min-height: 0;
517+ overflow-y: auto;
518+ padding: 16px;
519+ display: flex;
520+ flex-direction: column;
521+ gap: 12px;
522+}
523+.bubble {
524+ max-width: 90%;
525+ padding: 10px 14px;
526+ border-radius: 14px;
527+ font-size: 14px;
528+ line-height: 1.45;
529+ white-space: pre-wrap;
530+ word-wrap: break-word;
531+}
532+.bubble.user {
533+ align-self: flex-end;
534+ background: var(--accent);
535+ color: var(--on-accent);
536+ border-bottom-right-radius: 4px;
537+ box-shadow: 0 0 22px rgba(var(--glow-rgb), 0.22);
538+}
539+.bubble.assistant {
540+ align-self: flex-start;
541+ background: var(--panel-2);
542+ border: 1px solid var(--border);
543+ border-bottom-left-radius: 4px;
544+}
545+.bubble.err { color: var(--err); border-color: var(--err); }
546+
547+/* Revert control — sits just outside the right edge of an assistant bubble */
548+.bubble.assistant { position: relative; }
549+.bubble .revert {
550+ position: absolute;
551+ right: -30px;
552+ top: 50%;
553+ transform: translateY(-50%);
554+ width: 24px;
555+ height: 24px;
556+ padding: 0;
557+ display: grid;
558+ place-items: center;
559+ background: transparent;
560+ color: var(--muted);
561+ border: 1px solid transparent;
562+ border-radius: 50%;
563+ font-size: 13px;
564+ line-height: 1;
565+ cursor: pointer;
566+ opacity: 0;
567+ transition: opacity 0.15s, color 0.15s, border-color 0.15s;
568+}
569+.bubble.assistant:hover .revert,
570+.bubble .revert:focus-visible { opacity: 1; }
571+.bubble .revert:hover { color: var(--accent-2); border-color: var(--accent-2); }
572+
573+/* Time travel — every message that carries a pattern is a point on the song's
574+ timeline, and clicking it takes the editor there. Kept deliberately quiet:
575+ the whole transcript is clickable, so a loud affordance on each bubble would
576+ read as noise. The lift on hover is the hint; the ring is "you are here". */
577+.bubble.travelable {
578+ cursor: pointer;
579+ transition: transform 0.15s, box-shadow 0.15s, outline-color 0.15s;
580+ outline: 1.5px solid transparent;
581+ outline-offset: 2px;
582+}
583+.bubble.travelable:hover { transform: translateY(-1px); }
584+.bubble.travelable:focus-visible { outline-color: var(--accent-2); }
585+.bubble.travelled {
586+ outline-color: var(--accent-2);
587+ box-shadow: 0 0 18px rgba(var(--glow-rgb), 0.35);
588+}
589+
590+.undo-link {
591+ background: none;
592+ border: none;
593+ padding: 0;
594+ font: inherit;
595+ color: var(--accent);
596+ cursor: pointer;
597+ text-decoration: underline;
598+}
599+
600+/* Animated "Claude is thinking" indicator */
601+.bubble.typing {
602+ display: flex;
603+ gap: 5px;
604+ align-items: center;
605+ padding: 14px 16px;
606+}
607+.bubble.typing .dot {
608+ width: 7px;
609+ height: 7px;
610+ border-radius: 50%;
611+ background: var(--muted);
612+ animation: typing-bounce 1.2s infinite ease-in-out both;
613+}
614+.bubble.typing .dot:nth-child(2) { animation-delay: 0.15s; }
615+.bubble.typing .dot:nth-child(3) { animation-delay: 0.3s; }
616+@keyframes typing-bounce {
617+ 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
618+ 40% { transform: translateY(-5px); opacity: 1; }
619+}
620+
621+#chat-form {
622+ padding: 12px;
623+ border-top: 1px solid var(--border);
624+ background: var(--panel-2);
625+ backdrop-filter: blur(12px);
626+}
627+.composer { display: flex; gap: 8px; }
628+#chat-input {
629+ flex: 1;
630+ resize: none;
631+ background: var(--bg);
632+ color: var(--text);
633+ border: 1px solid var(--border);
634+ border-radius: 8px;
635+ padding: 10px;
636+ font-size: 14px;
637+ line-height: 1.45;
638+ font-family: inherit;
639+ overflow-y: auto;
640+}
641+#chat-input:focus {
642+ outline: none;
643+ border-color: var(--accent);
644+ box-shadow: 0 0 0 1px rgba(var(--glow-rgb), 0.35), var(--glow);
645+}
646+#chat-input::placeholder { color: var(--muted); }
647+
648+/* ---------- System prompt editor ----------
649+ A modal, not a popover: the prompt is a few hundred lines of Markdown and
650+ needs most of the window to be readable. */
651+.key-btn.prompt-custom { color: var(--accent); }
652+
653+.modal {
654+ position: fixed;
655+ inset: 0;
656+ z-index: 100;
657+ display: grid;
658+ place-items: center;
659+ padding: 24px;
660+ background: rgba(0, 0, 0, 0.55);
661+ backdrop-filter: blur(2px);
662+}
663+.modal[hidden] { display: none; }
664+
665+.modal-card {
666+ display: flex;
667+ flex-direction: column;
668+ width: min(900px, 100%);
669+ height: min(80vh, 100%);
670+ padding: 16px;
671+ background: linear-gradient(var(--panel-2), var(--panel-2)), var(--bg);
672+ border: 1px solid var(--border);
673+ border-radius: 12px;
674+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
675+}
676+.modal-header {
677+ display: flex;
678+ align-items: center;
679+ justify-content: space-between;
680+ margin-bottom: 10px;
681+}
682+.modal-header h2 { margin: 0; font-size: 14px; font-weight: 600; }
683+/* The close button lives in a header that isn't hovered as a whole, so it can't
684+ rely on the usual reveal-on-hover of .icon-btn. */
685+.modal-header .icon-btn { opacity: 1; }
686+
687+.prompt-text {
688+ flex: 1;
689+ min-height: 0;
690+ width: 100%;
691+ padding: 12px;
692+ resize: none;
693+ background: var(--bg);
694+ color: var(--text);
695+ border: 1px solid var(--border);
696+ border-radius: 8px;
697+ font: 400 12px/1.6 ui-monospace, monospace;
698+ tab-size: 2;
699+}
700+.prompt-text:focus { outline: none; border-color: var(--accent); box-shadow: var(--glow); }
701+.modal-card .key-actions { justify-content: flex-end; }
702+.modal-card .key-actions .btn { flex: 0 0 auto; }
addedsrc/themes.js+69 −0
1+// Two independent theme axes:
2+// • the site theme — the room's lighting (palette + haze), defined in style.css
3+// as :root[data-site-theme='…'] blocks;
4+// • the code theme — one of Strudel's CodeMirror themes, for syntax colors.
5+// They are separate because the code pane deliberately drops its own background
6+// and sits in the site's haze, so only a code theme's *colors* usually apply.
7+import { themes as codeThemes, settings as codeThemeSettings } from '@strudel/codemirror';
8+
9+// Each site theme names the code theme whose syntax colors sit best in its light.
10+export const SITE_THEMES = [
11+ { id: 'haze', label: 'Purple haze', code: 'aura' },
12+ { id: 'ember', label: 'Ember', code: 'monokai' },
13+ { id: 'laser', label: 'Laser', code: 'strudelTheme' },
14+ { id: 'blackout', label: 'Blackout', code: 'vscodeDark' },
15+];
16+
17+export const CODE_THEMES = Object.keys(codeThemes).sort((a, b) => a.localeCompare(b));
18+
19+// A code theme that expects a light background can't sit in a dark haze.
20+const isLight = (code) => Boolean(codeThemeSettings[code]?.light);
21+
22+const KEY = 'strudel-claude-theme';
23+const DEFAULT = { site: 'haze', code: 'aura' };
24+
25+function load() {
26+ try {
27+ const saved = JSON.parse(localStorage.getItem(KEY) || '{}');
28+ return {
29+ site: SITE_THEMES.some((t) => t.id === saved.site) ? saved.site : DEFAULT.site,
30+ code: CODE_THEMES.includes(saved.code) ? saved.code : DEFAULT.code,
31+ };
32+ } catch {
33+ return { ...DEFAULT };
34+ }
35+}
36+
37+let current = load();
38+export const currentTheme = () => ({ ...current });
39+
40+// `editor` is the StrudelMirror instance; it owns the CodeMirror theme extension.
41+function apply(editor) {
42+ document.documentElement.dataset.siteTheme = current.site;
43+ document.body.classList.toggle('code-light', isLight(current.code));
44+ editor.setTheme(current.code);
45+ try {
46+ localStorage.setItem(KEY, JSON.stringify(current));
47+ } catch {
48+ /* storage blocked — the theme still applies, it just won't persist */
49+ }
50+}
51+
52+export function setSiteTheme(editor, id) {
53+ const site = SITE_THEMES.find((t) => t.id === id);
54+ if (!site) return;
55+ // Switching rigs also switches to that rig's paired code theme; picking a code
56+ // theme afterwards overrides it.
57+ current = { site: site.id, code: site.code };
58+ apply(editor);
59+}
60+
61+export function setCodeTheme(editor, code) {
62+ if (!CODE_THEMES.includes(code)) return;
63+ current = { ...current, code };
64+ apply(editor);
65+}
66+
67+export function initThemes(editor) {
68+ apply(editor);
69+}
addedtui/strudel-tui.py+897 −0
1+#!/usr/bin/env python3
2+"""strudel-tui — watch a Strudel×Claude pattern play, and talk to it, from a terminal.
3+
4+ curl -s http://localhost:8787/tui.py | python3 - localhost:8787
5+ # or, to keep a copy you can read and edit:
6+ curl -sO http://localhost:8787/tui.py && python3 tui.py localhost:8787
7+
8+Connects to the app's /ws timing hub and draws three things: the pattern's
9+source code with the characters that produced each event flashing as it sounds,
10+the chat transcript, and a prompt line. A prompt typed here runs through the
11+same code path as the composer in the page, so the browser and the terminal
12+share one conversation.
13+
14+Python 3.8+, standard library only. No pip install, one file, meant to be read:
15+the WebSocket client is ~120 lines at the top, the drawing is the rest.
16+
17+Keys
18+ NORMAL q quit · i or Enter to write · j/k scroll code · J/K scroll chat
19+ f follow the sounding code · r redraw
20+ WRITING Enter send · Esc back to NORMAL · ^U clear · ^W delete word
21+"""
22+
23+import base64
24+import hashlib
25+import json
26+import os
27+import queue
28+import re
29+import select
30+import signal
31+import socket
32+import ssl
33+import sys
34+import termios
35+import textwrap
36+import threading
37+import time
38+import tty
39+from urllib.parse import urlparse
40+
41+# ---------------------------------------------------------------- websocket --
42+
43+GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
44+
45+OP_CONT, OP_TEXT, OP_BIN, OP_CLOSE, OP_PING, OP_PONG = 0x0, 0x1, 0x2, 0x8, 0x9, 0xA
46+
47+
48+class WSError(Exception):
49+ pass
50+
51+
52+class WebSocket:
53+ """A minimal RFC 6455 client: connect, receive text frames, send, close.
54+
55+ Enough of the protocol to talk to the hub and no more. Pings are answered,
56+ because a server that pings will hang up on a client that doesn't. Writes
57+ are serialised: the reader thread sends pongs while the input loop sends
58+ prompts, and two interleaved frames on one socket is a protocol error.
59+ """
60+
61+ def __init__(self, url, timeout=10):
62+ u = urlparse(url)
63+ secure = u.scheme == "wss"
64+ host = u.hostname or "localhost"
65+ port = u.port or (443 if secure else 80)
66+ path = u.path or "/"
67+ if u.query:
68+ path += "?" + u.query
69+
70+ self._wlock = threading.Lock()
71+ self.sock = socket.create_connection((host, port), timeout=timeout)
72+ if secure:
73+ ctx = ssl.create_default_context()
74+ self.sock = ctx.wrap_socket(self.sock, server_hostname=host)
75+
76+ key = base64.b64encode(os.urandom(16)).decode()
77+ hosthdr = host if port in (80, 443) else f"{host}:{port}"
78+ req = (
79+ f"GET {path} HTTP/1.1\r\n"
80+ f"Host: {hosthdr}\r\n"
81+ "Upgrade: websocket\r\n"
82+ "Connection: Upgrade\r\n"
83+ f"Sec-WebSocket-Key: {key}\r\n"
84+ "Sec-WebSocket-Version: 13\r\n"
85+ "\r\n"
86+ )
87+ self.sock.sendall(req.encode())
88+
89+ head = b""
90+ while b"\r\n\r\n" not in head:
91+ chunk = self.sock.recv(4096)
92+ if not chunk:
93+ raise WSError("connection closed during handshake")
94+ head += chunk
95+ if len(head) > 65536:
96+ raise WSError("handshake response too large")
97+ header_blob, _, rest = head.partition(b"\r\n\r\n")
98+ lines = header_blob.decode("latin-1").split("\r\n")
99+ if "101" not in lines[0]:
100+ raise WSError(f"server refused the upgrade: {lines[0]}")
101+ accept = ""
102+ for line in lines[1:]:
103+ k, _, v = line.partition(":")
104+ if k.strip().lower() == "sec-websocket-accept":
105+ accept = v.strip()
106+ expect = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
107+ if accept != expect:
108+ raise WSError("bad Sec-WebSocket-Accept")
109+
110+ self._buf = rest # bytes that arrived glued to the handshake response
111+ self.sock.settimeout(None)
112+
113+ def _read(self, n):
114+ while len(self._buf) < n:
115+ chunk = self.sock.recv(65536)
116+ if not chunk:
117+ raise WSError("connection closed")
118+ self._buf += chunk
119+ out, self._buf = self._buf[:n], self._buf[n:]
120+ return out
121+
122+ def recv(self):
123+ """Return the next complete text message, or None for a close frame."""
124+ payload = b""
125+ opcode = None
126+ while True:
127+ b0, b1 = self._read(2)
128+ fin = b0 & 0x80
129+ op = b0 & 0x0F
130+ masked = b1 & 0x80
131+ length = b1 & 0x7F
132+ if length == 126:
133+ length = int.from_bytes(self._read(2), "big")
134+ elif length == 127:
135+ length = int.from_bytes(self._read(8), "big")
136+ mask = self._read(4) if masked else None
137+ data = self._read(length) if length else b""
138+ if mask:
139+ data = bytes(c ^ mask[i % 4] for i, c in enumerate(data))
140+
141+ if op == OP_PING:
142+ self._send_frame(OP_PONG, data)
143+ continue
144+ if op == OP_PONG:
145+ continue
146+ if op == OP_CLOSE:
147+ try:
148+ self._send_frame(OP_CLOSE, data[:2])
149+ except OSError:
150+ pass
151+ return None
152+
153+ if op != OP_CONT:
154+ opcode = op
155+ payload += data
156+ if fin:
157+ if opcode == OP_TEXT:
158+ return payload.decode("utf-8", "replace")
159+ payload, opcode = b"", None # ignore binary, keep reading
160+
161+ def send_text(self, text):
162+ self._send_frame(OP_TEXT, text.encode("utf-8"))
163+
164+ def _send_frame(self, opcode, data=b""):
165+ mask = os.urandom(4)
166+ masked = bytes(c ^ mask[i % 4] for i, c in enumerate(data))
167+ head = bytes([0x80 | opcode])
168+ n = len(data)
169+ if n < 126:
170+ head += bytes([0x80 | n])
171+ elif n < 65536:
172+ head += bytes([0x80 | 126]) + n.to_bytes(2, "big")
173+ else:
174+ head += bytes([0x80 | 127]) + n.to_bytes(8, "big")
175+ with self._wlock:
176+ self.sock.sendall(head + mask + masked)
177+
178+ def close(self):
179+ try:
180+ self._send_frame(OP_CLOSE, b"\x03\xe8")
181+ except OSError:
182+ pass
183+ try:
184+ self.sock.close()
185+ except OSError:
186+ pass
187+
188+
189+# -------------------------------------------------------------------- state --
190+
191+
192+class State:
193+ """What we know about the pattern, and where the playhead is *now*.
194+
195+ The hub sends a window of upcoming events twice a second, not a frame per
196+ redraw. So the clock is ours: `cycle()` extrapolates from the last reported
197+ position at the reported cycles-per-second, which keeps the animation
198+ smooth at whatever framerate we draw at, over any link.
199+ """
200+
201+ def __init__(self):
202+ self.lock = threading.Lock()
203+ self.code = ""
204+ self.lines = [""]
205+ self.line_starts = [0]
206+ self.haps = [] # [{b, e, l: [[start, end]], s, c}]
207+ self.turns = [] # [{role, content}]
208+ self.got_chat = False # has the producer ever sent a transcript at all?
209+ # Prompts we have sent that the browser hasn't echoed back yet, as
210+ # {text, at}. A prompt the page never picks up — an old tab with no
211+ # handler for it, most often — would otherwise sit here saying
212+ # "sending…" forever, which is the least useful thing it could say.
213+ self.pending = []
214+ self.notice = None
215+ self.playing = False
216+ self.cps = 0.5
217+ self.connected = False
218+ self.source = False # is a browser actually feeding the hub?
219+ self.standby = 0 # other tabs open but not the live source
220+ self.status = "connecting…"
221+ self._base_cycle = 0.0 # cycle position at _base_mono
222+ self._base_mono = time.monotonic()
223+ self._slots = {} # source range -> colour slot, by first sighting
224+
225+ def slot(self, rng):
226+ """A stable colour slot for a source range, in order of first sighting.
227+
228+ Stable so a voice keeps its colour cycle to cycle; ordinal so the
229+ voices on screen come out a golden angle apart rather than wherever a
230+ hash of their offsets happened to land.
231+ """
232+ key = (rng[0], rng[1])
233+ with self.lock: # set_code clears this from the reader thread
234+ s = self._slots.get(key)
235+ if s is None:
236+ s = self._slots[key] = len(self._slots)
237+ return s
238+
239+ def set_code(self, code):
240+ with self.lock:
241+ if code != self.code:
242+ self._slots.clear() # offsets mean something else now
243+ self.code = code
244+ self.lines = code.split("\n")
245+ starts, pos = [], 0
246+ for line in self.lines:
247+ starts.append(pos)
248+ pos += len(line) + 1
249+ self.line_starts = starts
250+
251+ def set_turns(self, turns):
252+ with self.lock:
253+ self.turns = turns
254+ self.got_chat = True
255+ # Drop the local echo of anything the browser has now taken up, so
256+ # a prompt doesn't appear twice for the half-second in between.
257+ said = {t.get("content") for t in turns if t.get("role") == "user"}
258+ self.pending = [p for p in self.pending if p["text"] not in said]
259+
260+ def set_haps(self, now, cps, haps):
261+ with self.lock:
262+ self.haps = haps
263+ self.cps = cps or self.cps
264+ mono = time.monotonic()
265+ here = self._base_cycle + (mono - self._base_mono) * self.cps
266+ drift = now - here
267+ if abs(drift) > 0.25:
268+ self._base_cycle = now # a jump: eval, restart, seek
269+ else:
270+ # Nudge toward the truth instead of snapping to it, so ordinary
271+ # network jitter doesn't make the playhead stutter backwards.
272+ self._base_cycle = here + drift * 0.3
273+ self._base_mono = mono
274+
275+ def cycle(self):
276+ with self.lock:
277+ if not self.playing:
278+ return self._base_cycle
279+ return self._base_cycle + (time.monotonic() - self._base_mono) * self.cps
280+
281+ def snapshot(self):
282+ with self.lock:
283+ return self.lines, list(self.haps)
284+
285+ def chat(self):
286+ with self.lock:
287+ return list(self.turns), list(self.pending), self.got_chat
288+
289+ def thinking(self):
290+ """A user turn with no reply yet means the request is still out."""
291+ with self.lock:
292+ return bool(self.turns) and self.turns[-1].get("role") == "user"
293+
294+ def offset_to_rowcol(self, offset):
295+ """Character offset in `code` -> (row, col), both 0-based."""
296+ lo, hi = 0, len(self.line_starts) - 1
297+ while lo < hi:
298+ mid = (lo + hi + 1) // 2
299+ if self.line_starts[mid] <= offset:
300+ lo = mid
301+ else:
302+ hi = mid - 1
303+ return lo, offset - self.line_starts[lo]
304+
305+
306+# -------------------------------------------------------------------- color --
307+
308+CSS_COLORS = {
309+ "white": (255, 255, 255), "black": (0, 0, 0), "red": (255, 0, 0),
310+ "green": (0, 128, 0), "lime": (0, 255, 0), "blue": (0, 0, 255),
311+ "yellow": (255, 255, 0), "cyan": (0, 255, 255), "aqua": (0, 255, 255),
312+ "magenta": (255, 0, 255), "fuchsia": (255, 0, 255), "orange": (255, 165, 0),
313+ "purple": (128, 0, 128), "pink": (255, 192, 203), "gray": (128, 128, 128),
314+ "grey": (128, 128, 128), "salmon": (250, 128, 114), "gold": (255, 215, 0),
315+ "steelblue": (70, 130, 180), "tomato": (255, 99, 71),
316+ "turquoise": (64, 224, 208), "violet": (238, 130, 238),
317+}
318+
319+# Hues are handed out by order of appearance, one golden angle apart, which
320+# keeps whatever voices are actually on screen as far apart on the wheel as
321+# they can be. Hashing the character offset instead looks like it would work
322+# and doesn't: source lines land at evenly-spaced offsets, so any hash can
323+# happen to drop three voices into the same 20° of the wheel, and a stride
324+# whose product with the multiplier is ≈0 mod 360 makes every voice identical.
325+GOLDEN_ANGLE = 137.508
326+
327+# How long a sent prompt may go unacknowledged before the terminal stops
328+# claiming it is on its way.
329+PROMPT_TIMEOUT_S = 8.0
330+
331+
332+def hsl_to_rgb(h, s, ll):
333+ def f(n):
334+ k = (n + h / 30.0) % 12
335+ a = s * min(ll, 1 - ll)
336+ return round(255 * (ll - a * max(-1, min(k - 3, 9 - k, 1))))
337+ return f(0), f(8), f(4)
338+
339+
340+def parse_color(spec, fallback_seed=0):
341+ """A CSS colour from the pattern if it named one, else a stable hue."""
342+ if isinstance(spec, str):
343+ s = spec.strip().lower()
344+ if s in CSS_COLORS:
345+ return CSS_COLORS[s]
346+ m = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6})", s)
347+ if m:
348+ h = m.group(1)
349+ if len(h) == 3:
350+ h = "".join(c * 2 for c in h)
351+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
352+ m = re.fullmatch(r"rgba?\(([^)]+)\)", s)
353+ if m:
354+ parts = [p.strip() for p in m.group(1).replace("/", ",").split(",")]
355+ try:
356+ return tuple(int(float(p)) for p in parts[:3])
357+ except ValueError:
358+ pass
359+ return hsl_to_rgb(GOLDEN_ANGLE * fallback_seed % 360, 0.72, 0.62)
360+
361+
362+def blend(fg, bg, alpha):
363+ return tuple(round(f * alpha + b * (1 - alpha)) for f, b in zip(fg, bg))
364+
365+
366+def luminance(c):
367+ return (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) / 255
368+
369+
370+# ------------------------------------------------------------------ drawing --
371+
372+ESC = "\x1b"
373+RESET = f"{ESC}[0m"
374+
375+
376+def fg(c):
377+ return f"{ESC}[38;2;{c[0]};{c[1]};{c[2]}m"
378+
379+
380+def bg(c):
381+ return f"{ESC}[48;2;{c[0]};{c[1]};{c[2]}m"
382+
383+
384+class Screen:
385+ def __init__(self, light=False):
386+ self.light = light
387+ self.base = (245, 245, 245) if light else (12, 14, 20)
388+ self.dim = (120, 124, 138) if light else (108, 114, 132)
389+ self.text = (40, 42, 50) if light else (196, 202, 216)
390+ self.accent = (0, 150, 125) if light else (120, 230, 200)
391+ self.you = (200, 120, 30) if light else (240, 190, 120)
392+ self.warn = (190, 90, 40) if light else (235, 150, 100)
393+ self.claude = (140, 90, 200) if light else (190, 160, 245)
394+ self.rows, self.cols = 24, 80
395+ self.measure()
396+
397+ def measure(self):
398+ try:
399+ self.cols, self.rows = os.get_terminal_size()
400+ except OSError:
401+ pass
402+
403+
404+# A row is a list of (text, fg, bg) cells; None means "the screen default".
405+# Panes build rows independently and get stitched side by side, so nothing may
406+# emit raw escapes until the whole row is known.
407+
408+def fit(cells, width):
409+ """Truncate or pad a row of cells to exactly `width` visible columns."""
410+ out, used = [], 0
411+ for text, f, b in cells:
412+ if used >= width:
413+ break
414+ t = text[: width - used]
415+ if t:
416+ out.append((t, f, b))
417+ used += len(t)
418+ if used < width:
419+ out.append((" " * (width - used), None, None))
420+ return out
421+
422+
423+def paint(cells, screen):
424+ parts, cur_f, cur_b = [], None, None
425+ for text, f, b in cells:
426+ f = f or screen.text
427+ b = b or screen.base
428+ if b != cur_b:
429+ parts.append(bg(b))
430+ cur_b = b
431+ if f != cur_f:
432+ parts.append(fg(f))
433+ cur_f = f
434+ parts.append(text)
435+ return "".join(parts) + f"{ESC}[K" + RESET
436+
437+
438+def hap_style(hap, now, screen, seed):
439+ """Colour + intensity for an event at the current instant.
440+
441+ Brightest on the attack and fading across the event's length, so a long
442+ note reads as decaying rather than as a static block — the same cue the
443+ browser gives, and the reason a screenshot of this looks like music.
444+ """
445+ b, e = hap["b"], hap["e"]
446+ dur = max(e - b, 1e-6)
447+ p = min(max((now - b) / dur, 0.0), 1.0)
448+ color = parse_color(hap.get("c"), seed)
449+ return blend(color, screen.base, 0.92 - 0.62 * p), color
450+
451+
452+def code_pane(state, screen, lines, spans, scroll, width, height):
453+ gutter = min(6, max(3, len(str(len(lines))) + 1))
454+ rows = []
455+ for i in range(height):
456+ row = scroll + i
457+ if row >= len(lines):
458+ rows.append(fit([], width))
459+ continue
460+ lit = spans.get(row, [])
461+ num = str(row + 1).rjust(gutter - 1)
462+ cells = [(num + " ", screen.accent if lit else screen.dim, None)]
463+ text = lines[row][: max(0, width - gutter)]
464+ if not lit:
465+ cells.append((text, screen.text, None))
466+ else:
467+ # Paint left to right, switching background at each span boundary.
468+ col = 0
469+ for c0, c1, bgc in sorted(lit, key=lambda s: s[0]):
470+ c0, c1 = max(c0, col), min(c1, len(text))
471+ if c1 <= c0:
472+ continue
473+ cells.append((text[col:c0], screen.text, None))
474+ ink = (18, 18, 22) if luminance(bgc) > 0.55 else (250, 250, 252)
475+ cells.append((text[c0:c1], ink, bgc))
476+ col = c1
477+ cells.append((text[col:], screen.text, None))
478+ rows.append(fit(cells, width))
479+ return rows
480+
481+
482+def chat_lines(state, screen, width):
483+ """The transcript as flat rows, wrapped to the pane."""
484+ turns, pending, got_chat = state.chat()
485+ body_w = max(8, width - 2)
486+ out = []
487+
488+ # An empty pane is ambiguous — a conversation that hasn't started looks
489+ # exactly like a page too old to send one. Say which. Falls through so a
490+ # prompt sent before the first transcript arrives still shows as pending.
491+ if not got_chat:
492+ if state.source:
493+ msg = ["the browser hasn't sent a transcript.",
494+ "if the page is open, reload it — a tab",
495+ "loaded before this feature existed only",
496+ "sends the pattern, not the chat."]
497+ else:
498+ msg = ["no browser connected."]
499+ out.append([("✦ no transcript", screen.dim, None)])
500+ out.extend([(" " + line, screen.dim, None)] for line in msg)
501+ out.append([])
502+ elif not turns:
503+ out.append([("✦ no messages yet — press i to write one", screen.dim, None)])
504+ out.append([])
505+ for turn in turns:
506+ role = turn.get("role")
507+ who, color = ("you", screen.you) if role == "user" else ("claude", screen.claude)
508+ out.append([(f"{'›' if role == 'user' else '✦'} {who}", color, None)])
509+ for para in (turn.get("content") or "").split("\n"):
510+ for line in textwrap.wrap(para, body_w) or [""]:
511+ out.append([(" " + line, screen.text, None)])
512+ out.append([])
513+ for item in pending:
514+ # The browser echoes the transcript back within ~500ms of accepting a
515+ # prompt, so silence well past that means it never arrived.
516+ stale = time.monotonic() - item["at"] > PROMPT_TIMEOUT_S
517+ color = screen.warn if stale else screen.dim
518+ out.append([("› you", color, None)])
519+ for line in textwrap.wrap(item["text"], body_w) or [""]:
520+ out.append([(" " + line, color, None)])
521+ if stale:
522+ out.append([(" not picked up by the browser — reload", screen.warn, None)])
523+ out.append([(" the page if it was open before you", screen.warn, None)])
524+ out.append([(" updated the app.", screen.warn, None)])
525+ else:
526+ out.append([(" sending…", screen.dim, None)])
527+ if state.thinking():
528+ # A user turn with no reply yet: the request is still in flight.
529+ dots = "." * (1 + int(time.monotonic() * 2) % 3)
530+ out.append([("✦ claude is thinking" + dots, screen.claude, None)])
531+ return out
532+
533+
534+def render(state, screen, ui):
535+ lines, haps = state.snapshot()
536+ now = state.cycle()
537+ width, height = screen.cols, screen.rows
538+
539+ # Which characters are sounding, and in what colour.
540+ spans, active = {}, []
541+ for hap in haps:
542+ if not (hap["b"] <= now < hap["e"]):
543+ continue
544+ for rng in hap.get("l", []):
545+ bgc, pure = hap_style(hap, now, screen, state.slot(rng))
546+ r0, c0 = state.offset_to_rowcol(rng[0])
547+ r1, c1 = state.offset_to_rowcol(rng[1])
548+ if r0 != r1: # a range spanning lines: clip to its first line
549+ c1 = len(lines[r0]) if r0 < len(lines) else c0
550+ spans.setdefault(r0, []).append((c0, c1, bgc))
551+ if hap.get("s"):
552+ active.append((pure, hap["s"]))
553+
554+ # Layout. Two panes side by side when there is room for both to be
555+ # readable, stacked when there isn't; the chat is never dropped, because
556+ # you cannot answer a question you cannot see.
557+ body_h = max(1, height - 4)
558+ side_by_side = width >= 100
559+ if side_by_side:
560+ code_w = max(46, int(width * 0.58))
561+ chat_w = max(20, width - 1 - code_w - 1)
562+ code_h = chat_h = body_h
563+ else:
564+ code_w = chat_w = width - 1
565+ chat_h = max(3, min(8, body_h // 3))
566+ code_h = max(1, body_h - chat_h - 1)
567+
568+ if ui.follow and spans:
569+ # Scroll only when nothing that is sounding is on screen at all.
570+ #
571+ # The obvious rule — keep the whole active range in view — thrashes: a
572+ # pattern whose voices span more lines than the pane cannot satisfy it,
573+ # so scrolling to the top pushes the bottom out, which scrolls back,
574+ # thirty times a second. Whether *something* audible is visible is a
575+ # condition the view can actually settle on.
576+ if not any(ui.scroll <= r < ui.scroll + code_h for r in spans):
577+ ui.scroll = max(0, min(min(spans) - 1, max(0, len(lines) - code_h)))
578+ ui.scroll = max(0, min(ui.scroll, max(0, len(lines) - 1)))
579+
580+ code_rows = code_pane(state, screen, lines, spans, ui.scroll, code_w, code_h)
581+
582+ chat_all = chat_lines(state, screen, chat_w)
583+ # Anchored to the bottom: the newest turn is the one you want, and
584+ # chat_scroll counts lines back from there.
585+ ui.chat_scroll = max(0, min(ui.chat_scroll, max(0, len(chat_all) - chat_h)))
586+ end = len(chat_all) - ui.chat_scroll
587+ window = chat_all[max(0, end - chat_h):end]
588+ chat_rows = [fit(r, chat_w) for r in window]
589+ chat_rows += [fit([], chat_w)] * (chat_h - len(chat_rows))
590+
591+ out = []
592+
593+ # --- header
594+ if not state.connected:
595+ dot, word, dotc = "●", state.status, (220, 90, 90)
596+ elif not state.source:
597+ dot, word, dotc = "○", "no browser connected", (200, 170, 80)
598+ elif state.playing:
599+ dot, word, dotc = "●", "playing", (110, 220, 140)
600+ else:
601+ dot, word, dotc = "○", "stopped", (150, 155, 170)
602+ # Another tab being open explains why the terminal isn't following the one
603+ # you're looking at, so say so rather than leaving it a mystery.
604+ if state.standby:
605+ word += f" (+{state.standby} idle tab{'s' if state.standby > 1 else ''})"
606+ bpm = state.cps * 60 * 4 # a cycle is conventionally a bar of 4 beats
607+ right = f"cycle {now:8.3f} {state.cps:.3f} cps {bpm:.0f} bpm "
608+ head = [(f" {dot} {word}", dotc, None)]
609+ pad = max(1, width - 1 - len(f" {dot} {word}") - len(right))
610+ head.append((" " * pad + right, screen.dim, None))
611+ # Every row is positioned explicitly. Letting a full-width line wrap on its
612+ # own is not portable — terminals defer the wrap, so the next write lands
613+ # in the wrong place on some of them.
614+ out.append(f"{ESC}[1;1H" + paint(fit(head, width - 1), screen))
615+ out.append(f"{ESC}[2;1H" + paint(fit([("─" * (width - 1), screen.dim, None)], width - 1), screen))
616+
617+ # --- body
618+ for i in range(body_h):
619+ row = f"{ESC}[{i + 3};1H"
620+ if side_by_side:
621+ cells = code_rows[i] + [("│", screen.dim, None)] + chat_rows[i]
622+ elif i < code_h:
623+ cells = code_rows[i]
624+ elif i == code_h:
625+ cells = [("─" * (width - 1), screen.dim, None)]
626+ else:
627+ cells = chat_rows[i - code_h - 1]
628+ out.append(row + paint(fit(cells, width - 1), screen))
629+
630+ # --- timeline: one cycle wide, with a marker for the playhead and ticks
631+ # where the upcoming events land. This is the "moving cursor".
632+ bar_w = max(10, width - 2)
633+ cycle_start = int(now // 1)
634+ ticks = [None] * bar_w
635+ for hap in haps:
636+ if cycle_start <= hap["b"] < cycle_start + 1:
637+ idx = int((hap["b"] - cycle_start) * bar_w)
638+ if 0 <= idx < bar_w and ticks[idx] is None:
639+ ticks[idx] = parse_color(hap.get("c"), state.slot(hap.get("l", [[0, 0]])[0]))
640+ head_i = int((now - cycle_start) * bar_w)
641+ bar = [(" ", None, None)]
642+ for i, c in enumerate(ticks):
643+ if i == head_i:
644+ bar.append(("┃", (255, 255, 255), None))
645+ elif c:
646+ bar.append(("╷", blend(c, screen.base, 0.75), None))
647+ else:
648+ bar.append(("·", blend(screen.dim, screen.base, 0.35), None))
649+ out.append(f"{ESC}[{height - 1};1H" + paint(fit(bar, width - 1), screen))
650+
651+ # --- last row: the prompt when writing, otherwise what is sounding
652+ if ui.mode == "input":
653+ # Scroll the field so the caret stays visible on a long prompt.
654+ field_w = max(10, width - 4)
655+ shown = ui.buf[-field_w:]
656+ line = [("› ", screen.accent, None), (shown, screen.text, None), (" ", screen.base, screen.text)]
657+ else:
658+ seen, line, used = set(), [(" ", None, None)], 1
659+ for color, label in active:
660+ if label in seen:
661+ continue
662+ seen.add(label)
663+ if used + len(label) + 1 > width - 34:
664+ break
665+ line.append((label + " ", blend(color, screen.base, 0.95), None))
666+ used += len(label) + 1
667+ hint = ui.note or "q quit · i write · j/k scroll · f follow"
668+ line.append((" " * max(1, width - 1 - used - len(hint)) + hint, screen.dim, None))
669+ out.append(f"{ESC}[{height};1H" + paint(fit(line, width - 1), screen))
670+
671+ sys.stdout.write("".join(out))
672+ sys.stdout.flush()
673+
674+
675+# ----------------------------------------------------------------- plumbing --
676+
677+
678+class UI:
679+ """Everything the drawing needs that isn't the pattern itself."""
680+
681+ def __init__(self):
682+ self.scroll = 0
683+ self.chat_scroll = 0
684+ self.follow = True
685+ self.mode = "normal" # or "input"
686+ self.buf = ""
687+ self.note = None
688+
689+
690+def reader_thread(url, state, conn):
691+ """Own the socket: connect, parse, and keep reconnecting if the app goes away."""
692+ backoff = 0.5
693+ while True:
694+ ws = None
695+ try:
696+ ws = WebSocket(url)
697+ conn["ws"] = ws
698+ with state.lock:
699+ state.connected = True
700+ state.status = "connected"
701+ backoff = 0.5
702+ while True:
703+ msg = ws.recv()
704+ if msg is None:
705+ break
706+ try:
707+ data = json.loads(msg)
708+ except ValueError:
709+ continue
710+ kind = data.get("type")
711+ if kind == "code":
712+ state.set_code(data.get("code", ""))
713+ elif kind == "haps":
714+ state.set_haps(data.get("now", 0.0), data.get("cps", 0.5), data.get("haps", []))
715+ elif kind == "chat":
716+ state.set_turns(data.get("turns", []))
717+ elif kind == "transport":
718+ with state.lock:
719+ state.playing = bool(data.get("playing"))
720+ state.cps = data.get("cps") or state.cps
721+ elif kind == "notice":
722+ with state.lock:
723+ state.notice = data.get("text")
724+ elif kind == "hello":
725+ with state.lock:
726+ state.source = bool(data.get("source"))
727+ state.standby = int(data.get("standby") or 0)
728+ elif kind == "source":
729+ with state.lock:
730+ state.source = bool(data.get("connected"))
731+ state.standby = int(data.get("standby") or 0)
732+ if not state.source:
733+ state.playing = False
734+ except (WSError, OSError, ssl.SSLError) as err:
735+ with state.lock:
736+ state.connected = False
737+ state.source = False
738+ state.playing = False
739+ state.status = f"reconnecting… ({err.__class__.__name__})"
740+ finally:
741+ conn["ws"] = None
742+ if ws:
743+ ws.close()
744+ time.sleep(backoff)
745+ backoff = min(backoff * 2, 5.0)
746+
747+
748+def send_prompt(state, conn, text):
749+ ws = conn.get("ws")
750+ if not ws:
751+ return "not connected — prompt not sent"
752+ try:
753+ ws.send_text(json.dumps({"type": "prompt", "text": text}))
754+ except OSError:
755+ return "send failed — prompt not sent"
756+ with state.lock:
757+ state.pending.append({"text": text, "at": time.monotonic()})
758+ return None
759+
760+
761+def handle_key(ch, state, conn, ui, screen):
762+ """Returns False to quit."""
763+ ui.note = None
764+ if ch in ("\x03",): # Ctrl+C, in either mode
765+ return False
766+
767+ if ui.mode == "input":
768+ if ch == ESC:
769+ ui.mode, ui.buf = "normal", ""
770+ elif ch in ("\r", "\n"):
771+ text = ui.buf.strip()
772+ ui.buf = ""
773+ ui.mode = "normal"
774+ if text:
775+ ui.note = send_prompt(state, conn, text) or "sent"
776+ elif ch in ("\x7f", "\b"):
777+ ui.buf = ui.buf[:-1]
778+ elif ch == "\x15": # Ctrl+U
779+ ui.buf = ""
780+ elif ch == "\x17": # Ctrl+W — delete the word before the caret
781+ stripped = ui.buf.rstrip()
782+ cut = stripped.rfind(" ")
783+ ui.buf = stripped[: cut + 1] if cut >= 0 else ""
784+ elif not ch.startswith(ESC):
785+ ui.buf += "".join(c for c in ch if c.isprintable())
786+ return True
787+
788+ body_h = max(1, screen.rows - 4)
789+ if ch == "q":
790+ return False
791+ elif ch in ("i", "\r", "\n"):
792+ ui.mode = "input"
793+ elif ch in ("j", f"{ESC}[B"):
794+ ui.scroll += 1
795+ ui.follow = False
796+ elif ch in ("k", f"{ESC}[A"):
797+ ui.scroll -= 1
798+ ui.follow = False
799+ elif ch == " ":
800+ ui.scroll += body_h
801+ ui.follow = False
802+ elif ch == "J":
803+ ui.chat_scroll = max(0, ui.chat_scroll - 1)
804+ elif ch == "K":
805+ ui.chat_scroll += 1
806+ elif ch == "f":
807+ ui.follow = not ui.follow
808+ ui.note = "follow on" if ui.follow else "follow off"
809+ elif ch == "r":
810+ screen.measure()
811+ sys.stdout.write(f"{ESC}[2J")
812+ return True
813+
814+
815+def normalize_url(arg):
816+ """Accept localhost:8787, http(s)://host, ws(s)://host/ws — all mean the hub."""
817+ if not arg:
818+ arg = "localhost:8787"
819+ if "://" not in arg:
820+ arg = "ws://" + arg
821+ u = urlparse(arg)
822+ scheme = {"http": "ws", "https": "wss"}.get(u.scheme, u.scheme)
823+ if scheme not in ("ws", "wss"):
824+ raise SystemExit(f"don't know how to connect to {arg!r}")
825+ path = u.path if u.path not in ("", "/") else "/ws"
826+ netloc = u.netloc or "localhost:8787"
827+ return f"{scheme}://{netloc}{path}"
828+
829+
830+def main(argv):
831+ args = [a for a in argv[1:] if not a.startswith("-")]
832+ flags = {a for a in argv[1:] if a.startswith("-")}
833+ if flags & {"-h", "--help"}:
834+ print(__doc__)
835+ return 0
836+ url = normalize_url(args[0] if args else os.environ.get("STRUDEL_WS", ""))
837+ fps = 30.0
838+
839+ state = State()
840+ conn = {"ws": None}
841+ threading.Thread(target=reader_thread, args=(url, state, conn), daemon=True).start()
842+
843+ screen = Screen(light="--light" in flags)
844+ ui = UI()
845+
846+ resized = threading.Event()
847+ signal.signal(signal.SIGWINCH, lambda *_: resized.set())
848+
849+ # `curl … | python3 -` feeds the *script* in on stdin, so the keyboard has
850+ # to come from the terminal directly or nothing would be typeable in the
851+ # one command this file advertises.
852+ keys = sys.stdin
853+ if not keys.isatty():
854+ try:
855+ keys = open("/dev/tty")
856+ except OSError:
857+ pass
858+
859+ fd = keys.fileno()
860+ isatty = keys.isatty()
861+ old = termios.tcgetattr(fd) if isatty else None
862+ try:
863+ if isatty:
864+ tty.setcbreak(fd)
865+ sys.stdout.write(f"{ESC}[?1049h{ESC}[?25l{ESC}[2J") # alt screen, no cursor
866+ while True:
867+ if resized.is_set():
868+ resized.clear()
869+ screen.measure()
870+ sys.stdout.write(f"{ESC}[2J")
871+
872+ with state.lock:
873+ if state.notice:
874+ ui.note, state.notice = state.notice, None
875+
876+ render(state, screen, ui)
877+
878+ if isatty and select.select([fd], [], [], 1.0 / fps)[0]:
879+ ch = os.read(fd, 64).decode("utf-8", "replace")
880+ if not handle_key(ch, state, conn, ui, screen):
881+ break
882+ elif not isatty:
883+ # Piped rather than interactive (a recording, a demo): there is
884+ # no keyboard to wait on, so pace the loop ourselves.
885+ time.sleep(1.0 / fps)
886+ except KeyboardInterrupt:
887+ pass
888+ finally:
889+ if isatty and old:
890+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
891+ sys.stdout.write(f"{ESC}[?25h{ESC}[?1049l")
892+ sys.stdout.flush()
893+ return 0
894+
895+
896+if __name__ == "__main__":
897+ sys.exit(main(sys.argv))
addedvite.config.js+36 −0
1+import { defineConfig } from 'vite';
2+
3+// Frontend dev server. All /api/* calls are proxied to the Express backend
4+// (server/index.js) so the browser never sees the Anthropic API key.
5+// Where the Express backend is listening. Override to run a second instance
6+// alongside the usual one (API_PORT=8899 vite --port 5174).
7+const API = `localhost:${process.env.API_PORT || 8787}`;
8+
9+export default defineConfig({
10+ build: {
11+ // The Strudel libraries are inherently large (soundfonts, codemirror, the
12+ // pattern engine) and are all needed on first paint, so code-splitting them
13+ // buys nothing. Raise the bar past the real bundle size instead.
14+ chunkSizeWarningLimit: 2000,
15+ rolldownOptions: {
16+ onwarn(warning, defaultHandler) {
17+ // @strudel/soundfonts parses sf2 metadata with a direct eval(). Nothing
18+ // we can do from here, and it doesn't break the build.
19+ if (warning.code === 'EVAL' && /node_modules[/\\]@strudel[/\\]/.test(warning.id ?? '')) return;
20+ defaultHandler(warning);
21+ },
22+ },
23+ },
24+ server: {
25+ port: 5173,
26+ proxy: {
27+ '/api': `http://${API}`,
28+ // The timing broadcast hub. `ws: true` so the upgrade request is proxied
29+ // rather than answered by Vite's own HMR socket.
30+ '/ws': { target: `ws://${API}`, ws: true },
31+ // Serve the terminal client's source through the dev server too, so the
32+ // curl-it-and-run instructions work on :5173 as well as the API port.
33+ '/tui.py': `http://${API}`,
34+ },
35+ },
36+});