anvilsign in

collin/strudel-claude

pre-demo / node-tui / app.ts
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.
12import { useState, useEffect, useCallback, useRef } from 'react';
13import type { ReactElement } from 'react';
14import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
15
16import { html } from './ui/html.ts';
17import { Editor } from './ui/Editor.ts';
18import { StatusBar } from './ui/StatusBar.ts';
19import { LogPane } from './ui/LogPane.ts';
20import type { LogRecord } from './ui/LogPane.ts';
21import { SessionList } from './ui/SessionList.ts';
22import { Chat } from './ui/Chat.ts';
23import * as buf from './buffer.ts';
24import * as vim from './vim.ts';
25import * as sessions from './sessions.ts';
26import type { SessionMeta } from './sessions.ts';
27import * as claude from './claude.ts';
28import type { Turn } from './claude.ts';
29import { createHapWindow } from './highlights.ts';
30import type { Span } from './highlights.ts';
31import { useMouse, isMouseSequence } from './mouse.ts';
32import type { MouseEvent } from './mouse.ts';
33import { computeLayout, contains, editorPosition } from './layout.ts';
34import type { Zoom } from './layout.ts';
35import { subscribeToLogs } from './browser-shim.ts';
36import type { LogEntry } from './browser-shim.ts';
37import { initAudio, createEngine } from './engine.ts';
38import type { Repl } from './engine.ts';
39
40const 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
46const LOG_HEIGHT = 6;
47/** The log box's border, top and bottom — only on screen while it is open. */
48const LOG_FRAME = 2;
49const SESSIONS_WIDTH = 22;
50const CHAT_WIDTH = 34;
51/** Below this, the side panels cost more than they're worth — hide them. */
52const WIDE_ENOUGH = 100;
53const MAX_LOG_ENTRIES = 200;
54
55/** Where unnamed work goes, so that quitting never discards it. */
56const 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 */
65const FRAME_MS = 80;
66
67type Focus = 'editor' | 'sessions' | 'chat';
68
69/** Shared empty array, so "no spans" is always the same reference. */
70const 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 */
78function 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
90interface 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 */
105function 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
124function 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.
721process.stdout.write('starting strudel…\n');
722await 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.
728const handlers: Handlers = {};
729const 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().
738render(html`<${App} engine=${engine} handlers=${handlers} />`, { exitOnCtrlC: false });