a73x

535ccca0

docs: the zoom is a lens — supersede the real-attach decision, retable the keys

a73x   2026-08-19 22:51

Commit message
docs: the zoom is a lens — supersede the real-attach decision, retable the keys

decisions.md marks "the wall's zoom is a real attach, and that is the whole
design" superseded rather than rewriting it, house style, and names what
survives it: the generation counter and the selection-under-the-mutex argument
were about a terminal changing hands, not about a child process. The new entry
records promote/demote, why demote deliberately sends nothing, why the chord
table and the prediction overlay are shared rather than copied, why input
leaves on the pump's thread, and both mutations — including the dead assertion
one of them exposed.

README grows a second wall key table for the zoomed half, and says what a
zoomed tile does NOT have: the mouse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

README.md
Old New
@@ -39,7 +39,7 @@ Inside a session:
39 | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | detach (session keeps running) | 39 | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | detach (session keeps running) |
40 | `Ctrl-\` `c` | create a session and switch to it (the old one keeps running) | 40 | `Ctrl-\` `c` | create a session and switch to it (the old one keeps running) |
41 | `Ctrl-\` `n` / `Ctrl-\` `p` | switch to the next / previous session, wrapping | 41 | `Ctrl-\` `n` / `Ctrl-\` `p` | switch to the next / previous session, wrapping |
42 | `Ctrl-\` `w` | show every session as a wall (`j`/`k` select, `Enter` types, `q` comes back) | 42 | `Ctrl-\` `w` | show every session as a wall (`j`/`k` select, `Enter` zooms, `q` comes back) |
43 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | 43 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) |
44 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | 44 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) |
45 45
@@ -167,18 +167,39 @@ watching never resizes anyone. The stripes are cut once, from the terminal's
167 size when the wall opened, so resizing that terminal while the wall is up 167 size when the wall opened, so resizing that terminal while the wall is up
168 misplaces them until you leave and reopen it. 168 misplaces them until you leave and reopen it.
169 169
170 Zoomed out (the wall):
171
170 | Key | Effect | 172 | Key | Effect |
171 |---|---| 173 |---|---|
172 | `j` / `k` (or `n` / `p`) | select the next / previous stripe, wrapping | 174 | `j` / `k` (or `n` / `p`) | select the next / previous stripe, wrapping |
173 | `1`-`9` | select that stripe, counting from the top | 175 | `1`-`9` | select that stripe, counting from the top |
174 | `Enter` | type into the selected session; `Ctrl-\` `d` comes back | 176 | `Enter` | zoom the selected stripe — type into that session |
175 | `q` (or `Ctrl-\`) | leave the wall | 177 | `q` (or `Ctrl-\`) | leave the wall |
176 178
177 The selected stripe's label bar carries a `>` marker. `Enter` zooms it: 179 Zoomed in (one session, full screen):
178 the wall hands the terminal to an ordinary `mux` attached to that session, 180
179 so typing is a full-size client claiming the grid — never a tile, which 181 | Key | Effect |
180 stays 0×0 and passive. `Ctrl-\` `d` detaches that client and the wall 182 |---|---|
181 comes back, current. 183 | `Ctrl-\` `d` / `Ctrl-\` `w` | zoom back out to the wall |
184 | `Ctrl-\` `n` / `Ctrl-\` `p` | move the zoom to the next / previous tile |
185 | `Ctrl-\` `l` | skip to the last tile you zoomed (out, if that was this one) |
186 | any key not behind `Ctrl-\` | goes to the session |
187
188 `Ctrl-\` is the prefix, so it never reaches the session; any other command
189 key after it is swallowed along with it, as in a plain client.
190
191 The selected stripe's label bar carries a `>` marker. `Enter` zooms it in
192 place: the tile's own connection is resized from 0×0 to this terminal's
193 size and starts carrying your keystrokes, so the client that types is a
194 full-size one, claiming the grid the ordinary way. Nothing is dialled and
195 nothing new attaches. Every other tile keeps replicating behind the zoom,
196 so `Ctrl-\` `n` and `Ctrl-\` `l` move between sessions instantly — no
197 reconnect, no snapshot, no flash. Zooming out stops the keystrokes and puts
198 the stripes back; the session keeps the grid the zoom claimed, so nothing
199 reflows when you look away.
200
201 Typed input at a zoomed tile is predicted locally, as in a plain client.
202 The mouse is not: a zoomed tile is keyboard-only for now.
182 203
183 The same wall, from inside a session: `Ctrl-\` `w` shows every session on 204 The same wall, from inside a session: `Ctrl-\` `w` shows every session on
184 the daemon you are attached to, and `q` puts you back where you were. 205 the daemon you are attached to, and `q` puts you back where you were.
docs/decisions.md
Old New
@@ -4138,6 +4138,17 @@ work out is not a reason to end the wall the user is still standing in. The
4138 no `--via` spelling to parse: no tile can hold that target, and pretending 4138 no `--via` spelling to parse: no tile can hold that target, and pretending
4139 otherwise would add an error path nothing can reach. 4139 otherwise would add an error path nothing can reach.
4140 4140
4141 **Superseded in the wall-home-screen phase 1 (2026-08-19).** The zoom is no
4142 longer an attach at all; the whole child-spawn path above — `zoomArgv`, the
4143 spawn, the wait, the failure notice, the `paint_mu` held for a child's whole
4144 life — is deleted. What survives verbatim is the generation counter and the
4145 selection-under-the-mutex argument, which turned out to be about a terminal
4146 changing hands and not about a child process. The argument that a typing tile
4147 forks latest-wins had a hidden premise: that the tile types AT 0×0. Promote
4148 resizes before the first keystroke can be sent, so the client that types is a
4149 full-size one claiming the grid the legitimate way, and the rule keeps no
4150 exception. See the phase-1 entry below.
4151
4141 ## 2026-08-19 (the wheel, and who owns the mouse) 4152 ## 2026-08-19 (the wheel, and who owns the mouse)
4142 4153
4143 The complaint: spinning the wheel in a mux session walked the shell's history 4154 The complaint: spinning the wheel in a mux session walked the shell's history
@@ -4242,3 +4253,125 @@ Shift+drag still selects, and that is the mitigation rather than a fix. It is
4242 taken on purpose: the wheel is used constantly and the drag has a documented 4253 taken on purpose: the wheel is used constantly and the drag has a documented
4243 key to fall back on, where the wheel had nothing. A native copy mode is what 4254 key to fall back on, where the wheel had nothing. A native copy mode is what
4244 would settle it properly, and it stays on the roadmap. 4255 would settle it properly, and it stays on the roadmap.
4256
4257 ## 2026-08-19 (the wall is the home screen, phase 1: zoom is a lens)
4258
4259 Spec: `docs/superpowers/specs/2026-08-19-wall-home-screen-design.md`. Phase 1
4260 of three: in-place zoom inside `wallview`. Phase 2 is attach-adds-a-tile and
4261 `x`-forgets; phase 3 converges `client.zig`'s session loop with the wall.
4262
4263 **An unzoomed tile claims nothing; zooming promotes the tile's existing
4264 connection, unzooming demotes it.** That one sentence replaces "tiles are
4265 passive, and interaction spawns a client". Promote = resize the tile's attach
4266 from 0×0 to the terminal's size, then forward keystrokes down it. Latest-wins
4267 already makes a resize the legitimate claim on a grid, and the resize goes out
4268 before the first keystroke can, so the client that types is a full-size one and
4269 the passivity rule keeps no exception. The daemon changes not at all — not one
4270 line of `server.zig`, no new frame, no new verb.
4271
4272 **Demote is client-local and sends NOTHING.** The tempting symmetry — hand the
4273 grid back by resizing to 0×0 — does not exist: `applySize` refuses sub-minimum
4274 resizes, so a 0×0 "relinquish" would be silently ignored, and a slot that kept
4275 its promoted size claims nothing anyway under latest-wins, because latest-wins
4276 follows the most recently ACTIVE client and a demoted tile is never active
4277 again — except for bytes typed BEFORE the demote, which are deliberately still
4278 delivered (they were typed at that session; losing them to a race would be the
4279 worse bug). `.input` calls `claimGrid`, so such a tile can re-assert the size
4280 it already holds, once, on its way out. Re-claiming a size it was already at
4281 moves nothing. So the slot keeps the size it claimed, the session keeps its grid (no
4282 resize storm every time the eye moves), and the stripe crops it — which is what
4283 stripes have always done. "Claims nothing" is enforced by the keyboard never
4284 queueing a byte for a tile it is not zoomed into: `sendKeys` has exactly one
4285 caller, inside the `zoomed` branch of the input loop, and that is the whole
4286 mechanism.
4287
4288 **Moving the zoom is a repaint, not a dial.** Every tile's pump keeps running
4289 whether or not it is the zoom, so every replica is hot — `Ctrl-\ n`/`p`/`l`
4290 change which one is painted full-screen and which one the keyboard feeds. The
4291 old design could not offer this at any price: skipping between two sessions
4292 meant a detach, a dial and a snapshot round trip each way. Measured in e2e as
4293 the absence of a second attach: `muxd stats`'s per-session `clients=` gauge,
4294 sampled every 200ms for the whole life of the wall, peaks at 1 on both tiles
4295 across a zoom, a skip to the other tile, a skip back, and an unzoom.
4296
4297 **The chord table is shared, not copied.** `client.PrefixFilter` is now `pub`
4298 and wallview reads its zoomed chords out of it; `zoomChord` maps actions to
4299 wall meanings and is the only new table. A twin chord table is the failure mode
4300 this repo has already paid for once. `l` joined the shared table as
4301 `.last_session`, which the client swallows — it has no last session to skip to
4302 until phase 3, and a shared table naming a key one caller has no meaning for is
4303 cheaper than two tables.
4304
4305 `d` and `w` BOTH unzoom, deliberately. `d` is the muscle memory the child-spawn
4306 era left behind (that child was a real client, and `Ctrl-\ d` detached it) and
4307 `w` is where the model is going. During phases 1-2 a plain client's `Ctrl-\ w`
4308 still spawns a wall while a zoomed tile's `w` leaves one; the two meanings
4309 coexist on purpose and phase 3 collapses them.
4310
4311 **Prediction is the client's, not a second overlay.** `offerKeystroke`,
4312 `reconcileOverlay` and `paintOverlay` became `pub` rather than being reproduced
4313 — the overlay is the single place where "a prediction never enters the replica"
4314 is kept, and a second implementation would be a second place to break it. One
4315 consequence worth naming: a tile feeds `.pty_mode` frames to its overlay even
4316 while it is a stripe, because the pty's line discipline is the entire gate on
4317 speculation (a password prompt must never be predicted) and a promote that
4318 started at `.never` would take a round trip to learn what it already knew.
4319
4320 **Input leaves on the pump's thread.** The keyboard runs on the main thread and
4321 hands bytes to the tile through a fixed mailbox plus a non-blocking doorbell
4322 pipe. A `Transport` has exactly one owning thread — QUIC's `service()` and
4323 `writeFrame` share state, and a reconnect swaps the whole struct out from under
4324 the pump — so a keyboard that wrote frames itself would be a data race on every
4325 `.quic` tile. The doorbell is what keeps a promote and a keystroke immediate
4326 rather than one poll timeout (100ms) away.
4327
4328 **Mouse reporting is deliberately not wired up.** The wheel/scrollback
4329 machinery landed in `client.zig` days earlier, but the wall never asks its
4330 terminal for mouse reports, so nothing arriving at a zoomed tile can be one and
4331 filtering there would be pure loss. Phase 3 brings it for free when the two
4332 input loops become one; until then a zoomed tile is keyboard-only.
4333
4334 **A gauge could not prove it; a counter can.** The first witness was
4335 `clients=`, sampled every 200ms and asserted on its peak, and review found two
4336 ways past it: a connection that closes as another opens never shows two at
4337 once, and one that lives under a sample interval is invisible. So `muxd stats`
4338 grew `attaches=`, a cumulative count of accepted attaches, and the assertion is
4339 now that the delta across a whole wall session is exactly the tile count — two
4340 tiles, two attaches, however far the zoom moved. Measured, not argued: a
4341 mutation that makes the zoom close its connection and re-dial leaves the gauge
4342 peaking at 1 on BOTH sessions (it passes) while the counter reports 3 and the
4343 leg fails. The gauge is kept anyway, because it localises — it names which
4344 session grew a second watcher — but it is no longer the proof.
4345
4346 **Only pumps answer `repaint_gen`, and a dead tile has no pump.** Observed in a
4347 live capture during review, two symptoms of one hole: zooming a tile whose pump
4348 had ended painted an entirely blank terminal (no cursor, no label, no way out —
4349 indistinguishable from a hung multiplexer), and after any zoom a dead tile's
4350 stripe never came back at all. Survivable when the screen was cleared once per
4351 zoom; phase 1 clears it on every `n`/`p`/`l`. `Tile.alive` is set false by the
4352 first `defer` in `pumpTile`, so every exit path sets it, and the keyboard — the
4353 only thread left — paints for the dead: one line naming the tile, its state and
4354 the chord out, and its bar redrawn on the way back to the wall. Deliberately
4355 ONLY for dead pumps: a `[reconnecting]` tile still has a thread that will
4356 repaint its hot replica within a poll timeout, and drawing over that would
4357 replace something true with something stale.
4358
4359 **A mailbox that overflows drops the chunk WHOLE.** The first version copied
4360 what fit, which splices: the head of one read lands, the middle of the stream is
4361 lost, and the next read appends to the head — so a shell can be handed a command
4362 nobody typed. Losing a whole read is a keystroke that did not arrive, which
4363 users understand and retry. The tile's bar narrates it (`[up, input dropped]`)
4364 until input moves again, because input vanishing silently is the worst version
4365 of this.
4366
4367 Three mutations, to check the legs have teeth. Making a demoted wall forward
4368 input to the selected tile fails the zero-input leg; making a promote dial a
4369 second connection and attach full-size on it — the child-spawn design's exact
4370 observable — fails the flat-attach leg at `the daemon accepted 3 attaches (want
4371 2)`; making a zoom move close and re-dial fails the same leg while the gauge
4372 alone would have passed. The first mutation also exposed a dead assertion: the
4373 negative marker was spelled `wallnope`, whose `n` and `p` the unzoomed wall
4374 consumes as selection keys, so what a leak would actually deliver is `wallne`
4375 and the grep could never have matched. Renamed to `wallmute`, every letter of
4376 which the wall swallows, and re-checked under the mutation before being
4377 believed.