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