0b5110d2
docs: the native client is a session viewer painted from the grid (spec)
a73x 2026-09-04 18:48
Commit message
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`.** | ||