535ccca0
docs: the zoom is a lens — supersede the real-attach decision, retable the keys
a73x 2026-08-19 22:51
Commit message
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. | ||