a73x

0b5110d2

docs: the native client is a session viewer painted from the grid (spec)

a73x   2026-09-04 18:48

Commit message
docs: the native client is a session viewer painted from the grid (spec)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

docs/superpowers/specs/2026-09-04-native-client-design.md
Old New
@@ -0,0 +1,329 @@
1 # The native client — design
2
3 2026-09-04. A new program, `muxg`: a window on one daemon session, painted
4 with OpenGL from the grid cells-on-the-wire delivers. It is a SESSION
5 VIEWER, not a wall. Nothing in it decides which sessions are on screen,
6 where they sit, or what happens when one ends; it shows one session and
7 types into it. This spec is written against the cells-on-the-wire branch's
8 `src/engine/grid.zig` and `replica.zig`, and implementation starts after
9 that branch lands on main, because the painter's whole input is that grid.
10
11 ## Goal
12
13 A terminal window whose emulator is the daemon. Today every mux surface is
14 a program that paints INTO a terminal somebody else drew: the wall into
15 foot or ghostty, the hub into a browser. `muxg` is the first surface that
16 owns its pixels, and it exists to answer one question end to end: can a
17 window painted from `term.grid` keep up with a session under a flood of
18 output, with nothing between the daemon's grid and the screen but a copy
19 and a draw. Everything else a terminal window does — selection,
20 scrollback, the mouse, colour emoji, panes — is deferred until that loop
21 is measured working.
22
23 ## Findings the design rests on
24
25 - **The daemon-facing half of a client is already terminal-free.**
26 `client.Transport` dials (unix socket, `--via`, QUIC, the ssh handoff),
27 sends the attach frame, reads and writes frames and exposes a poll fd;
28 `client.nextBackoffMs` and `client.lostMsg` are the redial policy;
29 `client.keymap.encode` turns a named key with modifiers into the bytes the
30 session reads; `client.core.ClientCore.receive` sorts every non-replay
31 frame into state, effect or reply. On the cells-on-the-wire branch
32 `term.replica.Replica.apply` copies rows into a `term.grid.Grid` of styled
33 cells with a cursor, returns `.painted` or `.resync`, and `attachArgs`
34 spells the re-attach. None of that has a terminal, a fork or an escape
35 byte under it. A native program links it as it is.
36
37 - **The wall never crossed.** The tile set, focus, births, the two end
38 arms, the picker, the layout file's seat and persist and the once-a-second
39 host grade all live under `src/tui/`, written against a cell painter under
40 `paint_mu`, with rails and span-bounded clears. The hub already shows what
41 a second surface does when it wants that policy: `webhub.applyList` is a
42 copy of `wall_host.planHostDiff`, and it is an open issue. A native
43 program that "starts as a wall" would either be a third copy under
44 `src/gui/` or reach into `src/tui/` for code that assumes a terminal.
45 This spec does neither: `muxg` has no wall policy, and native panes wait
46 on lifting that policy out of `src/tui/` into a painter-free module (see
47 Deferred).
48
49 - **waystty's painter was never the problem.** `~/code/rad/waystty` (Wayland
50 + Vulkan, same ghostty pin and Zig 0.15.2 as mux) was abandoned as "not
51 performant". Measured 2026-09-04 in ReleaseFast under `cat /dev/random`:
52 71 frames, per-frame total average 256 µs, p99 784 µs. What starved it is
53 the pty drain loop in its `main.zig`, which reads and feeds the emulator
54 until `WouldBlock` — which never comes under a flood — so no frame is
55 ever painted. A mux client has no such loop: the daemon owns the pty and
56 the client receives rows at whatever rate it applies them. Its
57 `cell_instance.zig` (a cell becomes zero, one or two instanced quads) and
58 `font.zig` (fontconfig chooses the face, freetype rasterises) are the two
59 pieces worth carrying, by reading and rewriting against `term.grid`, and
60 its per-stage frame-timing ring is the habit that found the real fault.
61 Nothing else of it is copied: not the Wayland protocol code, not the
62 Vulkan setup, not the loop.
63
64 - **There is no key mode to track.** `keymap.encode` has no application
65 cursor-keys state; the wall and the browser core send the same arrow
66 bytes whatever mode the session set. `muxg` inherits that exactly, so a
67 key means one thing on all three surfaces.
68
69 ## Design
70
71 ### 1. Build root and target
72
73 A new folder, `src/gui/`, one row in the layout table:
74
75 | Folder | Row — its child files |
76 |---|---|
77 | `src/gui/` | `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` |
78
79 Its entry is `src/cli/muxg.zig`, a child of the `mux` row like the other
80 entrypoints. The binary is `muxg`, separate from `mux`: the Linux release
81 of `mux` is static musl, and this program links the system's SDL3, libGL,
82 freetype and fontconfig dynamically through pkg-config, against native
83 glibc. `zig build native` is an opt-in step that builds `muxg` and
84 `native-test` runs its unit tests; `make native` is the only caller of
85 either. `make build`, `make check` and `make ci` never touch it, so a box
86 without SDL3 builds and gates mux as before.
87
88 `src/gui/` imports `client` and `term` and nothing else of ours.
89 `checkSourceBans` reads it like any folder under `src/`, so the platform
90 bans of rule 7 hold there. Two rules of its own, stated in `native.zig`'s
91 header:
92
93 - **No wall policy enters `src/gui/`.** No tiles, no layout file, no
94 picker, no host grade, no session list. A viewer shows one session.
95 - **SDL is confined to `frame.zig` and `muxg.zig`.** No other file under
96 `src/gui/` sees an SDL type. `font`, `atlas`, `quads` and `bench` are
97 plain Zig over `term.grid` and freetype, and `gl` sees GL alone.
98
99 SDL3 is the windowing layer, chosen over GLFW and raw Wayland: it opens the
100 window and the GL context on Wayland, X11 and later macOS from one code
101 path; it delivers keyboard layouts, dead keys and compose as one UTF-8
102 text-input event, which waystty never finished doing by hand; every box we
103 build on packages it; and its own GPU API sits behind Metal and Vulkan, so
104 a painter written against instanced quads can change backends without a
105 new windowing layer if GL on macOS bites.
106
107 ### 2. The pump — `src/client/session_pump.zig`
108
109 A new unit in `src/client/`, with no SDL in it and no terminal under it.
110 One thread owns the transport from dial to close.
111
112 Its inputs are a `client.Target`, the initial cols and rows, a mailbox and
113 a wake callback. Its output is a `Replica` it owns, behind a mutex, and a
114 small `State` the window thread reads: attached, reconnecting, exited with
115 a code, refused with a reason, or taken.
116
117 The thread dials through the existing handoff (`Transport.open` and the
118 ssh `--start` ask, `HandoffTarget.asked` defaulting to false as for every
119 dial), sends the attach frame with the current size, then loops on a poll
120 over the transport fd and the doorbell: it reads frames, applies each
121 through `Replica.apply` under the mutex, feeds every non-replay frame to
122 `ClientCore.receive`, and calls the wake once per pass that changed
123 anything. The wake is a function pointer; on the SDL side it pushes one
124 user event, and the window thread coalesces however many wakes arrived
125 into one paint.
126
127 The mailbox carries the four things a viewer can say — input bytes, a
128 resize, a detach and quit — and an eventfd doorbell rings the pump out of
129 its poll to send them. The window thread never touches the transport.
130
131 Frame handling mirrors the wall pump arm for arm and no further:
132
133 - `.resync` from `apply`: re-attach at seq 0, as `wall_pump` does, because
134 a quoted seq after a resync invites an unfixable delta.
135 - `exit_status`: before any replay frame it is the refusal path and the
136 state is `refused` with the payload; after, `exited` with the shell's
137 code. The pump returns.
138 - `taken_over`: the wall treats it as an end and so does this (the current
139 daemon does not send it; the arm is wire-compat). State `taken`, return.
140 - Effects from `ClientCore`: a bell and a clipboard set are recorded in the
141 state for the window to read. Selection replies, agent channels, session
142 lists and end replies are not handled — a viewer asks none of those
143 questions.
144 - A lost link (read error, `.closed`): state `reconnecting`, redial with
145 `nextBackoffMs`, re-attach with the replica's `attachArgs`, until the
146 mailbox says quit.
147
148 The header names the debt: this is the third terminal-free attach loop,
149 after `webhub.pumpTile` and `mux a`'s, and `pumpTile` is the first
150 candidate to move onto it. It is not moved in this change.
151
152 ### 3. The painter — `src/gui/`
153
154 **`font.zig`** asks fontconfig for the system monospace face (`monospace`
155 pattern, default size 12 pt overridable by a flag) and freetype for glyph
156 bitmaps. It measures the cell from the face's advance width and its
157 ascender-plus-descender height, in whole pixels, and rasterises one glyph
158 on demand. Colour emoji, fallback faces and hinting choices are deferred;
159 a codepoint the face lacks paints as the face's missing-glyph box.
160
161 **`atlas.zig`** is one R8 texture, shelf-packed, grown by re-upload only
162 when a glyph first appears. It never shrinks. Each entry is the glyph's
163 texture rectangle plus its bearing, so `quads` can place it.
164
165 **`quads.zig`** is the port of waystty's `cell_instance.zig`, rewritten
166 over `term.grid`. For each row it walks the cells and emits zero, one or
167 two instances per cell: a background quad when the cell's background is not
168 the default, and a glyph quad when the cell has text, with the atlas entry
169 for the cell's text (rasterised on first sight). A wide cell's glyph quad
170 is two columns wide and the trailing half is skipped. Reverse video, bold,
171 underline and the rest of `proto.CellStyle` are attributes on the
172 instance; the palette is a fixed table for the 256 colours plus RGB
173 pass-through. The cursor is one more quad over its cell, in the foreground
174 colour. A row's instances are built from a `Row` and a column offset, so
175 the unit test can hold the offset non-zero.
176
177 **`gl.zig`** owns the OpenGL 3.3 core objects: one vertex array, one
178 instance buffer, one program with the two shaders as string constants, the
179 atlas texture, and one instanced draw per frame with backgrounds first in
180 the buffer and glyphs after, so a glyph is never covered by its own cell's
181 background.
182
183 **`frame.zig`** is the loop the window thread runs and the one file that
184 sees SDL. It creates the window and the GL context with vsync on, sets
185 the wake to push a user event, and waits on SDL's event queue. On a wake
186 or a resize it locks the replica, rebuilds the instance list from the
187 whole grid, unlocks, uploads the instances and any atlas growth, draws
188 and swaps. The whole-grid rebuild every frame is deliberate: a large window
189 is on the order of ten thousand cells, and the timing table is what will
190 say whether dirty rows ever matter. A window resize floors the drawable
191 size to whole cells and, when cols or rows changed, sends a resize through
192 the mailbox; the daemon follows the latest active client, so the session
193 takes the window's size. Frames are painted only on a wake, a resize or an
194 expose; an idle window draws nothing.
195
196 **`bench.zig`** is a ring of per-frame stage times in microseconds: `apply`
197 (pump side, the time inside `Replica.apply` per pass), and on the window
198 side `rebuild`, `atlas_upload`, `instance_upload` and `draw_swap`. Idle
199 passes are not recorded. The table — min, average, p99 and max per stage
200 and in total — prints to stderr on exit and on SIGUSR1, always compiled
201 in, in the shape waystty's did:
202
203 ```
204 === muxg frame timing (243 frames) ===
205 stage min avg p99 max (us)
206 apply 2 4 15 89
207 rebuild 1 12 124 890
208 atlas_upload 0 180 5200 8100
209 instance_upload 1 6 24 71
210 draw_swap 3 8 35 210
211 total 9 210 5400 8800
212 ```
213
214 ### 4. Input
215
216 SDL's text-input events carry UTF-8 for anything that types a character;
217 those bytes go to the mailbox as an `input` frame unchanged. Key-down
218 events for everything else become a `keymap.Event`: the arrows, Home, End,
219 Insert, Delete, Page Up, Page Down, F1 through F12, Enter, Tab, Backspace
220 and Escape by name, and a letter held with Ctrl or Alt as `.char` with the
221 codepoint and the modifiers. `keymap.encode` produces the bytes, so the
222 key table stays in one file and a chord means the same on the wall, in the
223 browser and here. The SDL keycode to `keymap.Event` mapping is a pure
224 table in `frame.zig`, unit-tested without a window.
225
226 There is no prefix chord: `Ctrl-\` is a byte for the session, because a
227 viewer has nothing to switch to. Closing the window sends detach and
228 exits 0. A bell flashes the title for a moment; a clipboard set is read
229 and ignored (v1 has no clipboard). Mouse events are dropped. Losing window
230 focus sends nothing, and regaining it sends nothing: the daemon's
231 latest-wins follows input, not focus.
232
233 ### 5. Error handling
234
235 - A dial that fails prints the existing `client.openFailure` words to
236 stderr and exits 2, as `mux` does.
237 - `exited` closes the window and the process exits with the shell's code,
238 as a piped `mux` does, because a script can read it. `refused` prints
239 the daemon's reason and exits 1. `taken` prints one line and exits 0.
240 - `reconnecting` puts `[reconnecting]` in the title and keeps the last
241 grid painted until frames resume or the window closes.
242 - A missing library is a link error in pkg-config's own words at
243 `make native`; nothing falls back.
244 - A face freetype cannot load, or a GL context SDL cannot create, exits 2
245 with that library's error string on stderr.
246 - The pump thread ending for any reason the state does not name ends the
247 window with exit 1 and the frame table on stderr.
248
249 ### 6. Testing
250
251 Both layers are opt-in under `make native` and outside `make ci`.
252
253 **Unit tests, no window.** `native-test` builds `src/gui/` without SDL and
254 runs:
255
256 - `quads` over a `Row` built from decoded cells, with the column offset
257 non-zero: a blank cell emits nothing, a styled-background blank emits one
258 quad, a cell with text emits a glyph quad, a wide cell emits one
259 two-column glyph quad and its trailing half emits nothing, and the cursor
260 quad lands on the cursor cell.
261 - The drawable-size to cols and rows flooring at a non-square cell size,
262 and the resize decision (changed vs unchanged) it feeds.
263 - The SDL keycode to `keymap.Event` table, both directions worth pinning:
264 a named key maps, a printable key does not (it arrives as text).
265 - The atlas packer: a second glyph lands beside the first, a row that
266 overflows opens a new shelf, and growth keeps every earlier entry's
267 rectangle.
268
269 **One end-to-end leg, `test/native.sh`**, `make native-e2e`, against a real
270 daemon on an isolated `XDG_RUNTIME_DIR` and `XDG_STATE_HOME`, in the
271 harness's shape (`test/os_oracle.sh` for anything about a pid):
272
273 1. Start a daemon; start `muxg --sock PATH` with `SDL_VIDEODRIVER=offscreen`
274 and a fixed window size, and capture its pid.
275 2. Type `echo native-ok` through the real event path — SDL's own event
276 injection from a test hook in `muxg`, gated on an environment variable
277 the leg sets — and read the shell's reply through `mux a`. The bytes
278 crossed the keymap, the mailbox, the pump and the daemon.
279 3. Flood the session with a bounded stream — a fixed byte count of base64
280 from `/dev/urandom` through `mux a run`, never an unbounded `cat` — and
281 send SIGUSR1 when it ends.
282 4. Read the table: frames were painted WHILE the flood ran (the frame count
283 grew between two SIGUSR1 reads taken during it), and the window-side
284 stages' p99 sat under the budget below. This is the waystty failure,
285 pinned.
286 5. Resize the window through the test hook and read the session's size
287 back through `mux a status`: the daemon followed the window.
288 6. Close the window; the process exits 0 and the daemon still lists the
289 session.
290
291 The budget is stated for ReleaseSafe or ReleaseFast only, and the leg
292 refuses to grade a Debug binary, because a Debug ghostty runs its
293 page-integrity check on every mutation and a number measured there means
294 nothing. The first plan step is a spike: whether SDL3's offscreen driver
295 yields a GL context on the dev box and on the mux-e2e VM. If it does not,
296 the leg runs under Xvfb with SDL's X11 driver, and the spec is amended
297 with which.
298
299 ## What this adds to the tree
300
301 - `src/gui/native.zig` `font.zig` `atlas.zig` `quads.zig` `gl.zig`
302 `frame.zig` `bench.zig`; `src/cli/muxg.zig`.
303 - `src/client/session_pump.zig`, re-exported from `client.zig`.
304 - `build.zig`: the `native` and `native-test` steps, pkg-config for SDL3,
305 GL, freetype2 and fontconfig, the table row, the two folder rules.
306 - `Makefile`: `native`, `native-e2e`.
307 - `test/native.sh`.
308 - `README.md`: one section, `muxg TARGET`, and that it is a viewer.
309 - `CLAUDE.md`: the table row, the two rules, the build line.
310
311 ## Deferred
312
313 - **Selection and clipboard**, both directions, with `select.zig`'s rules.
314 - **Scrollback** through the dense-row chunk path, on the wheel.
315 - **Mouse** reports to the session.
316 - **Colour emoji and fallback faces.**
317 - **Dirty-row rebuild**, only if the table says the whole-grid rebuild is
318 what costs.
319 - **The macOS build**: the same `make native` on a Mac with Homebrew's SDL3,
320 GL 3.3 core (deprecated there but present), or SDL's GPU API behind
321 Metal if it bites.
322 - **Native panes.** Prerequisite: lift the wall's policy — tiles, focus,
323 births, ends, the layout file, the grade — out of `src/tui/` into a
324 painter-free module with painting behind an interface, so the terminal
325 wall, the hub and `muxg` drive one model. That is the refactor the hub's
326 duplicated grade already asks for. Until then a wall inside a window is
327 `mux` running inside a `muxg` session, at the cost of a second replica
328 hop and nothing else.
329 - **Moving `webhub.pumpTile` onto `session_pump`.**