a73x

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`.**