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 | + | }); |