anvilsign in

collin/strudel-claude

main / src / main.js
1import { createEditor, playPattern } from './strudel.js';
2import * as Sessions from './sessions.js';
3import { authHeaders, initApiKeyUI } from './apikey.js';
4import { getModel, initModelUI } from './models.js';
5import { getSystemPrompt, initPromptUI } from './prompt.js';
6import * as Themes from './themes.js';
7import { bindPanel } from './panel.js';
8import { createBroadcaster } from './broadcast.js';
9
10const STARTER = `// Press ▶ Play, or ask Claude on the right to make something.
11// While it plays, watch the notes flash in the code.
12stack(
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 ----------
20Sessions.initStore(STARTER);
21const 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.
24let 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.
28const 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).
32let history_ = dropDanglingTurn(Array.isArray(active.history) ? [...active.history] : []);
33
34// ---------- Status line ----------
35const 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).
38const 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) ----------
52const 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.
62const 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
73const saveActive = () =>
74 Sessions.saveSession(loadedId, { code: editor.code, history: history_ });
75
76// ---------- Transport (one button that toggles) ----------
77const transportBtn = document.getElementById('playstop');
78const isPlaying = () => Boolean(editor.repl?.scheduler?.started);
79
80function 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
90transportBtn.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.
103setInterval(syncTransport, 250);
104syncTransport();
105
106setInterval(saveActive, 4000);
107window.addEventListener('beforeunload', saveActive);
108// Persist manual edits as soon as focus leaves the editor.
109editor.editor.contentDOM.addEventListener('blur', saveActive);
110
111// ---------- Chat (right pane) ----------
112const messagesEl = document.getElementById('messages');
113const form = document.getElementById('chat-form');
114const input = document.getElementById('chat-input');
115const sessionListEl = document.getElementById('session-list');
116const 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).
120function 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).
169function renderMessages() {
170 messagesEl.replaceChildren();
171 history_.forEach((m, i) => addBubble(m.role, m.content, i));
172}
173
174function 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
184function 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.
194function 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.
201function 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
229function 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.
244function 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).
269songTitleEl.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
301function 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
310function 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.
321async 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.
346let travel = null; // { index, latestCode }
347
348// The pattern this message represents, or null if the session predates the
349// snapshot it needs.
350function 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
357async 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
383async 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.
395let undoState = null;
396
397// Index of the user message that opens the turn containing `index`, or -1.
398function 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.
405async 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
425function 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.
439async function applyCode(code) {
440 editor.setCode(code);
441 if (editor.repl?.scheduler?.started) await playPattern(editor);
442}
443
444// Initial render of restored session.
445renderMessages();
446renderSessionList();
447setStatus(history_.length ? 'restored your last track' : 'ready');
448
449// New session.
450document.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 ----------
505initModelUI();
506
507// ---------- System prompt (view / edit) ----------
508initPromptUI();
509
510// ---------- API key (bring your own) ----------
511const apiKeyUI = initApiKeyUI({
512 onChange: (key) => setStatus(key ? 'API key saved' : 'API key removed'),
513});
514
515// ---------- Sidebar collapse ----------
516const SIDEBAR_KEY = 'strudel-claude-sidebar';
517const hideBtn = document.getElementById('sidebar-hide');
518const showBtn = document.getElementById('sidebar-show');
519
520function 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
530hideBtn.addEventListener('click', () => setSidebar(true));
531showBtn.addEventListener('click', () => setSidebar(false));
532setSidebar(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.
537let chatBusy = false;
538
539async 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
619form.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.
628input.addEventListener('keydown', (e) => {
629 if (e.key === 'Enter' && !e.shiftKey) {
630 e.preventDefault();
631 form.requestSubmit();
632 }
633});