anvilsign in

collin/strudel-claude

main / node-tui / README.md

RenderedSource

node-tui

Strudel in the terminal — pattern engine, audio, editor, and Claude, all in one Node process. Self-contained: it does not talk to the web app, the broadcast hub, or server/index.js, and none of them need to be running.

It is a reimplementation of the web app, not a viewer for it.

npm run node-tui

No build step. Node 24 strips the TypeScript itself, and the UI is written with htm tagged templates rather than JSX (which Node's stripping does not handle).

npm run typecheck:tui     # tsc as a checker only, never an emitter
node --test node-tui/*.test.ts

Using it

Three panes: sessions on the left, the editor in the middle, chat on the right. The side panels appear when the terminal is at least 100 columns wide. The transport line sits along the bottom, under the panes.

↵ or ^Eevaluate the buffer
^Ohush — stop, keep the code
^Ssave the session (↵ keeps the offered name)
^Pask Claude for a pattern
^Ffill the window with the chat, and back
^Lshow the log, and hide it again
^Wcycle panes
^Cquit

Transport keys work from every pane and every mode, including insert — you evaluate while typing at least as often as you do from normal mode.

Enter evaluates outside insert mode. Ctrl+Enter would be the obvious key, but a terminal sends the identical byte for Enter and Ctrl+Enter unless it speaks the Kitty keyboard protocol, so there is nothing to tell them apart. Plain Enter is free: vim's normal-mode <cr> moves to the next line's first non-blank, which j and ^ already cover.

The log is closed until ^L. It is diagnostic — sample loading, save confirmations — and a running pattern fills it with the sampler repeating itself, which is six rows of editor spent on nothing you asked for. Errors reach the status line regardless.

Editing is modal. i a I A o O to insert, esc to leave; hjkl w b e 0 ^ $ { } gg G to move; d c y with any motion, plus dd cc yy x D C S p P u ^R; counts (3j, d2w); v and V for visual. cw behaves like ce, as in vim.

The mouse works — click anywhere in a pane to focus it, borders and padding included, click a line to put the cursor there, click a session to load it, click the chat's prompt row to start typing a question, wheel to scroll. Ink has no mouse support, so mouse.ts enables tracking and parses the reports itself.

Focus is hit-tested against the pane frames and content against the text areas, which is why layout.ts publishes both. Measuring a click against the text area is the only way to land on the right line, but a click on a pane's border is unambiguously a click on that pane, and having it do nothing reads as the mouse being broken rather than as a two-column miss. layout.test.ts holds the frames to covering every cell of the body exactly once.

Sessions hold a pattern and the conversation that produced it, as the web app's do — one JSON file each in sessions/ (override with STRUDEL_SESSIONS). They autosave when you evaluate, when a reply arrives, and on quit; an unnamed session takes its name from the first thing you asked for, or scratch if you never asked anything — quitting never discards work. Launching reopens the most recent session. A .str file in the same directory is read as a code-only session (marked · in the list) — that's the plain-text import/export path, exactly the text you'd type and nothing else, so a pattern moves between here, strudel.cc, and a gist without ceremony.

Chat authenticates with ANTHROPIC_API_KEY, read from the environment or from .env (loaded by boot.ts, so it works however you start the app). An ant auth login profile works too — the app doesn't check for a key before trying, it just reports clearly if the API rejects it. A reply with a pattern replaces the buffer and plays it.

How it works

Strudel is layered more cleanly than it looks. The pattern algebra, the mini-notation parser, the transpiler and the scheduler are pure JS with no DOM dependency. Only the output stage — superdough — needs Web Audio, and repl() takes both the clock and the output as constructor arguments. So the whole engine runs in Node once you give it a real AudioContext.

That comes from node-web-audio-api, a Rust/cpal-backed implementation with genuine AudioWorklet support — which matters, because superdough registers 15 worklet processors (crush, coarse, supersaw, the ladder filter…) and silently loses those effects without them.

engine.ts        repl() wired to a native AudioContext
buffer.ts        text buffer — offsets, positions, edits
vim.ts           modal editing as a pure (state, key) → state reducer
highlights.ts    which characters are sounding, and how brightly
layout.ts        pane geometry, shared by the renderer and the hit test
mouse.ts         mouse tracking and SGR report parsing
sessions.ts      the session library (code + conversation)
claude.ts        pattern generation via the Anthropic API
app.ts           panes, focus, keymap
browser-shim.ts  the non-audio DOM surface Strudel touches
loader-hooks.ts  module resolution Vite would normally handle

The animation

Characters light up as the events they produced sound. This is strudel.cc's own mechanism, not an imitation: the transpiler records which characters produced each value, the scheduler carries those ranges on hap.context.locations, and highlights.ts asks the running pattern what's sounding now.

Two timescales. The query is windowed and cached — asking a pattern for its events is the expensive part, so it happens a few times a second, a cycle ahead. The filter runs every frame against that cache, which is cheap. That split is also what makes the fade work: each hap knows its own start and end, so intensity follows the note between queries.

The fade is quantised and the frame rate is capped at 12fps on purpose. Ink has no partial redraw — it rewrites every line whenever anything changes — so the animation rate is the full-screen repaint rate, and on a terminal without synchronized output that reads as flicker.

The two shims

Neither is a hack around a Strudel bug; both stand in for a bundler.

browser-shim.ts — node-web-audio-api/polyfill.js installs the Web Audio constructors globally and creates a bare window. What's left is a small DOM surface: document is a real EventTarget, because @strudel/core's logger dispatches every message as a strudel.log CustomEvent — that's how the log pane is fed. document.createElement deliberately throws rather than returning a fake.

loader-hooks.ts — two module-resolution patches:

  • @strudel/core imports SalatRepl from @kabelsalat/web, which ships a browser IIFE bundle Node can't read named exports from. It's only reachable via the kabel/mondo path, so it gets a stub that constructs fine and throws if ever called. The constructor must succeed — repl() instantiates it eagerly.
  • superdough's source imports its worklets as './worklets.mjs?audioworklet', a Vite-only specifier, resolved here to the path on disk. (The published bundle inlines them as a data: URL and never hits this path.)

These are registered in boot.ts, which then pulls in the app with a dynamic import — registerHooks() only affects modules resolved after it runs, and an ESM graph links before it evaluates. A static import would be linked before the hooks existed.

Four things that look like details and aren't

Ink strips the ESC from mouse reports. The same click arrives at mouse.ts as \x1b[<0;9;6M on raw stdin and at the keymap as [<0;9;6M. Both forms have to be recognised — the first to act on, the second to discard before it gets typed into the pattern.

useMouse keeps its handler in a ref. The handler depends on the layout and the session list, both rebuilt every render, and this app re-renders on every animation frame. With the handler in the effect's dependency array, mouse tracking is torn down and re-armed at that rate.

Still missing, against the web app

Deliberately not carried over: themes (the terminal already has one) and in-app API key entry (an environment variable is the CLI-native answer).

Genuinely not built yet:

  • Model picker. The web app offers four; this hardcodes Opus 4.8. claude.ts already lists all four and generate() takes a model id.
  • Revert. The web app stores a codeBefore snapshot per turn and can rewind to it, with one level of undo.
  • Stepping through history non-destructively.

Ink's exitOnCtrlC has to be off. Its built-in handler unmounts the moment it sees Ctrl+C, which races the save-on-quit and usually wins: the app appears to exit cleanly while silently discarding unsaved work. The keymap owns Ctrl+C instead, and awaits the write before calling exit().

The shim must not set window.document. Libraries sniff for a browser by testing window && window.document && navigator — the Anthropic SDK does exactly this and refuses to construct a client in what it thinks is a browser, since that would risk leaking an API key to a web page. Setting window.document makes the whole process look like a browser to every library in it, and the chat pane fails with a confusing error about credentials. The lie stays as small as it needs to be: document is a global, window.document is not, and nothing in Strudel reads the latter. Passing dangerouslyAllowBrowser instead would be claiming to accept a risk that cannot occur here.

Never emit more rows than a Box has. Ink's response to overflow is to draw rows on top of each other, which reads as corrupted text rather than as a layout bug — a turn that only partly fits is truncated to its last lines instead.