docs/superpowers/specs/2026-09-04-native-client-design.md
Ref: Size: 21.1 KiB History
# The native client — design 2026-09-04. A new program, `muxg`: a window on one daemon session, painted with OpenGL from the grid cells-on-the-wire delivers. It is a SESSION VIEWER, not a wall. Nothing in it decides which sessions are on screen, where they sit, or what happens when one ends; it shows one session and types into it. The cells-on-the-wire implementation has landed: the painter consumes `src/engine/grid.zig` through `replica.zig` and links no terminal emulator. ## Goal A terminal window whose emulator is the daemon. Today every mux surface is a program that paints INTO a terminal somebody else drew: the wall into foot or ghostty, the hub into a browser. `muxg` is the first surface that owns its pixels, and it exists to answer one question end to end: can a window painted from `term.grid` keep up with a session under a flood of output, with nothing between the daemon's grid and the screen but a copy and a draw. Everything else a terminal window does — selection, scrollback, the mouse, colour emoji, panes — is deferred until that loop is measured working. ## Findings the design rests on - **The daemon-facing half of a client is already terminal-free.** `client.Transport` dials (unix socket, `--via`, QUIC, the ssh handoff), sends the attach frame, reads and writes frames and exposes a poll fd; `client.nextBackoffMs` and `client.lostMsg` are the redial policy; `client.keymap.encode` turns a named key with modifiers into the bytes the session reads; `client.core.ClientCore.receive` sorts every non-replay frame into state, effect or reply. On the cells-on-the-wire branch `term.replica.Replica.apply` copies rows into a `term.grid.Grid` of styled cells with a cursor, returns `.painted` or `.resync`, and `attachArgs` spells the re-attach. None of that has a terminal, a fork or an escape byte under it. A native program links it as it is. - **The wall never crossed.** The tile set, focus, births, the two end arms, the picker, the layout file's seat and persist and the once-a-second host grade all live under `src/tui/`, written against a cell painter under `paint_mu`, with rails and span-bounded clears. The hub already shows what a second surface does when it wants that policy: `webhub.applyList` is a copy of `wall_host.planHostDiff`, and it is an open issue. A native program that "starts as a wall" would either be a third copy under `src/gui/` or reach into `src/tui/` for code that assumes a terminal. This spec does neither: `muxg` has no wall policy, and native panes wait on lifting that policy out of `src/tui/` into a painter-free module (see Deferred). - **waystty's painter was never the problem.** `~/code/rad/waystty` (Wayland + Vulkan, same ghostty pin and Zig 0.15.2 as mux) was abandoned as "not performant". Measured 2026-09-04 in ReleaseFast under `cat /dev/random`: 71 frames, per-frame total average 256 µs, p99 784 µs. What starved it is the pty drain loop in its `main.zig`, which reads and feeds the emulator until `WouldBlock` — which never comes under a flood — so no frame is ever painted. A mux client has no such loop: the daemon owns the pty and the client receives rows at whatever rate it applies them. Its `cell_instance.zig` and `font.zig` (fontconfig chooses the face, freetype rasterises) are the two pieces worth carrying, by reading and rewriting against `term.grid`, and its per-stage frame-timing ring is the habit that found the real fault. Nothing else of it is copied: not the Wayland protocol code, not the Vulkan setup, not the loop. - **There is no key mode to track.** `keymap.encode` has no application cursor-keys state; the wall and the browser core send the same arrow bytes whatever mode the session set. `muxg` inherits that exactly, so a key means one thing on all three surfaces. ## Design ### 1. Build root and target A new folder, `src/gui/`, one row in the layout table: | Folder | Row — its child files | |---|---| | `src/gui/` | `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` | Its entry is the separate `muxg` row rooted at `src/cli/muxg.zig`. The binary is `muxg`, separate from `mux`: the Linux release of `mux` is static musl, and this program links the system's SDL3, freetype, fontconfig and HarfBuzz dynamically through pkg-config, against native glibc. GL functions are loaded by name through SDL, so there is no libGL link; compilation still requires the OpenGL development headers for the 3.3 declarations. `zig build native` is an opt-in step that builds `muxg` and `native-test` runs its unit tests; `make native` calls both. `make native-e2e` builds ReleaseSafe and checks the binary's reported build mode before the real journey. `make build`, `make check` and `make ci` never touch it, so a box without SDL3 builds and gates mux as before. `src/gui/` imports `client` and `term` and nothing else of ours. `checkSourceBans` reads it like any folder under `src/`, so the platform bans of rule 7 hold there. Two rules of its own, stated in `native.zig`'s header: - **No wall policy enters `src/gui/`.** No tiles, no layout file, no picker, no host grade, no session list. A viewer shows one session. - **SDL is confined to `frame.zig` and `muxg.zig`.** No other file under `src/gui/` sees an SDL type. `font`, `atlas`, `quads` and `bench` are plain Zig over `term.grid` and freetype, and `gl` sees GL alone. SDL3 is the windowing layer, chosen over GLFW and raw Wayland: it opens the window and the GL context on Wayland, X11 and later macOS from one code path; it delivers keyboard layouts, dead keys and compose as one UTF-8 text-input event, which waystty never finished doing by hand; every box we build on packages it; and its own GPU API sits behind Metal and Vulkan, so a painter written against instanced quads can change backends without a new windowing layer if GL on macOS bites. ### 2. The pump — `src/client/session_pump.zig` A new unit in `src/client/`, with no SDL in it and no terminal under it. One thread owns the transport from dial to close. Its inputs are a `client.Target`, the initial cols and rows, a mailbox and a wake callback. Its output is a `Replica` it owns, behind a mutex, and a small `State` the window thread reads: attached, reconnecting, exited with a code, refused with a reason, or taken. The thread dials through the existing handoff (`Transport.open` and the ssh `--start` ask, `HandoffTarget.asked` defaulting to false as for every dial), sends the attach frame with the current size, then loops on a poll over the transport fd and the doorbell: it reads frames, applies each through `Replica.apply` under the mutex, feeds every non-replay frame to `ClientCore.receive`, and calls the wake once per pass that changed anything. Socket and pipe reads consume one frame per readiness check; QUIC consumes at most 64 buffered frames before servicing the mailbox and waking the painter. A full QUIC batch schedules another pass without waiting for new socket traffic. The wake is a function pointer; the window side keeps at most one outstanding wake event and bounds event processing before painting, so continuous arrivals cannot starve a frame. The mailbox carries the four things a viewer can say — input bytes, a resize, a detach and quit — and a nonblocking pipe doorbell rings the pump out of its poll to send them. A full doorbell never blocks a post: the queued mailbox remains authoritative. The window thread never touches the transport. Frame handling mirrors the wall pump arm for arm and no further. A fresh attach requests seq 0; a reconnect resumes from `Replica.attachArgs`' retained sequence and epoch: - `BadPayload` from a short snapshot leaves the grid untouched and is skipped. `SnapshotAborted` makes the replica unusable: publish a failure, wake the window and exit 1. Unexpected pump exits also publish failure. - `.resync` from `apply`: re-attach at seq 0, as `wall_pump` does, because a quoted seq after a resync invites an unfixable delta. - `exit_status`: before any replay frame on the current attach it is the refusal path and the state is `refused` with the payload; after, `exited` with the shell's code. The pump returns. - `taken_over`: the wall treats it as an end and so does this (the current daemon does not send it; the arm is wire-compat). State `taken`, return. - Effects from `ClientCore`: a bell and a clipboard set are recorded in the state for the window to read. Selection replies, agent channels, session lists and end replies are not handled — a viewer asks none of those questions. - A lost link (read error, `.closed`): state `reconnecting`, redial with `nextBackoffMs`, re-attach with the replica's `attachArgs`, until the mailbox says quit. Reset admission state on every attach while retaining the resume sequence and epoch on reconnect. Closing must interrupt reconnect waits; failed initial dials use the error path below. The header names the debt: this is the third terminal-free attach loop, after `webhub.pumpTile` and `mux a`'s, and `pumpTile` is the first candidate to move onto it. It is not moved in this change. ### 3. The painter — `src/gui/` **`font.zig`** asks fontconfig for the system monospace face (`monospace` pattern, default size 16 pixels at 100% display scale, overridable by `--font-px`) and freetype for glyph bitmaps. HarfBuzz shapes each cell's complete UTF-8 cluster within the daemon's narrow or wide span; it never shapes across cells. Regular, bold, italic and bold-italic variants are matched or synthesized. It measures the cell from the face's advance width and its ascender-plus-descender height, in whole pixels, and rasterises glyph IDs on demand. Colour emoji, fallback faces and hinting choices are deferred; a codepoint the face lacks paints as the face's missing-glyph box. The window selects its raster size from SDL's display scale after creation. At 200% scale, the default font uses 32 physical pixels. A scale change rebuilds the font, shaped runs and atlas, then resizes the session from the new cell metrics and framebuffer size. Linux prefers native Wayland to avoid compositor scaling of an X11 buffer; explicit SDL driver settings still take precedence. **`atlas.zig`** is one R8 texture, shelf-packed, grown by re-upload only when a glyph first appears. It never shrinks. Each entry is the glyph's texture rectangle plus its bearing, keyed by face variant and glyph ID. Shaped runs are cached by owned complete text and font variant. Resolve all atlas insertions before computing normalized texture coordinates, so growth cannot invalidate the frame being drawn. **`quads.zig`** is the port of waystty's `cell_instance.zig`, rewritten over `term.grid`. Each cell emits an effective background when needed, a positioned glyph run, and any solid decoration instances. Glyphs retain their rasterized size and shaping offsets within the authoritative one- or two-column span; trailing wide-cell halves emit no duplicate glyph. Render every defined `proto.CellStyle` flag, including inverse, faint, invisible, blink, strikethrough, overline and all underline variants and colours. Invisible suppresses glyphs and decorations. The palette is a fixed table for the 256 colours plus RGB pass-through. The cursor is one more quad over its cell, in the foreground colour. A row's instances are built from a `Row` and a column offset, so the unit test can hold the offset non-zero. **`gl.zig`** owns the OpenGL 3.3 core objects: one vertex array, one instance buffer, one program with the two shaders as string constants, the atlas texture, and one instanced draw per frame with backgrounds first in the buffer and glyphs after, so a glyph is never covered by its own cell's background. **`frame.zig`** is the loop the window thread runs and the one file that sees SDL. It creates the window and the GL context with vsync on, sets the wake to push a user event, and waits on SDL's event queue. On a wake or a resize it locks the replica, rebuilds the instance list from the whole grid, unlocks, uploads the instances and any atlas growth, draws and swaps. The whole-grid rebuild every frame is deliberate: a large window is on the order of ten thousand cells, and the timing table is what will say whether dirty rows ever matter. A window resize floors the drawable size to whole cells and, when cols or rows changed, sends a resize through the mailbox; the daemon follows the latest active client, so the session takes the window's size. Frames are painted only on a wake, a resize or an expose, plus timed repainting while blinking cells are visible. An ordinary idle window draws nothing. Bell-title restoration also has a deadline. **`bench.zig`** is a ring of per-frame stage times in microseconds: `apply` (pump side, the most recent successful `Replica.apply` duration sampled when the window rebuilds), and on the window side `rebuild`, `atlas_upload`, `instance_upload` and `draw_swap`. Idle passes are not recorded. `total` sums only the four window-side stages; `apply` is reported separately and never counted as window latency. The table — min, average, p99 and max per stage and in total — prints to stderr on exit and on SIGUSR1, always compiled in, in the shape waystty's did: ``` === muxg frame timing (243 frames) === stage min avg p99 max (us) apply 2 4 15 89 rebuild 1 12 124 890 atlas_upload 0 180 5200 8100 instance_upload 1 6 24 71 draw_swap 3 8 35 210 total 9 210 5400 8800 ``` ### 4. Input SDL's text-input events carry UTF-8 for anything that types a character; those bytes go to the mailbox as an `input` frame unchanged. Key-down events for everything else become a `keymap.Event`: the arrows, Home, End, Insert, Delete, Page Up, Page Down, F1 through F12, Enter, Tab, Backspace and Escape by name, and a printable character (including punctuation and space) held with Ctrl or Alt as `.char` with the codepoint and the modifiers. `keymap.encode` produces the bytes, so the key table stays in one file and a chord means the same on the wall, in the browser and here. The SDL keycode to `keymap.Event` mapping is a pure table in `frame.zig`, unit-tested without a window. A consumed modifier chord must not also be sent as text; text composition and AltGr retain their layout-produced UTF-8. There is no prefix chord: `Ctrl-\` is a byte for the session, because a viewer has nothing to switch to. Closing the window sends detach and exits 0. A bell flashes the title for a moment; a clipboard set is read and ignored (v1 has no clipboard). Mouse events are dropped. Losing window focus sends nothing, and regaining it sends nothing: the daemon's latest-wins follows input, not focus. ### 5. Error handling - A dial that fails prints the existing `client.openFailure` words to stderr and exits 2, as `mux` does. - `exited` closes the window and the process exits with the shell's code, as a piped `mux` does, because a script can read it. `refused` prints the daemon's reason and exits 1. `taken` prints one line and exits 0. - `reconnecting` puts `[reconnecting]` in the title and keeps the last grid painted until frames resume or the window closes. - A missing library is a link error in pkg-config's own words at `make native`; nothing falls back. - A local dial to a socket nobody answers exits 2 with `muxg: no daemon at PATH (run: mux d start -d --sock PATH)`. `muxg` never starts a local daemon: remote SSH handoff retains its own remote-start behavior. The self-exec rule says an auto-start may only run the image already running, and this image is not the daemon's. - A font operation that fails exits 2 with its named error (for example `FaceLoad` or `GlyphLoad`); a GL context SDL cannot create exits 2 with SDL's error string on stderr. - The pump thread ending for any reason the state does not name ends the window with exit 1 and the frame table on stderr. ### 6. Testing Both layers are opt-in under `make native` and outside `make ci`. **Unit tests, no window.** `native-test` links the same libraries, opens no window, and runs: - `quads` over a `Row` built from decoded cells, with the column offset non-zero: a blank cell emits nothing, a styled-background blank emits one quad, complete text clusters retain every shaped glyph and offset, wide-cell trailing halves emit no duplicate glyph, and the cursor lands on its cell. Cover each style and underline variant, combinations, invisible decorations and both blink phases. - The drawable-size to cols and rows flooring at a non-square cell size, and the resize decision (changed vs unchanged) it feeds. - The SDL keycode to `keymap.Event` table, both directions worth pinning: a named key maps, a bare printable key does not (it arrives as text), and Ctrl punctuation/space and Alt chords produce exactly one sequence. - The atlas packer: a second glyph lands beside the first, a row that overflows opens a new shelf, and growth keeps every earlier entry's rectangle. **One canonical user journey, `test/native_journey.py`**, anchors `make native-e2e` against isolated XDG homes and owned daemons. It keeps one workspace alive across the compatible checks instead of reopening a basic window for each feature: 1. Refuse a Debug binary, open an empty persistent window, and select the first real daemon session explicitly. 2. Create one pane beside and one below through the picker, using both Unix and QUIC transports; cancellation must not attach or create anything. 3. Navigate and type independently in all three panes, then copy and paste Unicode/multiline text; compare daemon grids, framebuffer regions, layout geometry, and kernel PTY dimensions. 4. Move both dividers by pointer and keyboard, including clamps and a batched release/window-resize/input sequence, without replacing pane identities. 5. Keep a bounded producer active in one pane while a QUIC neighbour accepts input and paints; frame count must advance and p99 must remain in budget. 6. Run the font/config restart matrix on that same workspace, including a real fallback glyph when the independently inspected installed faces provide one. 7. Close and reopen around raw DEL-adjacent text, verify retained pixels and identities, and attach a terminal client to the same session. 8. Confine remote loss and shell exit to their panes, then represent and close an initially unavailable target at a tiny window size. Picker races, persistence failures, theme parsing, selection/clipboard, application mouse, wheel, tmux, DPI transitions, stress, and real OpenSSH retain directly runnable probes for targeted work. They are not chained behind `make native-e2e`: destructive and platform-specific fixtures must not turn the ordinary journey into the same fresh workspace repeated under different names. At the fixed 960×600 test window, the window-side total p99 budget is 20,000 microseconds over the timing ring, including draw/swap and any vsync wait. Report pump apply time separately; it is not window latency. The flood must show producer progress between frame-count samples and remain active through both samples. A timing table without concurrent frame progress fails, regardless of its percentiles. The budget is stated for ReleaseSafe or ReleaseFast only, and the leg refuses to grade a Debug binary, because a Debug ghostty runs its page-integrity check on every mutation and a number measured there means nothing. SDL3's offscreen driver yields a GL context on the development box. A real Wayland window also rendered an isolated session and produced a framebuffer capture successfully on 2026-09-04. Local QUIC and `--via` pipe smoke tests also rendered and accepted input; the mux-e2e VM remains to be measured. If it does not there, that environment may run the same leg under Xvfb with SDL's X11 driver. ## What this adds to the tree - `src/gui/native.zig` `font.zig` `atlas.zig` `quads.zig` `gl.zig` `frame.zig` `bench.zig`; `src/cli/muxg.zig`. - `src/client/session_pump.zig`, re-exported from `client.zig`. - `build.zig`: the `native` and `native-test` steps, pkg-config for SDL3, freetype2, fontconfig and HarfBuzz, the table row, the two folder rules. - `Makefile`: `native`, `native-e2e`. - `test/native_journey.py` and focused native acceptance scripts. - `README.md`: one section, `muxg TARGET`, and that it is a viewer. - `CLAUDE.md`: the table row, the two rules, the build line. ## Deferred - **Selection and clipboard**, both directions, with `select.zig`'s rules. - **Scrollback** through the dense-row chunk path, on the wheel. - **Mouse** reports to the session. - **Colour emoji and fallback faces.** - **Dirty-row rebuild**, only if the table says the whole-grid rebuild is what costs. - **The macOS build**: the same `make native` on a Mac with Homebrew's SDL3, GL 3.3 core (deprecated there but present), or SDL's GPU API behind Metal if it bites. - **Native panes.** Prerequisite: lift the wall's policy — tiles, focus, births, ends, the layout file, the grade — out of `src/tui/` into a painter-free module with painting behind an interface, so the terminal wall, the hub and `muxg` drive one model. That is the refactor the hub's duplicated grade already asks for. Until then a wall inside a window is `mux` running inside a `muxg` session, at the cost of a second replica hop and nothing else. - **Moving `webhub.pumpTile` onto `session_pump`.**