collin/strudel-claude
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 ^E | evaluate the buffer |
^O | hush — stop, keep the code |
^S | save the session (↵ keeps the offered name) |
^P | ask Claude for a pattern |
^F | fill the window with the chat, and back |
^L | show the log, and hide it again |
^W | cycle panes |
^C | quit |
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/coreimportsSalatReplfrom@kabelsalat/web, which ships a browser IIFE bundle Node can't read named exports from. It's only reachable via thekabel/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 adata: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.tsalready lists all four andgenerate()takes a model id. - Revert. The web app stores a
codeBeforesnapshot 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.