collin/strudel-claude
RenderedSource
| 1 | # node-tui |
| 2 | |
| 3 | Strudel in the terminal — pattern engine, audio, editor, and Claude, all in one |
| 4 | Node process. Self-contained: it does not talk to the web app, the broadcast hub, |
| 5 | or `server/index.js`, and none of them need to be running. |
| 6 | |
| 7 | It is a reimplementation of the web app, not a viewer for it. |
| 8 | |
| 9 | ``` |
| 10 | npm run node-tui |
| 11 | ``` |
| 12 | |
| 13 | No build step. Node 24 strips the TypeScript itself, and the UI is written with |
| 14 | `htm` tagged templates rather than JSX (which Node's stripping does not handle). |
| 15 | |
| 16 | ``` |
| 17 | npm run typecheck:tui # tsc as a checker only, never an emitter |
| 18 | node --test node-tui/*.test.ts |
| 19 | ``` |
| 20 | |
| 21 | ## Using it |
| 22 | |
| 23 | Three panes: sessions on the left, the editor in the middle, chat on the right. |
| 24 | The side panels appear when the terminal is at least 100 columns wide. The |
| 25 | transport line sits along the bottom, under the panes. |
| 26 | |
| 27 | | | | |
| 28 | |---|---| |
| 29 | | `↵` or `^E` | evaluate the buffer | |
| 30 | | `^O` | hush — stop, keep the code | |
| 31 | | `^S` | save the session (↵ keeps the offered name) | |
| 32 | | `^P` | ask Claude for a pattern | |
| 33 | | `^F` | fill the window with the chat, and back | |
| 34 | | `^L` | show the log, and hide it again | |
| 35 | | `^W` | cycle panes | |
| 36 | | `^C` | quit | |
| 37 | |
| 38 | Transport keys work from every pane and every mode, including insert — you |
| 39 | evaluate while typing at least as often as you do from normal mode. |
| 40 | |
| 41 | Enter evaluates outside insert mode. Ctrl+Enter would be the obvious key, but a |
| 42 | terminal sends the identical byte for Enter and Ctrl+Enter unless it speaks the |
| 43 | Kitty keyboard protocol, so there is nothing to tell them apart. Plain Enter is |
| 44 | free: vim's normal-mode `<cr>` moves to the next line's first non-blank, which |
| 45 | `j` and `^` already cover. |
| 46 | |
| 47 | The log is closed until `^L`. It is diagnostic — sample loading, save |
| 48 | confirmations — and a running pattern fills it with the sampler repeating |
| 49 | itself, which is six rows of editor spent on nothing you asked for. Errors reach |
| 50 | the status line regardless. |
| 51 | |
| 52 | **Editing is modal.** `i a I A o O` to insert, `esc` to leave; `hjkl w b e 0 ^ $ |
| 53 | { } gg G` to move; `d c y` with any motion, plus `dd cc yy x D C S p P u ^R`; |
| 54 | counts (`3j`, `d2w`); `v` and `V` for visual. `cw` behaves like `ce`, as in vim. |
| 55 | |
| 56 | **The mouse works** — click anywhere in a pane to focus it, borders and padding |
| 57 | included, click a line to put the cursor there, click a session to load it, |
| 58 | click the chat's prompt row to start typing a question, wheel to scroll. Ink has |
| 59 | no mouse support, so `mouse.ts` enables tracking and parses the reports itself. |
| 60 | |
| 61 | Focus is hit-tested against the pane *frames* and content against the text |
| 62 | areas, which is why `layout.ts` publishes both. Measuring a click against the |
| 63 | text area is the only way to land on the right line, but a click on a pane's |
| 64 | border is unambiguously a click on that pane, and having it do nothing reads as |
| 65 | the mouse being broken rather than as a two-column miss. `layout.test.ts` holds |
| 66 | the frames to covering every cell of the body exactly once. |
| 67 | |
| 68 | **Sessions** hold a pattern *and* the conversation that produced it, as the web |
| 69 | app's do — one JSON file each in `sessions/` (override with `STRUDEL_SESSIONS`). |
| 70 | They autosave when you evaluate, when a reply arrives, and on quit; an unnamed |
| 71 | session takes its name from the first thing you asked for, or `scratch` if you |
| 72 | never asked anything — quitting never discards work. Launching reopens the most |
| 73 | recent session. A `.str` file in the |
| 74 | same directory is read as a code-only session (marked `·` in the list) — that's |
| 75 | the plain-text import/export path, exactly the text you'd type and nothing else, |
| 76 | so a pattern moves between here, strudel.cc, and a gist without ceremony. |
| 77 | |
| 78 | **Chat** authenticates with `ANTHROPIC_API_KEY`, read from the environment or |
| 79 | from `.env` (loaded by `boot.ts`, so it works however you start the app). An |
| 80 | `ant auth login` profile works too — the app doesn't check for a key before |
| 81 | trying, it just reports clearly if the API rejects it. A reply with a pattern |
| 82 | replaces the buffer and plays it. |
| 83 | |
| 84 | ## How it works |
| 85 | |
| 86 | Strudel is layered more cleanly than it looks. The pattern algebra, the |
| 87 | mini-notation parser, the transpiler and the scheduler are pure JS with no DOM |
| 88 | dependency. Only the output stage — superdough — needs Web Audio, and `repl()` |
| 89 | takes both the clock and the output as constructor arguments. So the whole engine |
| 90 | runs in Node once you give it a real `AudioContext`. |
| 91 | |
| 92 | That comes from [`node-web-audio-api`](https://github.com/ircam-ismm/node-web-audio-api), |
| 93 | a Rust/cpal-backed implementation with genuine `AudioWorklet` support — which |
| 94 | matters, because superdough registers 15 worklet processors (`crush`, `coarse`, |
| 95 | `supersaw`, the ladder filter…) and silently loses those effects without them. |
| 96 | |
| 97 | ``` |
| 98 | engine.ts repl() wired to a native AudioContext |
| 99 | buffer.ts text buffer — offsets, positions, edits |
| 100 | vim.ts modal editing as a pure (state, key) → state reducer |
| 101 | highlights.ts which characters are sounding, and how brightly |
| 102 | layout.ts pane geometry, shared by the renderer and the hit test |
| 103 | mouse.ts mouse tracking and SGR report parsing |
| 104 | sessions.ts the session library (code + conversation) |
| 105 | claude.ts pattern generation via the Anthropic API |
| 106 | app.ts panes, focus, keymap |
| 107 | browser-shim.ts the non-audio DOM surface Strudel touches |
| 108 | loader-hooks.ts module resolution Vite would normally handle |
| 109 | ``` |
| 110 | |
| 111 | ### The animation |
| 112 | |
| 113 | Characters light up as the events they produced sound. This is strudel.cc's own |
| 114 | mechanism, not an imitation: the transpiler records which characters produced |
| 115 | each value, the scheduler carries those ranges on `hap.context.locations`, and |
| 116 | `highlights.ts` asks the running pattern what's sounding now. |
| 117 | |
| 118 | Two timescales. The *query* is windowed and cached — asking a pattern for its |
| 119 | events is the expensive part, so it happens a few times a second, a cycle ahead. |
| 120 | The *filter* runs every frame against that cache, which is cheap. That split is |
| 121 | also what makes the fade work: each hap knows its own start and end, so intensity |
| 122 | follows the note between queries. |
| 123 | |
| 124 | The fade is quantised and the frame rate is capped at 12fps on purpose. Ink has |
| 125 | no partial redraw — it rewrites every line whenever anything changes — so the |
| 126 | animation rate *is* the full-screen repaint rate, and on a terminal without |
| 127 | synchronized output that reads as flicker. |
| 128 | |
| 129 | ### The two shims |
| 130 | |
| 131 | Neither is a hack around a Strudel bug; both stand in for a bundler. |
| 132 | |
| 133 | **`browser-shim.ts`** — `node-web-audio-api/polyfill.js` installs the Web Audio |
| 134 | constructors globally and creates a bare `window`. What's left is a small DOM |
| 135 | surface: `document` is a real `EventTarget`, because `@strudel/core`'s logger |
| 136 | dispatches every message as a `strudel.log` CustomEvent — that's how the log pane |
| 137 | is fed. `document.createElement` deliberately throws rather than returning a fake. |
| 138 | |
| 139 | **`loader-hooks.ts`** — two module-resolution patches: |
| 140 | |
| 141 | - `@strudel/core` imports `SalatRepl` from `@kabelsalat/web`, which ships a |
| 142 | browser IIFE bundle Node can't read named exports from. It's only reachable via |
| 143 | the `kabel`/mondo path, so it gets a stub that constructs fine and throws if |
| 144 | ever called. The constructor must succeed — `repl()` instantiates it eagerly. |
| 145 | - superdough's source imports its worklets as `'./worklets.mjs?audioworklet'`, a |
| 146 | Vite-only specifier, resolved here to the path on disk. (The published bundle |
| 147 | inlines them as a `data:` URL and never hits this path.) |
| 148 | |
| 149 | These are registered in `boot.ts`, which then pulls in the app with a **dynamic** |
| 150 | import — `registerHooks()` only affects modules resolved after it runs, and an |
| 151 | ESM graph links before it evaluates. A static import would be linked before the |
| 152 | hooks existed. |
| 153 | |
| 154 | ### Four things that look like details and aren't |
| 155 | |
| 156 | **Ink strips the ESC from mouse reports.** The same click arrives at `mouse.ts` |
| 157 | as `\x1b[<0;9;6M` on raw stdin and at the keymap as `[<0;9;6M`. Both forms have to |
| 158 | be recognised — the first to act on, the second to discard before it gets typed |
| 159 | into the pattern. |
| 160 | |
| 161 | **`useMouse` keeps its handler in a ref.** The handler depends on the layout and |
| 162 | the session list, both rebuilt every render, and this app re-renders on every |
| 163 | animation frame. With the handler in the effect's dependency array, mouse |
| 164 | tracking is torn down and re-armed at that rate. |
| 165 | |
| 166 | ## Still missing, against the web app |
| 167 | |
| 168 | Deliberately not carried over: **themes** (the terminal already has one) and |
| 169 | **in-app API key entry** (an environment variable is the CLI-native answer). |
| 170 | |
| 171 | Genuinely not built yet: |
| 172 | |
| 173 | - **Model picker.** The web app offers four; this hardcodes Opus 4.8. |
| 174 | `claude.ts` already lists all four and `generate()` takes a model id. |
| 175 | - **Revert.** The web app stores a `codeBefore` snapshot per turn and can rewind |
| 176 | to it, with one level of undo. |
| 177 | - **Stepping through history** non-destructively. |
| 178 | |
| 179 | **Ink's `exitOnCtrlC` has to be off.** Its built-in handler unmounts the moment |
| 180 | it sees Ctrl+C, which races the save-on-quit and usually wins: the app appears to |
| 181 | exit cleanly while silently discarding unsaved work. The keymap owns Ctrl+C |
| 182 | instead, and awaits the write before calling `exit()`. |
| 183 | |
| 184 | **The shim must not set `window.document`.** Libraries sniff for a browser by |
| 185 | testing `window && window.document && navigator` — the Anthropic SDK does exactly |
| 186 | this and refuses to construct a client in what it thinks is a browser, since that |
| 187 | would risk leaking an API key to a web page. Setting `window.document` makes the |
| 188 | whole process look like a browser to every library in it, and the chat pane fails |
| 189 | with a confusing error about credentials. The lie stays as small as it needs to |
| 190 | be: `document` is a global, `window.document` is not, and nothing in Strudel |
| 191 | reads the latter. Passing `dangerouslyAllowBrowser` instead would be claiming to |
| 192 | accept a risk that cannot occur here. |
| 193 | |
| 194 | **Never emit more rows than a Box has.** Ink's response to overflow is to draw |
| 195 | rows on top of each other, which reads as corrupted text rather than as a layout |
| 196 | bug — a turn that only partly fits is truncated to its last lines instead. |