anvilsign in

collin/strudel-claude

pre-demo / node-tui / README.md

RenderedSource

1# node-tui
2
3Strudel in the terminal — pattern engine, audio, editor, and Claude, all in one
4Node process. Self-contained: it does not talk to the web app, the broadcast hub,
5or `server/index.js`, and none of them need to be running.
6
7It is a reimplementation of the web app, not a viewer for it.
8
9```
10npm run node-tui
11```
12
13No 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```
17npm run typecheck:tui # tsc as a checker only, never an emitter
18node --test node-tui/*.test.ts
19```
20
21## Using it
22
23Three panes: sessions on the left, the editor in the middle, chat on the right.
24The side panels appear when the terminal is at least 100 columns wide. The
25transport 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
38Transport keys work from every pane and every mode, including insert — you
39evaluate while typing at least as often as you do from normal mode.
40
41Enter evaluates outside insert mode. Ctrl+Enter would be the obvious key, but a
42terminal sends the identical byte for Enter and Ctrl+Enter unless it speaks the
43Kitty keyboard protocol, so there is nothing to tell them apart. Plain Enter is
44free: vim's normal-mode `<cr>` moves to the next line's first non-blank, which
45`j` and `^` already cover.
46
47The log is closed until `^L`. It is diagnostic — sample loading, save
48confirmations — and a running pattern fills it with the sampler repeating
49itself, which is six rows of editor spent on nothing you asked for. Errors reach
50the 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`;
54counts (`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
57included, click a line to put the cursor there, click a session to load it,
58click the chat's prompt row to start typing a question, wheel to scroll. Ink has
59no mouse support, so `mouse.ts` enables tracking and parses the reports itself.
60
61Focus is hit-tested against the pane *frames* and content against the text
62areas, which is why `layout.ts` publishes both. Measuring a click against the
63text area is the only way to land on the right line, but a click on a pane's
64border is unambiguously a click on that pane, and having it do nothing reads as
65the mouse being broken rather than as a two-column miss. `layout.test.ts` holds
66the 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
69app's do — one JSON file each in `sessions/` (override with `STRUDEL_SESSIONS`).
70They autosave when you evaluate, when a reply arrives, and on quit; an unnamed
71session takes its name from the first thing you asked for, or `scratch` if you
72never asked anything — quitting never discards work. Launching reopens the most
73recent session. A `.str` file in the
74same directory is read as a code-only session (marked `·` in the list) — that's
75the plain-text import/export path, exactly the text you'd type and nothing else,
76so 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
79from `.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
81trying, it just reports clearly if the API rejects it. A reply with a pattern
82replaces the buffer and plays it.
83
84## How it works
85
86Strudel is layered more cleanly than it looks. The pattern algebra, the
87mini-notation parser, the transpiler and the scheduler are pure JS with no DOM
88dependency. Only the output stage — superdough — needs Web Audio, and `repl()`
89takes both the clock and the output as constructor arguments. So the whole engine
90runs in Node once you give it a real `AudioContext`.
91
92That comes from [`node-web-audio-api`](https://github.com/ircam-ismm/node-web-audio-api),
93a Rust/cpal-backed implementation with genuine `AudioWorklet` support — which
94matters, because superdough registers 15 worklet processors (`crush`, `coarse`,
95`supersaw`, the ladder filter…) and silently loses those effects without them.
96
97```
98engine.ts repl() wired to a native AudioContext
99buffer.ts text buffer — offsets, positions, edits
100vim.ts modal editing as a pure (state, key) → state reducer
101highlights.ts which characters are sounding, and how brightly
102layout.ts pane geometry, shared by the renderer and the hit test
103mouse.ts mouse tracking and SGR report parsing
104sessions.ts the session library (code + conversation)
105claude.ts pattern generation via the Anthropic API
106app.ts panes, focus, keymap
107browser-shim.ts the non-audio DOM surface Strudel touches
108loader-hooks.ts module resolution Vite would normally handle
109```
110
111### The animation
112
113Characters light up as the events they produced sound. This is strudel.cc's own
114mechanism, not an imitation: the transpiler records which characters produced
115each value, the scheduler carries those ranges on `hap.context.locations`, and
116`highlights.ts` asks the running pattern what's sounding now.
117
118Two timescales. The *query* is windowed and cached — asking a pattern for its
119events is the expensive part, so it happens a few times a second, a cycle ahead.
120The *filter* runs every frame against that cache, which is cheap. That split is
121also what makes the fade work: each hap knows its own start and end, so intensity
122follows the note between queries.
123
124The fade is quantised and the frame rate is capped at 12fps on purpose. Ink has
125no partial redraw — it rewrites every line whenever anything changes — so the
126animation rate *is* the full-screen repaint rate, and on a terminal without
127synchronized output that reads as flicker.
128
129### The two shims
130
131Neither 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
134constructors globally and creates a bare `window`. What's left is a small DOM
135surface: `document` is a real `EventTarget`, because `@strudel/core`'s logger
136dispatches every message as a `strudel.log` CustomEvent — that's how the log pane
137is 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
149These are registered in `boot.ts`, which then pulls in the app with a **dynamic**
150import — `registerHooks()` only affects modules resolved after it runs, and an
151ESM graph links before it evaluates. A static import would be linked before the
152hooks 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`
157as `\x1b[<0;9;6M` on raw stdin and at the keymap as `[<0;9;6M`. Both forms have to
158be recognised — the first to act on, the second to discard before it gets typed
159into the pattern.
160
161**`useMouse` keeps its handler in a ref.** The handler depends on the layout and
162the session list, both rebuilt every render, and this app re-renders on every
163animation frame. With the handler in the effect's dependency array, mouse
164tracking is torn down and re-armed at that rate.
165
166## Still missing, against the web app
167
168Deliberately 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
171Genuinely 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
180it sees Ctrl+C, which races the save-on-quit and usually wins: the app appears to
181exit cleanly while silently discarding unsaved work. The keymap owns Ctrl+C
182instead, and awaits the write before calling `exit()`.
183
184**The shim must not set `window.document`.** Libraries sniff for a browser by
185testing `window && window.document && navigator` — the Anthropic SDK does exactly
186this and refuses to construct a client in what it thinks is a browser, since that
187would risk leaking an API key to a web page. Setting `window.document` makes the
188whole process look like a browser to every library in it, and the chat pane fails
189with a confusing error about credentials. The lie stays as small as it needs to
190be: `document` is a global, `window.document` is not, and nothing in Strudel
191reads the latter. Passing `dangerouslyAllowBrowser` instead would be claiming to
192accept a risk that cannot occur here.
193
194**Never emit more rows than a Box has.** Ink's response to overflow is to draw
195rows on top of each other, which reads as corrupted text rather than as a layout
196bug — a turn that only partly fits is truncated to its last lines instead.