summaryrefslogtreecommitdiff
path: root/docs/design.typ
diff options
context:
space:
mode:
Diffstat (limited to 'docs/design.typ')
-rw-r--r--docs/design.typ2243
1 files changed, 1927 insertions, 316 deletions
diff --git a/docs/design.typ b/docs/design.typ
index 8b93614e..d322920a 100644
--- a/docs/design.typ
+++ b/docs/design.typ
@@ -1,54 +1,144 @@
-#set page(margin: 2.2cm)
-#set text(font: "New Computer Modern", size: 10.5pt)
-#set par(justify: true)
-#show heading: set block(above: 1.4em, below: 0.8em)
+// Diagrams come from cetz, which is the one thing here that is genuinely
+// painful to hand-roll. Pinned to the version in the local package cache so
+// this document builds offline; `typst compile docs/design.typ docs/design.pdf`
+// must never need the network, because the PDF it produces is a test fixture
+// (src/pdf.zig and src/pdf_pane*.zig open it, and `zig build mupdf-check`
+// renders and searches it).
+#import "@preview/cetz:0.5.1"
-#align(center)[
- #text(17pt)[*Pardes: A Text Environment*]
+#set page(paper: "a4", margin: (x: 1.5cm, y: 1.8cm), columns: 2, numbering: "1")
+#set text(font: "New Computer Modern", size: 8.8pt)
+#set par(justify: true, leading: 0.55em)
+#set heading(numbering: "1.1")
+#show heading: set block(above: 1.15em, below: 0.6em)
+#show heading.where(level: 1): set text(size: 10.2pt)
+#show heading.where(level: 2): set text(size: 9.2pt)
+#show heading.where(level: 3): set text(size: 8.8pt, style: "italic")
- #text(11pt)[design for the rewrite — 2026-07-05]
+// Listings are styled NATIVELY rather than through a package. codly is the
+// obvious choice and the cached 1.2.0 does not compile under typst 0.15 — it
+// still calls the pre-0.13 `pattern` — and a document that cannot be rebuilt is
+// worse than one with plainer listings. Six lines buy independence from that.
+#show raw.where(block: true): it => block(
+ width: 100%,
+ fill: luma(246),
+ inset: (x: 0.5em, y: 0.45em),
+ radius: 1pt,
+ breakable: true,
+ text(size: 7.2pt, it),
+)
+#show raw.where(block: false): set text(size: 8.1pt)
+
+#show figure.caption: set text(size: 7.6pt)
+#set figure(gap: 0.55em)
+#set table(stroke: 0.4pt, inset: 0.35em)
+
+/// A figure that spans BOTH columns, for the diagrams and listings that will
+/// not survive being folded into 8 cm. Floats to the top of a page, which is
+/// where a reader expects a wide figure to be.
+#let wide(caption: none, body) = place(
+ top,
+ scope: "parent",
+ float: true,
+ clearance: 1.2em,
+ figure(body, caption: caption),
+)
+
+/// Diagram labels are code more often than they are prose, and the inline-raw
+/// rule above sizes for body text. Every canvas below is wrapped in this so a
+/// symbol name inside a box does not outgrow the box.
+#let diagram(body) = [
+ #show raw.where(block: false): set text(size: 5.9pt)
+ #body
+]
+
+#place(top + center, scope: "parent", float: true, clearance: 1.5em)[
+ #text(16pt)[*Pardes: A Text Environment*]
+
+ #text(8.8pt)[Architecture and implementation of the rewrite]
+]
+
+#place(top + center, scope: "parent", float: true, clearance: 1.4em)[
+ #block(width: 82%, inset: (x: 0pt))[
+ #set par(justify: true, leading: 0.52em)
+ #text(8.2pt)[
+ *Abstract.* Pardes is a text environment in the acme tradition: columns of
+ panes, each pane a tag line plus a body, the body a live terminal, a file,
+ an image or a PDF. It is one program with five thin shells — a terminal
+ over libvaxis, an SDL3 window, a browser tab driven by vanilla JavaScript,
+ an AppKit application, and an ESP32-P4 microcontroller — where the
+ prototype had three parallel implementations of one editor. Two seams
+ carry that. Outward, the core is a state machine over plain values:
+ `Event` in, `Surface` and a queue of `Effect` out, and nothing that cannot
+ be said in those types exists in pardes. Downward, `host.VTable` is
+ twenty-one optional function pointers, every null one answered in-process,
+ so the zero-method host is both the test harness and the browser. A
+ session can also be a daemon: the core keeps the ptys and the disk and N
+ frontends carry only a screen, a keyboard and a clipboard.
+ ]
+ ]
]
= What it is
-Pardes is a text environment in the acme tradition: columns of panes, each pane a
-tag line plus a body; the body is a live terminal, a file, an image, or a PDF. The
-mouse carries meaning — left selects, middle executes, right looks. Everything on
-screen is text and all text is equally alive, whether the shell printed it or the
-user typed it.
+== The acme inheritance
-It runs in four places: the terminal (libvaxis), a native SDL3 window (the
-steamdeck), the browser (a freestanding wasm core driven by vanilla
-JavaScript and rendered as HTML/CSS), and a native macOS app (an AppKit and
-CoreText shell over a static `libpardes.a`). The rewrite exists because
-the prototype grew three parallel implementations of one program. The rewrite has
-exactly one program and four thin shells.
+Columns of panes; each pane is a tag line plus a body; the body is a live
+terminal, a file, an image, or a PDF. The mouse carries meaning — left selects,
+middle executes, right looks. Everything on screen is text and all text is
+equally alive, whether the shell printed it or the user typed it.
-= The shape
+Two acme mechanisms are reproduced rather than reinterpreted. A *dump* is the
+session as data: `pardes -l state.zon` on every platform reconstructs the panes
+— terminals by replaying their raw VT streams into fresh emulators, files,
+images and PDFs from their bytes. Editable tag tails are stored separately from
+their dynamic live prefixes, and image records retain PETSCII, palette, and
+ASCII renderer choices. A PDF rides the dump's `image` kind, carrying its path,
+its bytes and the page it was on. The reader validates weights, scroll ranges,
+pane references, and bounded tag tails before constructing anything; invalid
+base64 fails instead of silently becoming empty content. A PDF record whose path
+cannot be opened — or a build without MuPDF — falls back to an ordinary file
+pane with those exact embedded bytes and its original editable tail.
+
+That is what collapses the prototype's *two* applications into one. Its browser
+build was a separate 800-line read-only replay viewer; here loading another
+instance's dump is a first-class feature of the one application, and the web
+shell is that application with an embedded dump and `spawn` left unanswered.
+Same core, no viewer fork.
+
+The other acme mechanism is a control filesystem, `src/acmefs.zig`
+(@fs).
+
+== One core, five shells
Pardes is a *library*, in the way ghostty-vt is a library: you feed it bytes and
events, and you read state out of it. Every platform owns its own event loop and
its own renderer; the core owns everything the user would recognize as pardes.
-#table(
- columns: (auto, 1fr),
- stroke: 0.4pt,
- [*core*], [layout, panes, modes, selection, click semantics, themes, text of the
- UI. Pure state machine: `update(event)` mutates, `render(arena)` returns the
- surface. No rendering, no event loop, and no IO that has an effect for it —
- but "no syscalls" was never true and is less true now: `look` reads a file
- a Look opened, walks a directory for Find and reads every candidate for
- Grep, `fonts` walks the font directories, and MuPDF opens a `.pdf`. Those
- are the ones that are cheaper done in place than round-tripped through an
- effect and back; everything with a lifetime — a pty, a window, the
- clipboard — is still asked for.],
- [*shell*], [one MODULE per platform (tty is one file; gui adds the CRT and
- gamepad files, a C font loader and eight GLSL shaders; web and macOS each
- add a host language).
- Owns the event loop, translates native input
- into core events, renders the core's surface, and performs the core's
- requested effects (spawn a shell, write a pty, open a link).],
-)
+*The core* owns layout, panes, modes, selection, click semantics, themes, and the
+text of the UI. Pure state machine: `update(event)` mutates, `render(arena)`
+returns the surface. No rendering, no event loop, and no IO that has an effect
+for it — but "no syscalls" was never true and is less true now: `look` reads a
+file a Look opened, walks a directory for Find and reads every candidate for
+Grep, `fonts` walks the font directories, and MuPDF opens a `.pdf`. Those are the
+ones that are cheaper done in place than round-tripped through an effect and
+back; everything with a lifetime — a pty, a window, the clipboard — is still
+asked for.
+
+*A shell* is one MODULE per platform: tty is one file, gui adds the CRT and
+gamepad files plus a C font loader and eight GLSL shaders, and web, macOS and the
+board each add a host language or a host repository. It owns the event loop,
+translates native input into core events, renders the core's surface, and
+performs the core's requested effects — spawn a shell, write a pty, open a link.
+
+The five, and what each one actually is: the terminal (libvaxis); a native SDL3
+window (the steamdeck); the browser (a freestanding wasm core driven by vanilla
+JavaScript and rendered as HTML/CSS); a native macOS app (an AppKit and CoreText
+shell over a static `libpardes.a`); and an ESP32-P4 microcontroller, a
+freestanding riscv32 *object* that the `zig_p4` package links beside its own
+`_start`, linker script and UART driver (`src/esp32p4.zig` header; the
+`pardes-esp32p4` object `build.zig` emits). `pardes.Platform` is
+`enum { tty, gui, web, macos, esp32p4 }`.
Native shells share `shell_bin`'s OSC 133 startup snippets, but not their
files. Each host owns a private `mkstemp` pair for its lifetime, writes and
@@ -57,341 +147,1841 @@ in child argv, and unlinks them at teardown. Concurrent tty, SDL and macOS
launches therefore cannot truncate, source, or replace one another's startup
files.
-This also collapses the prototype's *two* applications into one: the browser
-build today is a separate 800-line read-only replay viewer. In the rewrite,
-loading a dump of another instance is a first-class feature of the one
-application, the way acme handles dumps: `pardes -l state.zon` on every
-platform reconstructs the panes—terminals by replaying their raw VT streams
-into fresh emulators, files, images and PDFs from their bytes. Editable tag
-tails are stored separately from their dynamic live prefixes, and image records
-retain PETSCII, palette, and ASCII renderer choices. A PDF rides the dump's
-`image` kind, carrying its path, its bytes and the page it was on. The web shell embeds
-a dump and leaves `spawn` unanswered. The reader validates weights, scroll
-ranges, pane references, and bounded tag tails before constructing anything;
-invalid base64 fails instead of silently becoming empty content. A PDF record
-whose path cannot be opened—or a build without MuPDF—falls back to an ordinary
-file pane with those exact embedded bytes and its original editable tail. Same
-core, no viewer fork.
+== A shell need not share the process
+
+`pardes --detach[=name]` runs the core with no terminal of its own and
+`pardes --attach[=name]` makes a frontend of it over a unix socket, so one
+session can carry a terminal and an SDL window at the same time and outlive
+both. `main.nativeMain` dispatches `--detach` before it switches on the
+platform, because it is not a shell: the tty and gui builds can both be asked
+for one. The two flags together are refused there rather than resolved by
+declaration order, and so is `--fs` beside either of them — only a LOCAL session
+mounts the control filesystem, so `--fs --detach` used to be parsed, stored and
+served by nobody. Both flags take `=name` and never a separate word, so
+`pardes --attach README` opens `README` in a fresh session instead of attaching
+to one called `README`. See @detached and `docs/detached.md`.
+
+= The seam <seam>
+
+#wide(caption: [The core/shell seam. `Event` is the only way in; `Surface` and a
+queue of `Effect` are the only ways out. `host.VTable` is how an `Effect`
+reaches a resource, and every method a host leaves null is answered by
+`host.Fallback` inside the same process.])[
+ #diagram[
+ #cetz.canvas(length: 0.995cm, {
+ import cetz.draw: *
+ set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35))
+
+ // ---- the core ----
+ rect((6.0, 0.9), (11.6, 4.3), name: "core")
+ content((8.8, 4.02), text(7.6pt)[*the core* --- `src/pardes.zig`])
+ line((6.0, 3.78), (11.6, 3.78))
+ content((8.8, 3.42), text(6.2pt)[`update(ev)` --- one dispatch, mutates])
+ content((8.8, 2.92), text(6.2pt)[`render(arena)` #sym.arrow.r `*Surface`])
+ content((8.8, 2.42), text(6.2pt)[`emit(e)` #sym.arrow.r fixed ring, `limits.effect_cap`])
+ content((8.8, 1.92), text(6.2pt)[`nextEffect()` #sym.arrow.r `perform(e)`])
+ content((8.8, 1.32), text(6.2pt)[single-threaded by construction])
+
+ // ---- native input ----
+ rect((0, 3.0), (4.3, 4.3), name: "in")
+ content((2.15, 3.98), text(7.0pt)[*native input*])
+ content((2.15, 3.52), text(6.0pt)[termios+vaxis / SDL / DOM /])
+ content((2.15, 3.19), text(6.0pt)[AppKit / a byte sink on a UART])
+ line("in.east", (6.0, 3.65), mark: (end: "stealth"))
+ content((5.15, 3.92), text(6.4pt)[`Event`])
+ content((5.15, 3.5), text(5.6pt)[16 arms])
+
+ // ---- surface out ----
+ rect((13.3, 3.0), (18.0, 4.3), name: "paint")
+ content((15.65, 3.98), text(7.0pt)[*paint the grid*])
+ content((15.65, 3.52), text(6.0pt)[vaxis cell-by-cell / glyph atlas /])
+ content((15.65, 3.19), text(6.0pt)[DOM cells / CoreText / ANSI])
+ line((11.6, 3.65), "paint.west", mark: (end: "stealth"))
+ content((12.5, 3.92), text(6.4pt)[`Surface`])
+ content((12.5, 3.5), text(5.6pt)[cells, cursor,])
+ content((12.5, 3.16), text(5.6pt)[images, tracks])
+
+ // ---- effect out ----
+ rect((13.3, 1.1), (18.0, 2.5), name: "perf")
+ content((15.65, 2.2), text(7.0pt)[*perform*])
+ content((15.65, 1.78), text(6.0pt)[fork a pty, write a path, mark a])
+ content((15.65, 1.45), text(6.0pt)[directory, put text on a clipboard])
+ line((11.6, 1.8), "perf.west", mark: (end: "stealth"))
+ content((12.45, 2.05), text(6.4pt)[`Effect`])
+ content((12.45, 1.62), text(5.6pt)[18 arms])
+
+ // ---- the vtable ----
+ rect((6.0, -1.0), (11.6, 0.4), name: "vt")
+ content((8.8, 0.14), text(7.0pt)[`host.VTable` --- 21 optional methods])
+ content((8.8, -0.32), text(6.0pt)[`push_` #sym.arrow.r every host, returns nothing])
+ content((8.8, -0.68), text(6.0pt)[`pull_` #sym.arrow.r exactly one host answers])
+ line((8.8, 0.9), (8.8, 0.4), mark: (end: "stealth"))
+ line("vt.east", (13.3, 1.5), mark: (end: "stealth"))
+
+ // ---- fallback ----
+ rect((0, -1.0), (4.3, 0.9), name: "fb")
+ content((2.15, 0.62), text(7.0pt)[`host.Fallback`])
+ content((2.15, 0.18), text(6.0pt)[every null method, answered here:])
+ content((2.15, -0.18), text(6.0pt)[a virtual filesystem over the])
+ content((2.15, -0.52), text(6.0pt)[embedded source, a virtual])
+ content((2.15, -0.84), text(6.0pt)[clipboard, silent ptys])
+ line("vt.west", "fb.east", mark: (end: "stealth"))
+
+ // ---- answers loop back. Routed through the one corridor that is free of
+ // every box (x between `fb`/`in` at 4.3 and the core column at 6.0) and
+ // below the lowest box edge, so the canvas stays inside 0..18 and cannot
+ // bleed into the page margins.
+ line((15.65, 1.1), (15.65, -1.6), (5.72, -1.6), (5.72, 3.58),
+ mark: (end: "stealth"), stroke: (dash: "dashed"))
+ content((9.0, -1.94), text(5.8pt)[an answer returns as an ordinary `Event`: `paste`, `lsp_resp`, `pipe_resp`, `file_changed`])
+ })
+ ]
+]
+
+The boundary is three plain data types and one struct of function pointers.
+
+== `Event`: what comes in
+
+Sixteen arms (`pardes.Event`), in declaration order:
+`key`, `mouse`, `resize`, `output`, `eof`, `lsp_resp`, `pipe_resp`,
+`file_changed`, `paste`, `command`, `pdf_scroll`, `pinch`, `touch_scroll`,
+`pointer_leave`, `tick`, `fs_req`.
+
+`key` carries typed `text`. `mouse` carries press/release/motion/drag; button
+left, middle, right and the four wheel directions; a cell position; and the
+`ctrl` flag, because Ctrl-left-click is goto-definition. `resize` carries cols,
+rows, and — in a MuPDF build — the pixel size of one cell. `output` is bytes a
+pty produced, tagged with the pane id. `command` is one builtin line arriving
+from another process over the nested socket. `pointer_leave` exists because an
+out-of-range motion must clamp onto the last grid cell and a pointer that has
+left the drawable area must not: otherwise leaving the window previews whatever
+is under the final cell. `fs_req` is one filesystem request from a process that
+opened a file under the control mount, and its answer leaves as an
+`Effect.fs_reply` in the same update.
+
+Four of the arms are answers to something the core asked for; @effect
+names the asks.
+
+Touch policy belongs to the shell: web maps a one-finger tap to right-button
+LOOK and a drag past its tap slop to natural scrolling; native SDL keeps its
+two-finger gestures. A joystick cursor is a motion plus buttons. The shells do
+this translation and nothing else with input: one event vocabulary, five
+dialects translated at the door.
+
+`postEvent` is a value queue of 64 and asserts what cannot survive the trip
+(`Pardes.postEvent`): `output`, `paste`, `lsp_resp`, `pipe_resp`,
+`file_changed`, `command` and `fs_req` all carry a borrowed slice, so they are
+`unreachable` there and must be handed to `update` directly inside the host's
+borrow window. That is also what keeps the pty read path copy-free.
+
+== `Surface`: what goes out
+
+`cols`, `rows`, a grid of cells, a cursor, pixel attachments, and a bounded list
+of plain panel-transition tracks (`pardes.Surface`). This is the
+*canonical interface*: it is literally what the tty shell hands to vaxis, cell
+by cell. SDL rasterizes the same grid through a glyph atlas; the browser reads a
+packed copy and patches native DOM cells. If it cannot be expressed in the
+surface, it does not exist in pardes.
+
+Pixel images ride along as a list of PLACEMENTS rather than one per pane — a PDF
+pane contributes every page its viewport intersects, and pages can be
+arbitrarily short. The SDL shells blit RGBA; the tty shell uses kitty graphics
+when available and falls back to the petscii matcher. Transition tracks identify
+their pane by slot and serial and carry only phase, effect, frame, and from/to
+cell boxes. Canonical layout is committed immediately; tracks are finite
+presentation data.
+
+== `Effect`: what is asked for <effect>
-The boundary is two data types, both plain values:
+Eighteen arms (`pardes.Effect`). The core never performs IO for any of
+them; it asks.
-*Event* (in): `key` (which carries typed `text`), `mouse` (press/release/motion/
-drag; button left, middle, right and the four wheel directions; cell position;
-and the `ctrl` flag, because Ctrl-left-click is goto-definition), `pinch`,
-`touch_scroll`, `resize` (cols, rows, and — in a MuPDF build — the cell's pixel
-size), `paste`, `pdf_scroll`,
-`output` (bytes a pty produced, tagged with the pane id), `eof`, `command` (a
-builtin line arriving from another process over the nested socket), `tick`, and
-the answers to an ask: `lsp_resp`, `pipe_resp`, `file_changed`. Touch
-policy belongs to the shell: web maps
-a one-finger tap to right-button LOOK and a drag past its tap slop to natural
-scrolling; native SDL keeps its two-finger gestures. A joystick cursor is a
-motion plus buttons. The shells do this translation and nothing else with
-input: one event vocabulary, four dialects translated at the door.
+```zig
+pub const Effect = union(enum) {
+ spawn: struct { pane: u8, cwd: Buf(256) },
+ write: struct { pane: u8, bytes: Buf(64) },
+ resize_pty: struct {
+ pane: u8, cols: u16, rows: u16 },
+ open_link: Buf(256),
+ save_file: struct { pane: u8 },
+ save_text: struct {
+ pane: u8, serial: u32, path: Buf(256) },
+ write_dump,
+ set_clipboard,
+ read_clipboard,
+ lsp: struct { id: u32, kind: lsp.Kind,
+ pane: u8, offset: u32, arg: Buf(128) },
+ pipe: struct { id: u32 },
+ watch: struct { pane: u8, on: bool },
+ theme_file: struct {
+ generation: u32, on: bool },
+ dump_themes: struct { pane: u8 },
+ fs_reply: acmefs.Reply,
+ attach: struct {
+ pane: u8, name: Buf(attach_name_max) },
+ detach: struct { pane: u8 },
+ quit,
+};
+// doc comments elided; see the file
+```
+
+Every arm is a fixed-size value, and that is the constraint the widths record.
+Unbounded content is never in the union: `save_file` names a pane and the shell
+reads the bytes off the core, and `save_text` carries the *path* — bounded
+exactly like a spawn's cwd, so two saves armed in one batch cannot cross — while
+the bytes are read at drain time. `save_text` also carries the pane's `serial`,
+so a slot freed and reused before the drain writes nothing rather than another
+pane's text to that path. `attach_name_max` is 256 for the same reason `spawn`'s
+cwd is, and reusing that width is why the arm costs the ring nothing:
+`save_text` is still the widest member (`pardes.attach_name_max`).
+
+Four asks have an answer coming back. `read_clipboard` returns an ordinary
+`paste` event — or nothing at all when the shell cannot read the clipboard, which
+is most terminals, since they refuse the OSC 52 read. `lsp` returns `lsp_resp`.
+`pipe` returns `pipe_resp`. `watch` returns `file_changed` whenever the shell
+notices a text file or PDF moved under it.
+
+`theme_file` carries only a generation because the path lives in the core's fixed
+request buffer, which keeps an already-large ring compact. `fs_reply` carries no
+bytes either: `payload` says where they live — a staging buffer in the core, or a
+range of a pane's live text — and `fsPayload` resolves it during the drain, so a
+megabyte read costs one `writev` and no copy.
+
+`attach` and `detach` are the two arms no host method performs directly. `attach`
+must not tear anything down (@swap); `detach` is served only by a
+detached core's host, and the null case is the point rather than an oversight —
+a local tty or SDL shell has no session to leave, so `perform` reports that on
+the pane's message row instead of quietly quitting something (the `.detach` arm of
+`Pardes.perform`).
+
+The watch path, in full, because it is the ask with the most machinery behind
+it. The tty and SDL hosts and the detached daemon implement it with Linux
+inotify (`src/file_watch.zig`); the native macOS host watches both each file and
+its parent directory with debounced DispatchSources, then restats the exact path
+under a pane-generation guard. The file catches in-place writes while the parent
+follows rename-over saves. Text snapshots are filtered by their content hash;
+PDFs use bounded inode/size/time identity and commit it only when equal stats
+bracket a successful transactional MuPDF reopen (`file_watch.Generation`). A
+mismatched transaction gets one bounded self-retry. This catches rename-over
+saves without reading a large PDF merely to notice it changed, or spinning on a
+malformed one. A PDF response retains its reading position and pane settings.
+Web has no filesystem watcher, and nothing in the core waits for one.
+
+== The effect ring
+
+```zig
+pub fn emit(p: *Pardes, e: Effect) void {
+ if (p.effects_len == p.effects.len) return;
+ const tail = (p.effects_head +
+ p.effects_len) % p.effects.len;
+ p.effects[tail] = e;
+ p.effects_len += 1;
+}
-*Surface* (out): `cols`, `rows`, a grid of cells — grapheme, fg, bg, attrs — a
-cursor, pixel attachments, and a bounded list of plain panel-transition tracks.
-This is the *canonical interface*: it is literally what the
-tty shell hands to vaxis, cell by cell. SDL rasterizes the same grid through a
-glyph atlas; the browser reads a packed copy and patches native DOM cells. If it
-cannot be expressed in the surface, it does not
-exist in pardes. (Pixel images ride along as a list of PLACEMENTS rather than
-one per pane — a PDF pane contributes every page its viewport intersects, and
-pages can be arbitrarily short. The SDL shells blit RGBA, the tty shell falls
-back to the petscii matcher, kitty
-graphics when available. Transition tracks identify their pane by slot and
-serial and carry only phase, effect, frame, and from/to cell boxes. Canonical
-layout is committed immediately; tracks are finite presentation data.)
+pub fn nextEffect(p: *Pardes) ?Effect {
+ if (p.effects_len == 0) {
+ p.effects_head = 0;
+ return null;
+ }
+ const e = p.effects[p.effects_head];
+ p.effects_head =
+ (p.effects_head + 1) % p.effects.len;
+ p.effects_len -= 1;
+ return e;
+}
+```
+
+`emit` REFUSES when full and never evicts (`Pardes.emit`). Byte
+order is the reason: a `.write` dropped from the middle of a run would reorder a
+pty's input, and a `.write` dropped from the tail merely truncates it.
+
+Capacity is 4096 on every hosted platform and 128 on the board
+(`limits.effect_cap`). No capacity can deadlock the drain, because `pump` empties
+the ring on every iteration with an unconditional `while (nextEffect())` —
+including effects `perform` itself queues — so the only question a capacity
+answers is how large a single-pump *burst* may be. The one producer that can
+burst is `emitWrite`, which chunks arbitrary bytes into 64-byte `.write` effects
+for a pty; everything else queues O(1) effects per event, and the input queue
+holds at most 64 events per pump, so 128 leaves two effects per queued event. A
+build with no terminal panes has no pty to write to at all.
-*Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`,
-`resize_pty{pane, cols, rows}`, `open_link`,
-`save_file{pane}`, `save_text{pane, serial, path}`, `write_dump`,
-`set_clipboard`, `read_clipboard`, `lsp`,
-`pipe`, `watch`, `quit`.
-The core never performs IO for any of these; it asks — and four of the asks have an
-answer coming back: `read_clipboard` returns an ordinary `paste` event (or
-nothing at all when the shell cannot read the clipboard — most terminals refuse
-the OSC 52 read), `lsp` returns `lsp_resp`, `pipe` returns `pipe_resp`, and
-`watch` returns `file_changed` whenever the shell notices a text file or PDF
-moved under it. The tty and SDL hosts currently implement that path with Linux
-inotify; the native macOS host watches both each file and its parent directory
-with debounced DispatchSources, then restats the exact path under a pane-generation
-guard. The file catches in-place writes while the parent follows rename-over
-saves. Text snapshots are filtered by their content hash; PDFs use bounded
-inode/size/time identity and commit it only when equal stats bracket a
-successful transactional MuPDF reopen. A mismatched transaction gets one
-bounded self-retry. This catches rename-over saves without reading a large PDF
-merely to notice it changed or spinning on a malformed one. Web has no
-filesystem watcher. A PDF response retains its
-reading position and pane settings. This is what makes the browser build honest instead of a
-fork: a wasm shell simply
-answers `spawn` differently (or not at all) — the core does not know.
+== `host.VTable`: who serves the core
-Platform divergence inside the core is a comptime tag, used the way the stdlib
-uses `os.tag`:
+The seam itself is one struct of twenty-one optional function pointers
+(`host.VTable`): the `std.mem.Allocator` shape, and the generalization of
+two vtables this codebase already grew on its own: the tty shell's
+terminal-query hook, which this replaced, and `macos.Runtime`, which survives as
+the C ABI that shell's host still enters through.
```zig
-pub const Platform = enum { tty, gui, web, macos };
-// in look.zig:
-if (platform == .web and target.kind == .url)
- return .{ .open_link = target.text };
+pub const VTable = struct {
+ /// The ONLY place the process may sleep.
+ pull_wait_input: ?*const fn (
+ ctx: ?*anyopaque,
+ timeout_ms: u32,
+ ) void = null,
+ push_present: ?*const fn (
+ ctx: ?*anyopaque,
+ surface: *const pardes.Surface,
+ ) void = null,
+ // ...
+ pull_tty_taken: ?*const fn (
+ ctx: ?*anyopaque,
+ pane: u8,
+ ) bool = null,
+ // ...
+};
```
-There is one such file by design: `look.zig` holds path/`:line` resolution,
-URL detection, and the per-platform outcomes. The core's one `pointerOperand`
-primitive owns click-word expansion and is shared verbatim by right-click and
-the delayed hover preview; that policy stays beside input because it also
-observes live pane selections and wrapped grid coordinates.
+A null method is not an error: the core substitutes a default backed by ordinary
+data structures in the same process (`host.Fallback`). A `Save` lands in a real
+file under the tty host and in `Fallback.files` under a host that never wrote a
+filesystem method, and every path above that behaves identically. Two
+consequences were taken on purpose. The zero-method host IS the test harness: a
+`Host{}` is a complete, deterministic, in-process pardes with a virtual
+filesystem, a virtual clipboard and silent ptys. And `Fallback` lives on the
+`Pardes` instance rather than on the host, so N cores driven by one fan-out host
+each keep their own state and can run in parallel.
+
+The fallback filesystem is not empty. It is pardes's own source, embedded
+(`src/source_manifest.zig`), with `files` holding only what this session WROTE,
+so a Save shadows the built-in copy and reading it back returns the edit. That is
+what makes a host with no file methods a usable pardes rather than one staring
+at an empty buffer.
+
+What is deliberately NOT in the vtable: whether a capability EXISTS in this
+build. That stays comptime and stays next to the code it shapes
+(`pardes.platform`, `pardes.hosted`, `pardes.pdf_enabled`,
+`pardes.can_attach`, `pardes.terminal_panes`, `builtins.capabilities`,
+`PdfSlot`). A vtable cannot make a field zero-sized or a builtin absent from an
+enum. Comptime decides what a build HAS; the vtable decides who SERVES it at
+runtime.
+
+`pardes.can_attach` is the sharpest of those, because it exists to close a gap
+the coarser gate left open. `Attach` and `Detach` were gated on `pardes.hosted`,
+and macOS is hosted: it has a unix socket and it compiles `detached/`. What it
+does not do is POLL. `takeAttach` has to be a poll rather than a host method
+precisely because attaching replaces the core the call is running inside
+(@swap), and `src/macos.zig` never calls it — so there the word
+parsed, queued an effect, stored a request in `attach_buf`, and then did nothing
+at all, for ever, silently. `can_attach` admits exactly the tty and gui
+platforms, which is exactly the pair `main.zig` accepts `--attach` for: the same
+question asked at the command line instead of in a tag. A capability that a
+build cannot serve should not be a word that build offers.
+
+=== The naming rule is compiler-enforced
+
+Every method name says how a fan-out must route it, and `Fanout.isPull` makes a
+wrong name a compile error rather than a silent push.
+
+#wide(caption: [`host.Fanout.isPull`. The failure of a forgotten `pull_` is
+invisible in every unit test and obvious only to the user: one Ctrl-V pasting
+twice. `Fanout.all` synthesizes one wrapper per field, so adding a method to
+`VTable` needs no code in the fan-out at all.])[
+```zig
+/// How to route a method, read off its own name. A method that is neither
+/// is a COMPILE ERROR rather than a silent push, because the failure of a
+/// forgotten pull is invisible in every unit test and obvious only to the
+/// user: one Ctrl-V pasting twice.
+fn isPull(comptime name: []const u8) bool {
+ if (std.mem.startsWith(u8, name, "pull_")) return true;
+ if (std.mem.startsWith(u8, name, "push_")) {
+ if (Method(name).return_type.? != void) @compileError("Host.VTable." ++
+ name ++ " reaches every host, so it cannot return a value: whose answer would it be?");
+ return false;
+ }
+ @compileError("Host.VTable." ++ name ++ " must be named push_… (every host gets it) " ++
+ "or pull_… (exactly one host serves it, because there is one of whatever comes back)");
+}
+```
+]
+
+`push_` reaches every wrapped host and returns nothing — a push with an answer
+would have N answers and no way to pick one. `pull_` is served by exactly one
+host, because there is one of whatever comes back: one value, one sleep that
+ends, one `Event.paste` for one Ctrl-V, one `lsp_resp` per request id.
+
+The six `pull_` methods are therefore exactly the six places a single answer
+exists: `pull_wait_input` (the one place the process may sleep),
+`pull_tty_taken`, `pull_gpio_toggle`, `pull_read_clipboard`, `pull_lsp` and
+`pull_pipe`. `pull_tty_taken` is a pull and not a pushed fact because the
+`execute` that asks — has a program taken this pane's tty? — must choose a
+destination inside its own update, and effects drain after; a pushed fact would
+mean every host probing every pane's processes every frame to answer a question
+asked when a human middle-clicks a word.
+
+== The frame, as a frontend writes it
+
+```zig
+pub fn pump(p: *Pardes, h: Host) !void {
+ p.host = h;
+ const v = h.vtable;
+ if (v.pull_wait_input) |f|
+ f(h.ctx, if (p.animationActive())
+ animation.frame_ms else 0);
+ while (p.nextQueued()) |ev| p.update(ev);
+ while (p.nextEffect()) |e| p.perform(e);
+ // A quitting frame has already freed
+ // what it would draw.
+ if (p.quit) return;
+ if (v.push_poll_frame) |f| f(h.ctx);
+ _ = p.frame_arena.reset(.retain_capacity);
+ const surface = try p.render(
+ p.frame_arena.allocator());
+ if (v.push_present) |f| f(h.ctx, surface);
+ if (v.push_post_present) |f| f(h.ctx);
+ // ...
+}
+```
+
+`Pardes.pump`, eighteen lines in the source, and the order of them is the
+architecture. Input first, in whichever host owns the sleep;
+then every queued event, to completion; then every effect, to completion,
+including effects `perform` queued; then one arena reset and one render; then
+present, then post-present.
+
+Animation time is not spent in here. `pull_wait_input` was told how long it may
+sleep, and a display clock wakes faster than that on input, so only the host
+knows when a real frame interval has passed. Each spends it by handing back one
+`.tick`.
+
+`push_post_present` is split from `push_present` because it must observe a frame
+the user has actually seen: panel-presentation acknowledgement and pointer
+refresh both depend on that, and a hook that ran before the pixels landed would
+acknowledge a frame that was never shown.
+
+== Platform divergence is comptime
+
+There is one deliberately platform-divergent file by design: `look.zig` holds
+path and `:line` resolution, URL detection, and the per-platform outcomes. The
+divergence is a comptime switch on `pardes.platform`, used the way the stdlib
+switches on `os.tag`, so every platform's behaviour sits in the same screenful.
+
+```zig
+const platform_has_fs = !pardes.isolated and
+ switch (pardes.platform) {
+ .tty, .gui, .macos => true,
+ // The browser's filesystem is the embedded
+ // source archive; the P4 firmware's is
+ // whatever the serial host answers for,
+ // through the Host vtable — never a path
+ // this process opens.
+ .web, .esp32p4 => false,
+ };
+```
+
+`look.platform_has_fs`. An isolated build has no filesystem *by construction* —
+the option is comptime, so every libc path below is dead code the compiler
+removes rather than a branch that could be taken by accident.
+
+The core's one `pointerOperand` primitive owns click-word expansion and is shared
+verbatim by right-click and the delayed hover preview; that policy stays beside
+input because it also observes live pane selections and wrapped grid coordinates.
Pane implementations are similarly flat and direct: `file_pane.zig`,
`term_pane.zig`, `image_pane.zig`, `output_pane.zig`, and `pdf_pane.zig` own
-their kind-specific storage and operations. `pardes.zig` keeps the layout,
-input dispatch, cross-pane invariants, and the small calls joining those
-modules. There is no pane vtable or callback layer; the kind is already plain
-data, so a direct switch/call is the shortest boundary.
-The large end-to-end PDF cases live in `pdf_pane_integration_test.zig`, keeping
-pane-specific fixtures and raster assertions out of that core file as well.
+their kind-specific storage and operations. `pardes.zig` keeps the layout, input
+dispatch, cross-pane invariants, and the small calls joining those modules.
+There is no pane vtable or callback layer; the kind is already plain data, so a
+direct switch/call is the shortest boundary. The large end-to-end PDF cases live
+in `pdf_pane_integration_test.zig`, keeping pane-specific fixtures and raster
+assertions out of that core file as well.
-= Data structures
+= State
-The whole state is one struct, fixed-size where it can be. Panes themselves are
-heap-allocated on demand and their contents (file bytes, the yank register, PDF
-rasters, tree-sitter state) grow with what you open; everything else is sized
-at init.
+== One struct, fixed where it can be
-User-settable runtime choices are one plain `runtime_config.State`: booleans,
-theme index, owned bounded shell/font strings, requested/effective font facts,
-one panel-transition enum, and scene-effect booleans. A compile-time `settings`
-array generates each setting builtin and the rows of the single `Config`
-query. It has no callbacks and no parallel query registry to drift from it.
+The whole state is one struct. Panes themselves are heap-allocated on demand and
+their contents (file bytes, the yank register, PDF rasters, tree-sitter state)
+grow with what you open; everything else is sized at init.
+
+```zig
+Pardes
+ ncol + col_weight[6], col_terms[6][16], col_n[6]
+ panes: [16]?*Pane // slot array; id = index
+ active: usize
+ drag: Drag // none | border_v |
+ // border_h | move |
+ // tag | select
+ settings: runtime_config.State
+ rects: [16]Rect // where each pane
+ // landed, this frame
+ panel_tracks: [16]?Track // live panes,
+ // serial-guarded
+ presented_panel_tracks:[16]?Track
+ closing_panel_tracks: [16]Track // dense visual
+ // tombstones, no owner
+ presented_cells + panel_cell_diffs
+ effects: [limits.effect_cap]Effect + head/len
+ in_q: [64]Event + head/len
+ fallback: host.Fallback // per instance
+ fs: acmefs.State // inert until --fs
+
+Pane
+ tag: TagLine // live prefix (cwd/path)
+ // + editable tail
+ vt, stream: ghostty-vt Terminal and its stream —
+ on EVERY pane, not just terminals,
+ which is what lets the same keys and
+ the same parity suite drive a file
+ and a shell
+ file: ?file_pane.State
+ image: ?image_pane.State
+ pdf: PdfSlot // payload presence is the
+ // kind; none = terminal
+ vweight: f32
+ mode: enum { normal, insert, tty }
+ cursor: absolute body position // rides the
+ // scrollback, not the screen
+ msel/vsel + sels[63] + nsel // the primary
+ // range, and up to 63 more
+ ovl: ?term_pane.EditBuffer // ONE typed run,
+ // anchored to an absolute row
+ undo: two stacks, not one — term_pane.Snapshot
+ history for an edit buffer, and
+ file_pane.State history for file content
+
+file_pane.State = path + bytes + line index
+ + Syn (tree-sitter bytes)
+image_pane.State = decoded RGBA + petscii cache
+pdf_pane.State = MuPDF document + continuous
+ layout + search/selection/outline
+ + bounded per-page raster relay
+ + frame placement decisions
+```
+
+`MAX_PANES` is 16 and `MAX_COLS` is 6 (`pardes.MAX_PANES`, `pardes.MAX_COLS`). Sixteen panes
+is also why the jump stack's depth is what it is: the depth that matters is
+"more visits than you can hold in your head", not vim's hundred.
+
+== Cells
+
+```zig
+pub const CellStyle = struct {
+ fg: Color = .default,
+ bg: Color = .default,
+ bold: bool = false,
+ dim: bool = false,
+ italic: bool = false,
+ blink: bool = false,
+ reverse: bool = false,
+ invisible: bool = false,
+ strikethrough: bool = false,
+ ul: enum { off, single, double,
+ curly, dotted, dashed } = .off,
+ font_role: FontRole = .body,
+};
+
+/// One surface cell. `default = true` means
+/// "never painted this frame": the shell renders
+/// it as the terminal's default cell (vaxis clear
+/// semantics).
+pub const Cell = struct {
+ text: [7]u8 = @splat(' '),
+ len: u8 = 1,
+ style: CellStyle = .{},
+ default: bool = true,
+
+ pub fn grapheme(c: *const Cell) []const u8 {
+ return c.text[0..c.len];
+ }
+ // ...
+};
+```
+
+`pardes.CellStyle` and `pardes.Cell`. Seven bytes of `text` because a cell holds a
+complete grapheme cluster and not a codepoint: keeping the cluster is what makes
+combining marks visible and keeps ZWJ, modifier, flag and Indic sequences in the
+same screen cell that cursor and edit maths treat as one unit. `len` bounds it,
+and `default` distinguishes "a space was painted here" from "nothing was".
+
+That distinction is load-bearing twice over. `visuallyEqual` compares only what
+a shell can present — bytes past `len` are scratch left by earlier graphemes and
+must never manufacture a diff, and an unpainted default cell has no visible
+style or text either. And `printableAscii` returns `' '` for a default cell but
+`null` for a painted multi-byte one, which is what admits a cell to the ASCII
+transition walk (@diff).
+
+== Columns and panes are arithmetic
+
+Layout is arithmetic, not objects: columns are weights over the width, panes are
+weights over the column.
+
+#figure(
+ placement: auto,
+ caption: [The layout model. Each pane is a narrow gutter strip (move box and
+ scrollbar) plus a tag row plus a body. `col_weight` is 64-bit fixed point with
+ 32 fractional bits, so every possible column split divides an initial weight
+ exactly; `vweight` is an `f32` over its own column. `col_terms[c][k]` is the
+ pane in slot position `k` of column `c`, and `rects[id]` is where that pane
+ landed this frame.],
+ diagram[
+ #cetz.canvas(length: 1cm, {
+ import cetz.draw: *
+ set-style(stroke: 0.35pt, mark: (fill: black, scale: 0.3))
+ let w = 7.6
+ let h = 4.5
+
+ rect((0, 0), (w, h))
+ line((0, h - 0.28), (w, h - 0.28))
+ content((w / 2, h - 0.14), text(4.9pt, style: "italic")[topbar, execute-only])
+
+ line((2.8, 0), (2.8, h - 0.28))
+ line((5.2, 0), (5.2, h - 0.28))
+ line((2.8, 1.8), (5.2, 1.8))
+
+ let pane(x0, y0, x1, y1, kind) = {
+ line((x0 + 0.26, y0), (x0 + 0.26, y1 - 0.26))
+ line((x0, y1 - 0.26), (x1, y1 - 0.26))
+ content(((x0 + x1) / 2, y1 - 0.13), text(4.9pt, style: "italic")[tag: #kind])
+ }
+ pane(0, 0, 2.8, h - 0.28, [file])
+ pane(2.8, 1.8, 5.2, h - 0.28, [terminal])
+ pane(2.8, 0, 5.2, 1.8, [output])
+ pane(5.2, 0, w, h - 0.28, [PDF])
+ content((1.45, 2.3), text(4.9pt, style: "italic")[body])
+ content((6.4, 2.0), text(4.9pt, style: "italic")[body])
+
+ line((0.04, -0.3), (2.76, -0.3), mark: (start: "stealth", end: "stealth"))
+ content((1.4, -0.58), text(5.4pt)[`col_weight[0]`])
+ line((2.84, -0.3), (5.16, -0.3), mark: (start: "stealth", end: "stealth"))
+ content((4.0, -0.58), text(5.4pt)[`col_weight[1]`])
+ line((5.24, -0.3), (w - 0.04, -0.3), mark: (start: "stealth", end: "stealth"))
+ content((6.4, -0.58), text(5.4pt)[`col_weight[2]`])
+
+ line((5.0, 1.86), (5.0, h - 0.34), stroke: (dash: "dotted"),
+ mark: (start: "stealth", end: "stealth"))
+ content((4.2, 3.0), text(5.2pt)[`vweight`])
+ line((5.0, 0.06), (5.0, 1.74), stroke: (dash: "dotted"),
+ mark: (start: "stealth", end: "stealth"))
+ content((4.2, 0.9), text(5.2pt)[`vweight`])
+ })
+ ],
+)
Horizontal layout weights are fixed-point integers and geometry rounds
-cumulative boundaries. Splitting a column replaces only its weight `W` by
-`A+B=W` at the same position. Therefore every boundary outside the source
+cumulative boundaries. Splitting a column replaces only its weight $W$ by
+$A + B = W$ at the same position. Therefore every boundary outside the source
column is bit-identical before and after the split, including at awkward
non-dyadic screen widths; only the source and new column can receive movement
tracks. Vertical splits apply the corresponding rule to the source pane's
weight.
-= Presentation animation
+`splitBelow` shrinks only the source pane; `absorbVWeight` gives a dying pane's
+weight to one sibling; an emptied column hands its width to a neighbour.
+Minimal motion is the invariant: an operation on one pane may not move panes it
+does not touch.
+
+== Runtime settings are one table
+
+User-settable runtime choices are one plain `runtime_config.State`: booleans,
+theme index, owned bounded shell/font strings, requested/effective font facts,
+one panel-transition enum, and scene-effect booleans
+(`runtime_config.State`). A compile-time `settings` array
+(`runtime_config.settings`) generates each setting builtin and the rows of the
+single `Config` query. It has no callbacks and no parallel query registry to
+drift from it.
+
+Requested and effective are separate fields on purpose. Resolving a shell name
+to an executable, or a font name to a face, belongs to the native host; the core
+retains what the host actually chose and marks a changed request `pending` until
+the next spawn or the next atlas acknowledges it. A rejected request therefore
+stays queryable without claiming it is on screen.
+
+= Modes and selections
+
+== Three modes and a boolean
+
+`Pane.mode` is `enum { normal, insert, tty }` (`pardes.Mode`).
+
+*normal* is the helix motion model. *insert* is click-and-type: terminals get
+splice runs (shift right, never overwrite, anchored to an absolute row), files
+get real edits. *tty* is raw pty forwarding — a Ctrl-key toggle, default Ctrl-b,
+`--tty-toggle` — where the mouse is still usable, entry does a
+`promptClickMove`, and prompts become visible again (they are hidden in the
+other two modes via OSC 133).
+
+Select is not a fourth mode. `v` sets `Pane.select`, a bool on top of normal,
+which makes motions extend from a fixed anchor instead of replacing the range;
+`pane.mode` stays `.normal`, and insert/tty transitions drop it
+(`Pane.select`). It is *displayed* as a fourth mode name, "select",
+because that is what the user is in — but nothing in the dispatch branches on a
+fourth mode.
+
+Since the helix motion model landed, a traversal motion SELECTS the range it
+crossed. That is why there is no verb+noun grammar and why `i` after `w` types
+at the selection's start.
-`panel_animation.zig` is backend-neutral data and math: five transitions,
-their easing, exact endpoint progress, stable per-cell noise, and a POD track.
-The core detects opening/moving rectangles when it commits layout and publishes
-only active tracks. It retains the last successfully presented canonical grid
-and a typed old/new cell diff; unused grapheme bytes do not manufacture a
-change. A separate dense closing-track list is presentation-only state for a
-pane whose functional lifetime has already ended. Pointer input inverts the
-presented slide/zoom/vertical rectangle back to the canonical grid, lets
-unchanged dissolve/ASCII cells through immediately, and rejects closing
+== One primary range and up to 63 more
+
+`MAX_SELS` is 64 (`pardes.MAX_SELS`). helix's `Selection` is a list of
+ranges plus a primary index; pardes keeps the PRIMARY exactly where it has
+always been — `cur_row`/`cur_col` plus `vsel` — and the other ranges in
+`sels: [MAX_SELS - 1]SelRange`, document-ordered and disjoint
+(`Pane.sels`, `Pane.nsel`).
+
+That split is the whole design. Every motion, operator, renderer and mouse path
+still reads one selection, so with `nsel == 0` not a byte of behaviour moves, and
+the differential and snapshot suites keep proving it. The extra ranges are driven
+by replaying the single-selection key handler once per range (`replaySels`).
+
+`s`/`S` — select and split by regex — snapshot the selection they were armed on
+in `sel_snap` and re-derive the preview FROM that snapshot on every keystroke,
+rather than from the previous preview. This is what helix's `regex_prompt` does,
+it is what makes typing a pattern one character at a time land on the same answer
+as pasting it whole, and it is what makes Esc a plain restore with nothing else
+to undo. The snapshot also records whether the range was a *user-intent*
+selection, so restoring cannot silently promote motion residue into something
+the acme chords will act on (`Pane.sel_snap`).
+
+== Three buttons, three meanings
+
+The mouse selections are `[3]Sel`, one per button, block-shaped, in text-area
+coordinates where `r` counts from the tag row (`pardes.Sel`). Each
+has three states, `none`, `dragging` and `done`, which is what lets a kept left
+selection stay highlighted after the drag and still be distinguishable from one
+in progress.
+
+Left selects and pins the cursor. Middle executes: with no drag it expands to a
+file-ish word, then runs a builtin or sends the text to the shell, and it does
+not focus. Right looks. A theme owns the SELECTION — `sel_bg`/`sel_fg` are one
+pair per theme, and the three per-button tints and the dimmed extra cursors are
+mixed off it, so what stays fixed is the distinction between buttons and not the
+colours.
+
+`Drag` is a `union(enum)` with six arms (`pardes.Drag`): `none`,
+`border_v`, `border_h`, `move`, `tag`, `select`. `border_v` carries an optional
+`corner`: when the press lands on a cell that is both a column's vertical border
+and one of the two adjoining columns' own horizontal borders, the one drag moves
+*both* boundaries — never three, and when both columns happen to be split at the
+grabbed row the left one wins, so the gesture that existed before is bit-for-bit
+unchanged.
+
+== The tag is a command line
+
+A tag is one line: a live prefix (mode indicator, cwd or path) plus an editable
+tail, and the tail gets the full modal editor. The coordinate space is UTF-8 byte
+offsets into the *whole* rendered tag, prefix ++ tail, always on grapheme
+boundaries (`Pane.tag_col`). The prefix is live chrome, so it is
+selectable, yankable and executable but READ-ONLY: every edit op measures from
+`edit0` — the first editable byte — and does nothing left of it.
+
+`tag_tail` is a fixed `[max_tag_tail]u8`, and the bound IS the storage
+(`limits.max_tag_tail`): every writer — `appendTag`, `tagInsert`,
+`restoreDumpTail`, the acmefs `tag` file — refuses input that does not fit
+rather than truncating it. The schema limit and the buffer therefore can never
+disagree, which is what lets a dump reader reject data before copying it into a
+pane.
+
+A tag edit is always insert mode, so it hijacks the body's mode; `tag_mode`
+remembers what it hijacked and terminals restore it on exit, which is why
+clicking a tag never changes a pane's mode.
+
+= Diffing and presenting a frame
+
+== Eleven transitions
+
+`panel_animation.zig` is backend-neutral data and math: the transition
+vocabulary, easing, exact endpoint progress, stable per-cell noise, and a POD
+track. `Transition` has twelve members counting `off`
+(`panel_animation.Transition`), with explicit numeric values because they
+cross both GUI shader ABIs — GLSL receives the enum in an instance `uvec4` and
+the Core Image kernel receives it as a float, so spelling the numbers keeps a
+source reorder from changing pixels.
+
+#table(
+ columns: (auto, auto, auto, 1fr),
+ align: (left, left, left, left),
+ table.header([*id*], [*name*], [*frames*], [*composed by*]),
+ [1], [`slide`], [12], [backend shader / tty grid],
+ [2], [`zoom`], [14], [backend shader / tty grid],
+ [3], [`dissolve`], [10], [backend shader / tty grid],
+ [4], [`ascii`], [13], [core],
+ [5], [`vertical`], [12], [backend, lifecycle only],
+ [6], [`edges`], [12], [core],
+ [7], [`fall`], [14], [core],
+ [8], [`wave`], [14], [core],
+ [9], [`curtain`], [12], [core],
+ [10], [`scramble`], [12], [core],
+ [11], [`typewriter`], [14], [core],
+)
+
+The split in the last column is the design. `composedByCore` is true for every
+*character* effect: the core writes the finished glyphs into the published
+`Surface`, so no backend owns a byte walk, a stagger, or a noise threshold, and
+every renderer presents the same byte at a given frame. The geometry effects
+(`slide`, `zoom`, `vertical`) and `dissolve` are the ones a backend evaluates,
+because they are transforms over rectangles rather than choices about characters.
+
+Easing follows from that too. A sweep and a typewriter are constant-rate by
+definition, so `curtain` and `typewriter` are `linear`: easing their head would
+make the pass visibly hesitate mid-pane. Character walks and per-cell locks read
+best with a slow start, a fast middle and a slow settle, so `ascii`, `fall` and
+`scramble` are `smoother`.
+
+The core detects opening and moving rectangles when it commits layout and
+publishes only active tracks. A separate dense closing-track list is
+presentation-only state for a pane whose functional lifetime has already ended,
+which is why it needs no pane owner and no serial guard. Pointer input inverts
+the presented slide/zoom/vertical rectangle back to the canonical grid, lets
+unchanged dissolve and ASCII cells through immediately, and rejects closing
pixels, so pixels and gestures cannot disagree during a transition.
-Pardes core first composes each ASCII-qualified diff by incrementing or
-decrementing its printable byte. Short walks move once per frame; longer walks
-use integer ease-out skips and finish within twelve movements. Style-only and
-non-ASCII changes pass through, and every backend receives the same
-presentation cells.
-TTY copies that `Surface` grid into a compositor scratch grid, clears
-slide/zoom destinations, then paints moving, opening, and closing panels in
-order.
-Cleared geometry uses the theme page color when it is explicit and the host
+== The semantic cell diff <diff>
+
+```zig
+pub const PanelCellDiff = union(enum) {
+ unchanged,
+ visual,
+ ascii: AsciiDiff,
+
+ pub fn between(
+ old: *const Cell,
+ new: *const Cell,
+ ) PanelCellDiff {
+ if (old.visuallyEqual(new)) return .unchanged;
+ if (AsciiDiff.between(old, new)) |diff|
+ return .{ .ascii = diff };
+ return .visual;
+ }
+
+ // ...
+};
+```
+
+`pardes.PanelCellDiff`; the elided member is `changed`, which is
+`diff != .unchanged` and is what `Surface.panelCellChanged` calls. The core
+retains the last successfully presented canonical grid and this typed old/new
+classification, and it is the *semantics* that matter: unused grapheme bytes do
+not manufacture a change, and a style-only or multi-byte change is `.visual` and
+passes straight through.
+
+An `.ascii` diff is a `{ from: u8, to: u8 }` pair, and the core composes it by
+incrementing or decrementing the printable byte. Short walks move one value per
+frame; a longer walk is crossed by eased character skips and finishes within
+`ascii_max_movement_frames`, which is 12
+(`panel_animation.ascii_max_movement_frames`). Frame
+zero is the exact old byte and the endpoint is exact, so an intermediate frame is
+always valid UTF-8. `Track.frame_count` carries the core-computed duration for
+these data-dependent effects — zero selects the effect preset, and the ASCII
+composer fills it from the longest eased byte walk in the pane's diff.
+
+== What each backend does with it
+
+TTY copies that `Surface` grid into a compositor scratch grid, clears slide and
+zoom destinations, then paints moving, opening, and closing panels in order.
+Cleared geometry uses the theme page colour when it is explicit and the host
terminal default only for transparent themes, so a light theme cannot flash a
-dark gap. Slide/zoom change the copied rectangle. Dissolve changes only diff
-cells from their old value to their new value; vertical raises only an opening
-or frozen closing pane
-inside its own clip. SDL supplies old/new glyph data, diff flags,
-final/presented boxes, and effect parameters to the glyph and native-image
-shaders. Pixel attachments bypass ASCII because they have no character byte.
-macOS passes the same records across its plain C ABI and composites
-old/new panel images in Metal/Core Image. Scene `Crt`, `Ripple`, and
-`Glitch` bits share one full-window pass in each native GUI. DOM web is a
-separate platform, not a shader GUI: retaining selectable HTML/CSS is more
-important than duplicating the renderer in canvas, so it exposes neither
-effect family.
+dark gap. Slide and zoom change the copied rectangle; dissolve changes only diff
+cells from their old value to their new value; vertical raises only an opening or
+frozen closing pane inside its own clip.
+
+Every effect's last active sample is its exact canonical endpoint, so the
+compositor returns the source surface unchanged when no track is
+non-canonical — keeping the real cursor and attachments in that sample rather
+than suppressing them for one frame that is otherwise pixel-identical
+(`panel_compositor.compose`). Kitty placements cannot be resampled
+through the character-grid transform, so a moving pane's attachment is omitted
+while its geometry moves and placed again on the last active sample.
+
+SDL supplies old/new glyph data, diff flags, final/presented boxes, and effect
+parameters to the glyph and native-image shaders. Pixel attachments bypass ASCII
+because they have no character byte. macOS passes the same records across its
+plain C ABI and composites old/new panel images in Metal and Core Image. Scene
+`Crt`, `Ripple` and `Glitch` bits share one full-window pass in each native GUI.
+
+DOM web is a separate platform, not a shader GUI: retaining selectable HTML and
+CSS is more important than duplicating the renderer in canvas, so it exposes
+neither effect family.
`EffectCode <effect>` writes the actual backend math, host submission, and
-shader/grid source segments embedded by the build into an ordinary output
-pane. This makes the implementation inspectable
-after installation and makes sharing explicit: several builtins can quite
-honestly print the same shader with different uniform bits.
+shader or grid source segments embedded by the build into an ordinary output
+pane. This makes the implementation inspectable after installation and makes
+sharing explicit: several builtins can quite honestly print the same shader with
+different uniform bits.
+
+== The ASCII fast paths
+
+Three loops in this codebase run once per character over text that is almost
+always ASCII, and each asks two or three general functions for what arithmetic
+already knows. The guard that makes elision *correct* rather than merely fast is
+the same in all three, and it is a statement about Unicode: an ASCII base joins a
+following combining mark, ZWJ or spacing mark into ONE cluster, and every scalar
+that can do that is non-ASCII. So a printable ASCII byte followed by another
+ASCII byte, or by nothing, is a complete grapheme cluster one column wide.
```zig
-Pardes
- ncol + col_weight[6], col_terms[6][16], col_n[6] // columns as flat arrays
- panes: [16]?*Pane // slot array; id = index
- active: usize
- drag: Drag // none | select | move | border_v | border_h | tag
- settings: runtime_config.State // theme, shell/font, display/effect choices
- rects: [16]Rect // where each pane landed, this frame
- panel_tracks: [16]?Track // live panes, serial-guarded
- closing_panel_tracks: [16]Track // dense visual tombstones, no pane owner
- presented_cells + panel_cell_diffs // acknowledged baseline + semantic diff
- effects: [4096]Effect + head/len // a fixed ring
+// ASCII FAST PATH. Printable ASCII is one byte,
+// one cell, one column, and the general path
+// below reaches that answer through a UTF-8
+// length, a decode, a freshly constructed
+// grapheme iterator, a slice validation and a
+// width lookup - per character.
+{
+ const b = text[i];
+ if (b >= 0x20 and b < 0x7f and
+ (i + 1 == text.len or text[i + 1] < 0x80))
+ {
+ s.set(col, y, text[i .. i + 1], style);
+ i += 1;
+ col += 1;
+ continue;
+ }
+}
+```
-Pane
- tag: TagLine // live prefix (cwd/path) + editable tail
- vt, stream: ghostty-vt Terminal and its stream — on EVERY pane, not just
- terminals, which is what lets the same keys and the
- same parity suite drive a file and a shell
- file: ?file_pane.State / image: ?image_pane.State / pdf: ?pdf_pane.State
- // payload presence is the kind; none = terminal
- vweight: f32
- mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty
- // `v` adds a fourth thing to DISPLAY, "select",
- // which is a bool on top of normal, not a mode
- cursor: absolute body position // rides the scrollback, not the screen
- sel: [3]Sel + msel/vsel + sels[63] // per-button block sels, the line and
- // char modal ones, and up to 64 cursors
- ovl: ?term_pane.EditBuffer // ONE typed run, anchored to an absolute row
- undo: two stacks, not one — term_pane.Snapshot history for an edit buffer,
- // and file_pane.State history for file content
+`Surface.print`. Its own comment records the reason
+it exists: this function was 26% of a keystroke when profiled in the ESP32-P4's
+configuration — 40×12, no tree-sitter — which was the largest single item there.
+`\t`, `\r`, the C0 controls and DEL are excluded by the range test and keep their
+existing handling.
+
+The second is `file_pane.fitEnd` (`file_pane.fitEnd`), which decides
+where a soft-wrapped row breaks. It asks `modal.nextGrapheme` and
+`graphemeDisplayWidth` once per character, and a 640-column line asks 640 times.
-file_pane.State = path + bytes + line index + Syn (tree-sitter highlight bytes)
-image_pane.State = decoded RGBA + petscii grid cache
-pdf_pane.State = MuPDF document + continuous layout + search/selection/outline
- + bounded per-page raster relay + frame placement decisions
+The third is `modal.nextGrapheme` itself
+(`modal.nextGrapheme`), and it is where the argument above needs its one
+correction. GB3 is the single UAX #29 rule that joins two ASCII scalars: a CR
+takes a following LF into the same cluster. The two range-tested loops never see
+it, because `0x20..0x7e` excludes CR — but `nextGrapheme`'s fast path admits
+every byte below `0x80`, so it excludes CRLF by name. It did not always, and
+`graphemeStart` did: the two then disagreed about a CRLF file by exactly one
+byte, a head stepped onto the offset between CR and LF, and `graphemeStart`
+repaired it back onto the CR. Everything else ASCII is still O(1).
+
+Because `fitEnd` decides where text lands on screen, a fast path that is off by
+one column *moves text*. Both of its tests therefore pin it to the general walk
+it replaces rather than to a transcribed expectation: one sweeps every byte
+below 0x80 against a set of neighbours at every width and start offset
+(*the ASCII run in fitEnd survives an exhaustive byte sweep*), and the other runs a hand-written case list —
+including `"abc\u{00e9}def"` for a hand-over mid-run, a wide glyph a width
+boundary can land inside, `"a\u{0301}bc"` for a cluster the fast path must not
+split, and `"e\u{0301}x"` for an ASCII byte followed by a continuation byte —
+through both routes (*the ASCII run in fitEnd cuts where the grapheme walk
+would*). `Surface.print` has the same
+arrangement: its reference implementation is `print` with the fast-path block
+deleted and nothing else changed (the test *the ASCII fast path in surface
+print paints what the general arm paints*).
+
+= Detached sessions <detached>
+
+#wide(caption: [A detached session. The daemon owns the core and every
+machine-local resource; a frontend owns a screen, a keyboard and a clipboard.
+Counts and tag numbers from `wire.ClientTag` and `wire.ServerTag`; the routing rules
+from `src/detached/server.zig:34-59`.])[
+ #diagram[
+ #cetz.canvas(length: 1cm, {
+ import cetz.draw: *
+ set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35))
+
+ // ---- the daemon ----
+ rect((0, 2.0), (6.4, 5.5), name: "d")
+ content((3.2, 5.24), text(7.4pt)[*the daemon* --- `pardes --detach[=name]`])
+ line((0, 5.02), (6.4, 5.02))
+ content((3.2, 4.74), text(6.0pt)[one `Pardes`; `update` is called here])
+ content((3.2, 4.38), text(6.0pt)[every pty master (`host_io.forkShell`)])
+ content((3.2, 4.02), text(6.0pt)[every file (`host_io.writeFileBytes`)])
+ content((3.2, 3.66), text(6.0pt)[the inotify fd (`file_watch.zig`)])
+ content((3.2, 3.30), text(6.0pt)[the theme dir and the acme mount])
+ content((3.2, 2.94), text(6.0pt)[16 of 21 `VTable` methods implemented])
+ content((3.2, 2.58), text(6.0pt)[ONE `poll(2)` --- the only sleep])
+ content((3.2, 2.26), text(5.6pt, style: "italic")[pane shells outlive every frontend])
+
+ // ---- the socket, drawn in segments so the labels sit in the gaps ----
+ line((9.3, 2.0), (9.3, 3.02), stroke: (dash: "dashed"))
+ line((9.3, 3.46), (9.3, 4.72), stroke: (dash: "dashed"))
+ line((9.3, 5.16), (9.3, 5.6), stroke: (dash: "dashed"))
+ content((9.3, 5.78), text(6.2pt)[`AF_UNIX`])
+
+ // ---- the two directions ----
+ line((12.2, 4.7), (6.4, 4.7), mark: (end: "stealth"))
+ content((9.3, 4.94), text(6.2pt)[`ClientTag` --- 11 tags])
+ line((6.4, 3.0), (12.2, 3.0), mark: (end: "stealth"))
+ content((9.3, 3.24), text(6.2pt)[`ServerTag` --- 8 tags])
+ content((9.3, 2.48), text(5.6pt, style: "italic")[a frame per pump, per client;])
+ content((9.3, 2.18), text(5.6pt, style: "italic")[never queued, only skipped])
+
+ // ---- frontends ----
+ rect((12.2, 4.35), (18.0, 5.5))
+ content((15.1, 5.24), text(6.8pt)[*frontend* --- a terminal])
+ content((15.1, 4.86), text(5.8pt)[`--attach`; vaxis paints `grid`/`cursor`])
+ content((15.1, 4.52), text(5.8pt)[screen + keyboard + clipboard + a link])
+
+ rect((12.2, 3.1), (18.0, 4.15))
+ content((15.1, 3.90), text(6.8pt)[*frontend* --- an SDL window])
+ content((15.1, 3.52), text(5.8pt)[same protocol, same session, same screen])
+ content((15.1, 3.22), text(5.8pt)[`screen -x`, not N sessions])
+
+ rect((12.2, 1.85), (18.0, 2.9))
+ content((15.1, 2.68), text(6.8pt)[#sym.dots.v up to `max_clients` = 32])
+ content((15.1, 2.32), text(5.8pt)[the 33rd gets a `refuse .full`, not a backlog])
+ content((15.1, 2.02), text(5.6pt, style: "italic")[NO FORK HAPPENS IN A FRONTEND])
+
+ // ---- the message tables, full width ----
+ rect((0, -2.8), (18.0, 1.5))
+ content((9.0, 1.24), text(7.0pt)[*every message, and which way it crosses*])
+ line((0, 1.02), (18.0, 1.02))
+ let row(y, body) = content((0.3, y), body, anchor: "west")
+ row(0.72, text(5.6pt)[frontend #sym.arrow.r core (11): `hello` `bye` `key` `mouse` `resize` `paste` `command` `pdf_scroll` `pinch` `touch_scroll` `pointer_leave`])
+ row(0.32, text(5.6pt)[core #sym.arrow.r frontend (8): `welcome` `refuse` `frame` `quit` `detach` | `set_clipboard` `read_clipboard` `open_link`])
+ row(-0.16, text(5.6pt)[BROADCAST --- every screen must show the same thing: `frame`, `set_clipboard`])
+ row(-0.54, text(5.6pt)[ORIGIN ELSE PRIMARY --- the answer belongs to the human who acted: `read_clipboard`, `open_link`, `detach`])
+ row(-0.92, text(5.6pt)[`read_clipboard`'s answer is not a reply message: it comes back as an ordinary `Event.paste`])
+ row(-1.40, text(5.6pt)[the gaps `0x13`..`0x17`, `0x1e` were `output` `eof` `lsp_resp` `pipe_resp` `file_changed` `tick`: deleted, not renumbered,])
+ row(-1.78, text(5.6pt)[when the daemon took the disk --- a decodable `output` let an attached peer forge a pane's text])
+ row(-2.16, text(5.6pt)[never on the wire: `pull_wait_input` (it IS the poll loop), the two informationless frame pushes, the four `pull_`s])
+ row(-2.56, text(5.6pt)[that answer their own caller, and `push_fs_reply` --- with N frontends, N#sym.minus 1 would get an answer they never asked for])
+ })
+ ]
+]
+
+== The daemon owns the machine
+
+The `Pardes` instance lives in the detached process. A frontend owns a terminal
+(or a window, or a serial panel) and a socket, and nothing else: it sends the
+input it collects and draws the frames it is sent. One core per session, N
+frontends attached to it, all looking at the same screen — `screen -x`, not N
+sessions.
+
+The argument for putting every machine-local effect in the daemon is one
+sentence: a unix socket means the core and its frontends are on the SAME machine,
+so there is no question of whose disk or whose process table is meant, and given
+that, the process that must hold them is the long-lived one. A shell forked by a
+frontend dies with that frontend, and a session whose whole promise is outliving
+the frontend attached to it cannot keep its panes that way. So the daemon forks
+the pane shells, writes the files, marks the directories, and drains the pty
+masters in its own `poll(2)`. The pane shells outlive every frontend: attach,
+detach, kill the terminal, attach from another one, and the build that was
+running in pane 3 is still running and has been scrolling into the core the whole
+time.
+
+Of the twenty-one `VTable` methods, the detached core's `Session` implements
+sixteen and leaves five null: `push_post_present`, `pull_gpio_toggle`,
+`pull_lsp`, `pull_pipe`, `push_fs_reply`.
+
+Nothing blocks indefinitely, and that property is what a detached session is
+*for*. Every descriptor is non-blocking; the single `poll(2)` is the only place
+the process sleeps; and every queue that could grow without bound has a ceiling
+with a stated answer for reaching it. Frames in particular are NOT queued: a
+client with bytes still owed to the kernel is skipped for this frame and its
+mirror is left alone, so the next frame it does get is a diff against what it
+actually has. A slow frontend therefore sees fewer, larger frames instead of a
+growing queue, and coalescing costs no byte surgery. What is left in a client's
+out-queue is control messages, capped by `out_backlog` and checked *before* an
+append, so a single oversized message still goes out whole and what gets refused
+is a client that has stopped draining: it is closed, and its peers are untouched.
+
+== The wire
+
+```zig
+pub const ClientTag = enum(u8) {
+ hello = 0x01,
+ bye = 0x02,
+
+ key = 0x10,
+ mouse = 0x11,
+ resize = 0x12,
+ paste = 0x18,
+ command = 0x19,
+ pdf_scroll = 0x1a,
+ pinch = 0x1b,
+ touch_scroll = 0x1c,
+ pointer_leave = 0x1d,
+};
+
+pub const ServerTag = enum(u8) {
+ welcome = 0x01,
+ refuse = 0x02,
+ frame = 0x03,
+ quit = 0x04,
+ detach = 0x05,
+
+ set_clipboard = 0x10,
+ read_clipboard = 0x11,
+ open_link = 0x12,
+};
```
-Layout is arithmetic, not objects: columns are weights over the width, panes are
-weights over the column. `splitBelow` shrinks only the source pane;
-`absorbVWeight` gives a dying pane's weight to one sibling; an emptied column
-hands its width to a neighbor. Minimal motion is the invariant: an operation on
-one pane may not move panes it does not touch.
+`wire.ClientTag` and `wire.ServerTag`. Eleven tags in, eight out, and the shape of both
+lists is the argument.
-= Style
+Every `ClientTag` is something a human did: a handshake, a goodbye, and what a
+keyboard, a mouse, a trackpad or a window manager produces. Six numbers are
+missing from the input run — `0x13`..`0x17` and `0x1e` — and the gaps are left
+rather than tidied away, because renumbering is a change every deployed frontend
+feels. They were `output`, `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and
+`tick`: the machine-local host's own reports, which stopped being a frontend's
+business when the daemon took the disk and the process table. Leaving them
+decodable was not merely dead weight. `server.zig`'s `apply` routes any decoded
+non-resize event straight into `core.update`, so an attached peer could forge a
+pane's output, forge an `eof` for a shell that was still running — and unlike the
+daemon's own `paneEof` the wire path never called `closePty`, so the master
+stayed open and the shell was orphaned for the life of the session — or replace a
+pane's text with bytes the next `Save` would write to disk.
-TigerStyle, plus house rules proven in the prototype: imperative and flat; one
-big `update` dispatch, not handler objects; no one-line helpers — inline the
-four-line scan; assert invariants at entry (`assert(vsum > 0)`); static
-allocation at init, arenas per frame.
+On the way out there are only three effects left, and which three is the whole
+design: what a process nobody is looking at genuinely cannot do is put something
+on THIS human's clipboard, read it back, and open a link in front of the person
+who clicked it. The eight machine-local pushes — `spawn`, `pty_write`,
+`pty_resize`, `write_file`, `write_dump`, `watch_file`, `watch_theme`,
+`dump_themes` — were routed to ONE frontend precisely because each has one real
+resource behind it, and every one of them is now performed in the daemon. Two
+frontends can no longer fork two shells for pane 3 or race each other writing one
+path, because neither of them writes anything.
-The rewrite used source size as a pressure toward direct code, and that remains
-useful when a refactor deletes duplicate policy or state. A checked-in line-count
-inventory does not: it goes stale whenever a pane kind, backend, or generated
-asset moves. Measure the current tree when making that comparison; keep this
-document about ownership and invariants that should survive the next edit.
+`detach` sits in the session range beside `quit` rather than among the three
+effects, because it is not an effect the session performs on the world: it is one
+frontend being told it is done.
-Two things that number is not. It is not one program's worth of growth — the
-macOS and web shells and the PDF and language work are four products sharing a
-core. And it is not licence: the rule that survives is the local one, that a
-change should leave the file it touches no longer than it found it.
+Four `VTable` methods are named in the file with their reasons for *not* being on
+the wire. `pull_wait_input` IS the server's poll loop. `push_poll_frame` and
+`push_post_present` carry no information — `frame` already arrives exactly once
+per pump at the same place in the order, so two more messages per frame per
+client would say nothing the frame does not. `pull_tty_taken`,
+`pull_gpio_toggle`, `pull_lsp` and `pull_pipe` are answers the caller waits for
+or work dispatched off the loop, and a round trip inside `update` is the one
+thing this transport must never do. `push_fs_reply` cannot be a broadcast at all:
+the transport that asked is the one holding the request, so with N frontends,
+N−1 would receive the answer to a request they never made — which is why the
+acme mount stays in the detached process and `Event.fs_req` has no `ClientTag`.
+
+=== The codec is architecture- and build-neutral
+
+The frontend on the other end may be riscv32-freestanding while the core is
+`x86_64` linux, so: every integer is an explicit width, little
+endian, and no `usize` reaches the wire; no native struct is ever blitted, because
+`@bitCast` of a Zig struct puts this compiler's field order and padding on a
+socket; every union and every enum gets a tag chosen in this file and never
+`@intFromEnum` of a core type, with exhaustive mapping switches, so adding a
+variant to `Event` is a compile error here rather than a silent protocol
+redefinition; every variable-length payload carries an explicit length prefix and
+`max_payload` bounds the lot; a bool is one byte, 0 or 1, and any other value is a
+decode error rather than "nonzero is true"; floats travel as their IEEE-754
+binary32 bit pattern inside an explicit `u32`.
+
+The codec is build-neutral for the same reason: `Event.resize.cell_pixels` exists only when
+native PDF placement is compiled in, and a frontend must not have to have been
+built with the core's options, so it is ALWAYS on the wire and dropped on arrival
+by a build with nowhere to put it.
+
+`version` is a `u16` checked on connect and refused loudly, because two builds of
+pardes are routinely on one machine — `zig build` replaces the binary under a
+running session — and a frontend decoding another version's frame layout would
+paint garbage and blame the terminal. `ClientTag` is exhaustive on purpose, which
+is the opposite of `fuse.zig`'s `Opcode`: there a newer *kernel* adds opcodes,
+and a non-exhaustive enum is the only way to receive one without undefined
+behaviour, whereas here both ends are pardes and an unknown tag is a corrupt or
+hostile stream.
+
+`max_payload` is 16 MiB, derived rather than chosen: a full frame of the largest
+grid this protocol admits (512×128) at a worst case of one run per cell is
+512×128×(6+20) = 1.6 MiB, and one paste is already capped at 4 MiB by the tty
+frontend (`wire.max_payload`).
+
+== Connect first, swap second <swap>
+
+```zig
+if (core.takeAttach()) |req| {
+ var attempt = detached_client.attempt(
+ gpa, req.name, core.screen_w, core.screen_h);
+ switch (attempt) {
+ .greeted => |client| {
+ attached.* = client;
+ break :frames;
+ },
+ else => {
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(
+ req.pane,
+ attemptEnd(&attempt, req.name)
+ .row(&mbuf),
+ );
+ },
+ }
+}
+```
+
+`tty.localSession`, and the `takeAttach` block in `src/gui/gui.zig` is the same
+shape.
+CONNECTING IS NOT BEING ATTACHED, which is why this asks for a *greeted* client
+and not for a socket: `Client.open` writes a hello and returns, and every way a
+session says no — `refuse .version` for a session built from other bytes,
+`.full`, `.quitting`, or a plain `quit` from one that ended in the same round —
+arrives *after* a successful `connect(2)`. A swap that trusted the connect would
+already have SIGKILLed every pane shell, unmounted the filesystem and freed every
+undo history by the time it decoded the refusal.
+
+So `attached` is set only with the welcome in hand, and until it is, nothing has
+been touched: a failed `Attach` costs one message row and leaves every pane,
+every shell and every undo history where it was. The teardown that follows is
+the function's own defers, reached by leaving its scope.
+
+The ordering is enforced in three places at once. The builtin only asks
+(`builtins.Attach`). `Effect.attach` is unpacked into `attach_req` and
+`attach_buf` by `perform`, and what the frontend acts on is what `takeAttach`
+hands back AFTER the drain, not the effect value, which dies in the loop that
+read it (`drainForAttach`). And `takeAttach` is consumed from the shell's
+OUTER loop, beside `takeRestore` and for the same reason: both END this core, and
+nothing running inside `pump` may destroy the core it is running in
+(`Pardes.attach_req`). The unit test *Attach asks for a session and tears
+nothing down* asserts
+exactly that — after `Attach` and a full drain, `p.quit` is false and `p.panes[0]`
+is still there.
+
+Because a failed connect is cheap, `Attach` is the one word in the session group
+that is safe to press by accident, and it is the one that gets a leader chord:
+`SPC s a`. `Detach` takes `SPC s D`, a capital because `sd` has been `Dump`'s
+since before there was anything to detach from. Both entries sit behind
+`if (pardes.can_attach)` in `src/config.zig`, and that gate is not decoration:
+the leader table may only name a builtin that EXISTS, so on macOS — hosted, but
+no poller — the two words are compiled out and naming them would be a compile
+error. Which is the good outcome, and the reason the predicate exists.
+
+`Detach` is not `Attach` backwards, and the asymmetry is deliberate. Turning a
+live local session into a daemon needs `setsid` and a fork; a word that pretended
+to would hand you a session that dies with the window it was typed in. Run
+locally, `Detach` therefore reports rather than acts.
+
+== `host_io.zig`: the machine-local half
+
+`forkShell`, `writeFileBytes` and `writeFd`, and it is now the only copy of them:
+`tty.zig`, `detached/server.zig`, `gui/gui.zig` and `macos.zig` all fork and
+write through it (`src/host_io.zig`, module header).
+
+They did not always, and what the four copies had in common is the better
+argument for the file existing than "it is shared" is: ALL FOUR were missing
+`FD_CLOEXEC` on the pty master. `/dev/ptmx` is opened by `forkpty` with no
+`O_CLOEXEC` and there is no flag argument to ask for one, so in every shell
+pardes has ever shipped, a program in one pane could read and write another
+pane's terminal. The silent half is worse and compounds: closing a master is the
+only thing that hangs its shell up, and a master a later shell still holds open is
+not closed, so a pane delete or a respawn left an orphaned shell that never
+exited — never reaped, eventually blocked writing into a pty nobody reads — and
+each orphan pinned every earlier pane's master in turn. The startup drain forks
+pane 0 and then pane 1, so the arrangement existed from boot.
+
+`nested.setCloexec(master)` after the fork fixes it for all four callers at once,
+and the file states the window that leaves rather than papering over it: `fcntl`
+after `fork` is not atomic, so a thread that forks and execs between the two
+syscalls inherits the master anyway. In the detached daemon there is no such
+thread — it is single-threaded by construction, which is what putting the pty
+masters in its own `poll(2)` bought. Closing the window in the threaded shells
+means replacing `forkpty` with `posix_openpt(O_CLOEXEC)` / `grantpt` /
+`unlockpt` / fork / `setsid`.
+
+`forkShell` takes the core it is forking on behalf of and nothing about
+terminals: no vaxis, no `Loop`, no reader thread. Who drains the master is the
+caller's business, and the callers answer differently on purpose — the tty, gui
+and macOS shells hand it to a worker that posts into their event loop; the daemon
+adds it to the one `poll(2)` it already runs and makes its own copy non-blocking
+in order to.
+
+= The board
+
+== An object, not a module
+
+`-Dplatform=esp32p4` emits ONE freestanding riscv32 object exporting the C ABI in
+`src/esp32p4.zig`; the `zig_p4` package links it beside its own `_start`, its
+generated linker script, and its UART driver (the `pardes-esp32p4` object
+`build.zig` emits). Not an
+executable, because the entry point is over there. Not a library, because
+`addLibrary` bundles a `compiler_rt` the firmware already has.
+
+And not a module exposed through `build.zig.zon`, which is what it was first and
+is the interesting part. A dependency in the OTHER direction was built and
+reverted: nesting this package's roughly 30-package graph under `zig-p4`'s broke
+every build in that repo, not just the firmware one. `std/Build.zig:2091`
+exceeded its
+1000-branch comptime quota through ghostty's `SharedDeps.zig:874` `lazyImport`,
+seven cached tree-sitter versions failed to compile because their `build.zig`
+uses APIs removed in 0.16, and the fetch materialised 2.6 GB across 42,736 files
+into a repo whose entire claim is that Zig is its only dependency. This direction
+is free: `zig_p4` declares no dependencies at all, so it enlarges nothing here,
+and its `build()` early-returns when it is not the root.
+
+The seam stays bytes over eight C functions, because bytes are the right seam for
+a serial line and because that is the arrangement the board was measured
+through. `-Desp32p4-firmware` adds the other half in this tree — the firmware
+executable rooted at `src/esp32p4/app.zig`, the flashable image, and the steps
+that write it to a board and talk to it — and the image it produces is
+byte-identical to the one the toolchain repo produces from the same sources. It
+is opt-in and not out of timidity: `esp32p4.firmware()` reads ESP-IDF's register
+headers at CONFIGURE time and exits non-zero when there is no checkout, so a bare
+`-Dplatform=esp32p4` must not call it.
+
+The object is also the compile probe. Rooted at `src/esp32p4.zig` it drags the
+whole core through the riscv32 backend by actually calling it, so `llvm-size` on
+the result is a real number to hold against the board's 1.5 MiB factory
+partition.
+
+Where the terminal is: on the host. The board writes ANSI and reads ANSI, and the
+emulator at the far end of the serial line does the font rendering and answers
+this program's own capability queries. That is why vaxis works here unmodified —
+`Vaxis.render`, `queryTerminalSend` and `enableDetectedFeatures` all take a bare
+`*std.Io.Writer`, so the transport is a parameter, while `vaxis.Tty` and
+`vaxis.Loop` are termios/ioctl/SIGWINCH bound and are not used. Window size
+arrives as DEC mode 2048 in-band resize reports, parsed by `vaxis.Parser` like
+any other input, because firmware has no `TIOCGWINSZ`.
+
+== The memory budget is one table
+
+`src/limits.zig` is every board-shaped capacity in one place. These numbers used
+to be nine `platform == .esp32p4` tests scattered across nine files, each one a
+separate place to forget — and they are not nine decisions. They are ONE
+decision, how much memory this build is allowed to spend, taken nine times where
+no reader could see the total.
+
+```zig
+/// `board` is the ESP32-P4 firmware's budget:
+/// a 384 KiB heap and a 240 KiB chunk of L2MEM
+/// shared between `.bss`, `.data` and the stack.
+const board = config.platform == .esp32p4;
+/// No OS means no address space to reserve
+/// megabytes out of, whatever the platform is
+/// called.
+const reduced_target =
+ builtin.os.tag == .freestanding;
+
+pub const board_heap_bytes = 384 * KiB;
+pub const effect_cap = if (board) 128 else 4096;
+pub const wrap_rows = if (board) 128 else 256;
+pub const cwd_buf_cap = if (board) 0 else 1024;
+pub const undo_max = if (board) 16 else 256;
+pub const max_tag_tail: usize =
+ if (board) 512 else 4096;
+pub const host_path_cap: usize =
+ if (board) 0 else 4095;
+pub const embedded_sources = !board;
+pub const hexdump_row_bytes: u32 =
+ if (board) 8 else 16;
+// ... `arena` below; see the wide listing
+```
+
+Two booleans derive all of it, and nothing outside this file tests the platform
+for a capacity again. Every cap says what it is measured against, and
+`board_heap_bytes` is the number the others are measured against: the 384 KiB
+chunk of L2MEM at `0x4FF40000`. It is unconditional and not profile-derived,
+because it is a fact about the silicon rather than a budget this build chose — a
+desktop build that wants to know what the board affords is asking exactly that
+question, which is what the memory tests in `pardes.zig` do with it.
+
+Each cap is a different *kind* of shrink, which is why they are not one scale
+factor.
+
+- `cwd_buf_cap` and `host_path_cap` go to *zero*, and zero is a type: `Text(0)`
+ is a zero-sized field whose `set` refuses every non-empty path, so the three
+ producers — the resolved shell, the picked font file, the watched theme file —
+ report failure instead of storing 12 KiB nothing can fill. This is the
+ `PdfSlot` rule applied to a capacity: the board has no filesystem, no processes
+ to spawn a shell for and no font picker.
+- `undo_max` and `wrap_rows` shrink *gracefully*. `pushHistory` evicts and frees
+ the oldest once full, so the smaller ring loses the deepest undo steps and
+ nothing else. `wrapWidth` reads the array's own length and refuses to wrap a
+ pane taller than it, so a taller pane renders unwrapped rather than getting a
+ truncated map.
+- `effect_cap` changes *behaviour*, and the file says so: `emit` has always
+ refused rather than evicted once full, so on the board a burst larger than 128
+ effects now drops its tail where 4096 would have held it. That is reachable
+ only through `emitWrite`, i.e. only if a pty ever appears on this platform.
+- `embedded_sources` is a capacity spelled as rodata. The allowlist is empty on
+ the board, because the table is about 0.95 MiB against a 1.5 MiB partition.
+ The API is unchanged — `all` is a zero-length array and `find` answers null — so
+ every caller compiles identically and simply finds nothing embedded.
+- `hexdump_row_bytes` is the one that is about the *display* rather than memory.
+ `hexdump -C`'s sixteen needs 79 columns; the P4 drives 56 of which seven go to
+ the line-number gutter, so a sixteen-byte row wraps onto a second display line
+ and the columns stop lining up, which is the entire value of the layout. Eight
+ fits in 46 and keeps every property that matters.
+
+#wide(caption: [`limits.arena`. Three tiers, because the address space
+differs by four orders of magnitude. The board's tier is deliberately ALL
+FALLBACK: every buffer here is a `StackFallbackAllocator`'s static, which lands
+in `.bss`, and on the P4 `.bss`, `.data` and the stack share one 240 KiB chunk of
+L2MEM while the heap is a separate 384 KiB chunk. A megabyte-shaped reservation
+would not fit, and every byte that did fit would be taken from the stack's
+neighbourhood to duplicate memory the heap already has. Zero is legal and always
+spills, which is exactly what an arena for a compiled-out subsystem should do.])[
+```zig
+pub const arena = struct {
+ pub const pardes = if (board) 4 * KiB else if (reduced_target) 8 * MiB else 32 * MiB;
+ pub const frame = if (board) 4 * KiB else if (reduced_target) 4 * MiB else 16 * MiB;
+ // ...
+ pub const tree_sitter = if (board) 0 else if (reduced_target) 4 * MiB else 16 * MiB;
+ pub const image = if (board) 0 else if (reduced_target) 64 * KiB else 32 * MiB;
+ pub const pdf = if (board) 0 else if (reduced_target or !config.mupdf) 64 * KiB else 64 * MiB;
+};
+```
+]
+
+What does NOT belong in this table is capability switches. `terminal_panes`,
+`board_memory.enabled`, `hosted` and `font_picker` answer "does this build have
+the thing at all", which is a question about the platform and not about a budget,
+so they stay next to the thing they gate.
+
+That division is also why a build option selecting the board's budget on a
+desktop cannot work, and the file records the attempt. `-Dmem-profile=board` was
+meant to let a native test runner compile the board's capacities and boot the
+core under them. The dominant term in a boot is `@sizeOf(Pane)`, which carries
+the ghostty-vt `Terminal` — 1.1 MiB of it — and what removes that is
+`pardes.terminal_panes`, a CAPABILITY keyed on the platform rather than a
+capacity in this table. So the option shrank the rings and left the boot six
+times over budget, producing a configuration nothing was designed for:
+`zig build unit-test -Dmem-profile=board` deadlocked in a futex rather than
+failing, because a hosted build with the board's effect ring silently drops
+effects a hosted test is waiting on. What DOES test the board's memory pressure
+natively is in `pardes.zig`: the grid-scaled cost and the allocation-failure
+sweep, both platform-independent and both running on the ordinary build.
+
+== What the board does not have
+
+`terminal_panes` is false there and nowhere else (`pardes.terminal_panes`), and it
+is a platform gate and deliberately not one derived from the target: `web` is
+freestanding too and KEEPS the emulator, because the browser shell renders a
+replayed dump. False means ghostty-vt is not in the module graph at all —
+`build.zig` never even asks for the dependency — which removes about 400 KiB of
+flash, a `PageList` of RAM spent parsing input that cannot arrive, and a pile of
+freestanding root hooks (`os.PATH_MAX`, `os.heap.page_allocator`, a cwd handle)
+that the core itself does not want.
+
+Tree-sitter is refused outright: `-Dplatform=esp32p4` with anything but
+`-Dtree-sitter=disabled` fails the build, because the grammars' parse tables are
+megabytes against a 1.5 MiB partition (`build.zig:298`). MuPDF is refused for the
+same reason (`build.zig:297`).
+
+The board gains four words nothing else has, all gated on
+`board_memory.enabled == (platform == .esp32p4)`: `Peek`, `Poke`, `Hexdump` and
+`Gpio`. Each takes an address or a pin, so none can have a leader path — a key
+path names a builtin and can never carry an operand. `Gpio` is the one that goes
+through the host seam (@seam) rather than reaching the registers directly, and
+`pull_gpio_toggle`'s comment says why: driving a pad correctly is not one
+register. It is the IO MUX function select, the GPIO matrix output route, the
+pad's drive and input-buffer bits, and the output enable, keyed by a per-pin
+table. The firmware already owns that code and checks it against ESP-IDF's own
+headers on the die; a second copy in the core would be a second copy nobody
+tests.
+
+`board_memory.zig` makes the target the *witness* rather than the gate: `enabled`
+is keyed on the platform, and a `comptime` block then refuses to compile if that
+platform is hosted, is not freestanding, or is wasm — because whatever else
+`esp32p4` means, it has to still be a machine whose addresses are the bus's
+(`board_memory.enabled`).
+
+= The control filesystem <fs>
+
+== acmefs as a pure transaction
+
+plan9's acme serves `/mnt/acme`: a directory per window holding `addr`, `body`,
+`ctl`, `data`, `event`, `tag`, and a program that opens those files IS an editor
+extension — no plugin API, no embedded interpreter, no rebuild. `pardes --fs`
+serves the same tree over Linux FUSE (`src/fuse.zig`), and `src/acmefs.zig` is
+the whole of what the files MEAN.
+
+A filesystem is a request/response protocol driven by other processes, which is
+exactly the kind of concurrency the core does not have and must not grow. acme
+answers it with a thread per in-flight request — `xfidallocthread`, a `Channel`
+per `Xfid`, a `QLock` per window. pardes cannot and should not, so `acmefs.zig`
+is a pure main-thread transaction, `handle(p, req) Reply`: no thread, no waiting,
+no callback, no allocation on the hot path, freestanding-safe, and unit-testable
+with no FUSE anywhere near it.
+
+Requests arrive as an ordinary `Event.fs_req` and answers leave as an ordinary
+`Effect.fs_reply`, so the transport is the queue every other host/core message
+already uses. A backend with no threads at all is not a special case: it either
+never sends a request, or sends one from its own frame loop. And acme's blocking
+`event` read — which waits for the user to do something, parking the `Xfid` in
+`w->eventx` until a later `winevent` sends it a message — is `Status.again` here:
+"nothing consumed, ask me again". The waiting lives in the host, which is where
+the kernel's request already is. The core keeps no waiter list and no wakeups.
+
+One divergence from acme is deliberate: acme counts RUNES, pardes counts BYTES,
+clamped to grapheme boundaries. Every offset in this filesystem — `addr`, `data`,
+the event records' q0/q1, `index`'s lengths — is a byte offset, because pardes is
+byte-addressed end to end (selections, look spots, LSP offsets) and a second
+coordinate system would mean an O(n) conversion at every boundary and a lossy
+`addr=dot`. acme pays that cost the other way round: it keeps the document as
+`Rune*` and converts on every utf read, in a function that carries a
+"BUG: stupid code: scan from beginning" comment for its cache miss. The two agree
+for ASCII, which is what scripts compute with.
+
+`Pardes.fs` is `acmefs.State`, zero-initialised and inert: a core nobody scripts
+pays one branch per edit and nothing else.
+
+== The nested socket
+
+`src/nested.zig` listens on `<dir>/pardes-<pid>.sock`, where `<dir>` is
+`$XDG_RUNTIME_DIR` — a per-user 0700 tmpfs the login session already cleans up —
+or `~/.local/state/pardes` when the session has none. It accepts exactly one
+verb, `Look <path>[:<line>]`, and nothing else, which is the security property.
+That is how a `pardes <file>` run inside a pardes hands the file to the outer
+session instead of stacking a second full-screen UI inside one of its panes.
+
+It arrives as an `Event.command` rather than a direct `executeBuiltinLine` call
+so that it gets the trailing sync and the ordinary effect drain: `Look` on a
+directory emits a `.spawn` the shell has to perform.
+
+The detached sockets live in the same per-user directory under a different name,
+`pardes-detached-<name>.sock`, and both sides derive the path from one predicate
+in one file so that the side which binds and the side which connects cannot
+disagree (`server.socketPath` and `server.sessionPath`). A frontend that finds a socket at
+a derivable path which is not a private one of ours refuses with `NotPrivate`
+rather than reporting "no session": a planted socket collects every keystroke
+typed into the frontend that trusts it, and the two cases need different answers
+from a human.
+
+= Build
+
+#wide(caption: [The build graph. One `root_mod` per invocation, rooted at
+whichever file that platform's host enters through; up to three more compilations
+of the same graph beside it; one `pardes_config` per distinct *frontend*, which
+is the only thing two of them are allowed to disagree about. Line numbers are
+`build.zig`.])[
+ #diagram[
+ #cetz.canvas(length: 1cm, {
+ import cetz.draw: *
+ set-style(stroke: 0.4pt, mark: (fill: black, scale: 0.35))
+ let row(y, body) = content((0.3, y), body, anchor: "west")
+
+ // ---- tier 1: inputs, full width ----
+ rect((0, 6.76), (18.0, 9.5))
+ content((9.0, 9.24), text(7.0pt)[*inputs, generated or read at configure time*])
+ line((0, 9.02), (18.0, 9.02))
+ row(8.72, text(5.8pt)[`highlights.scm` from 29 grammars #sym.arrow.r one options module])
+ row(8.34, text(5.8pt)[`vendor/themes/*.toml` (214 helix) and `*.json` (11 zed) #sym.arrow.r 225 generated theme modules plus a `list.zig`, straight into the build cache])
+ row(7.96, text(5.8pt)[working-tree `.zig` sources #sym.arrow.r the web shell's read-only source archive])
+ row(7.58, text(5.8pt)[8 GLSL shaders #sym.arrow.r SPIR-V through `glslc` --- or `-Dprebuilt-shaders` embeds the committed `.spv` + `.glsl` pair, so `EffectCode` cannot lie])
+ row(7.24, text(5.8pt)[`@import("build.zig.zon")`, UNTYPED (`:8`) #sym.arrow.r `zon.version` (`:1920`) and a `comptime` check that `zls_version` matches the pinned sha (`:36-46`)])
+ row(6.90, text(5.8pt)[`gitCommit(b)` (`:1855-1867`) #sym.arrow.r `?[]const u8`, null when there is no repository, no `git`, or nothing to say])
+
+ // ---- tier 2: modules and the options module ----
+ rect((0, 3.8), (8.7, 6.6))
+ content((4.35, 6.34), text(7.0pt)[*modules that compile* `src/pardes.zig`])
+ line((0, 6.12), (8.7, 6.12))
+ content((4.35, 5.84), text(5.8pt)[`root_mod` (`:306`) --- its root is the file this])
+ content((4.35, 5.50), text(5.8pt)[platform's host enters through:])
+ content((4.35, 5.14), text(5.6pt)[`main.zig` (tty, gui) | `web.zig` | `macos.zig` | `esp32p4.zig`])
+ content((4.35, 4.74), text(5.8pt)[`hx_core_mod` --- a headless second core for `hxdiff`])
+ content((4.35, 4.38), text(5.8pt)[`isolated_mod` --- one comptime bool: no filesystem])
+ content((4.35, 4.02), text(5.8pt)[`gui_mod` --- the same `main.zig` at `platform = .gui`])
+
+ rect((9.3, 3.8), (18.0, 6.6))
+ content((13.65, 6.34), text(7.0pt)[`pardes_config` --- ONE PER DISTINCT FRONTEND])
+ line((9.3, 6.12), (18.0, 6.12))
+ content((13.65, 5.84), text(5.8pt)[`platform`, `mupdf`, tree-sitter tier, `zls_version`,])
+ content((13.65, 5.50), text(5.8pt)[`esp32p4_cols`/`rows`, `theme_animation`, `zig_lib_dir`,])
+ content((13.65, 5.14), text(5.8pt)[`version` (from the manifest), `commit` (from `git`)])
+ content((13.65, 4.70), text(5.8pt)[`ShellConfig` (`:1874-1890`) holds every field that is a])
+ content((13.65, 4.34), text(5.8pt)[property of the BUILD, so the two modules a default build])
+ content((13.65, 4.00), text(5.8pt)[cannot drift apart in any field but `platform`])
+
+ line((4.35, 6.76), (4.35, 6.6), mark: (end: "stealth"))
+ line((13.65, 6.76), (13.65, 6.6), mark: (end: "stealth"))
+ line((9.3, 5.2), (8.7, 5.2), mark: (end: "stealth"))
+
+ // ---- tier 3: outputs, full width ----
+ rect((0, 0), (18.0, 3.3))
+ content((9.0, 3.04), text(7.0pt)[*what one invocation emits*])
+ line((0, 2.82), (18.0, 2.82))
+ let out(y, name, what) = {
+ content((0.3, y), text(6.0pt)[#name], anchor: "west")
+ content((4.9, y), text(5.8pt)[#what], anchor: "west")
+ }
+ out(2.52, [`pardes`], [`-Dplatform=tty`, the default --- vaxis over a real terminal])
+ out(2.12, [`pardes-gui`], [`-Dplatform=gui` --- SDL3, a FreeType atlas, SPIR-V])
+ out(1.72, [`pardes.wasm`], [`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>`, plus a vanilla DOM shell])
+ out(1.32, [`libpardes.a`], [`-Dplatform=macos`, then the `macos-app` step #sym.arrow.r `pardes.app` (Darwin host)])
+ out(0.92, [`pardes-esp32p4.o`], [`-Dplatform=esp32p4` (target forced, `:171`); `-Desp32p4-firmware` adds the image and the board steps])
+ row(0.46, text(5.6pt)[a bare `zig build` emits the FIRST TWO together, because those two are what installing pardes means; every other spelling is one invocation each])
+ row(0.18, text(5.6pt)[beside them, from the same graph: `pardes-isolate` (tty only --- the filesystem is not compiled in) and `hxdiff`'s headless core])
+
+ line((4.35, 3.8), (4.35, 3.3), mark: (end: "stealth"))
+ })
+ ]
+]
+
+== One primary shell, sometimes two
+
+`-Dplatform` names ONE shell. Absent, the build makes BOTH native shells — the
+tty cli and the SDL gui — because those two together are what installing pardes
+means, and asking for them one at a time is two invocations a person has to
+remember are two (`also_gui` in `build.zig`). Everything else derives from `platform`,
+the PRIMARY shell: the one rooted at `root_mod`, the one `unit-test` runs, and
+the one the snapshot and harness suites drive. `also_gui` adds the second beside
+it and changes nothing about the first.
+
+A bare `zig build` with an untouched prefix also redirects the install prefix, on
+two conditions and the second is not the obvious one: no shell was NAMED
+(`-Dplatform=web` keeps writing `zig-out/web`, which its docs name), and nothing
+else has already said where to install — no `DESTDIR`, no `--prefix`, no
+`--prefix-*dir`. It cannot depend on which *step* was asked for, because
+`build.zig` cannot know that: `build_runner` keeps the step names in a local and
+resolves them after `build()` returns. That is why the dev binaries install to
+`<prefix>/dev` rather than `<prefix>/bin` — `zig build perf` redirects the prefix
+too, and a 200 MB Debug benchmark must not land on a PATH.
+
+The other three platforms are separate invocations:
+`-Dplatform=gui` for `pardes-gui`;
+`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` for `pardes.wasm`
+plus a vanilla DOM shell; `-Dplatform=macos` plus the `macos-app` step for
+`pardes.app` wrapped around a static `libpardes.a` on a Darwin host; and
+`-Dplatform=esp32p4` for the freestanding object. Any other spelling of the
+combination fails on purpose rather than building something silently wrong — the
+web target, the web dump, MuPDF on a freestanding platform, and tree-sitter on
+the board each have a named refusal (`build.zig:292-298`). The esp32p4 target is
+in fact *forced* to `esp32p4_target` rather than taken from `-Dtarget`, so a
+`-Dtarget` cannot discard its CPU features, and a wrong one is still an error
+rather than silently overridden.
+
+Because `platform` is comptime and `pardes.platform` is read from
+`pardes_config`, two frontends in one build need two options modules — the gui
+shell `@cImport`s an SDL the cli must not link. `ShellConfig` gathers everything
+`pardes_config` carries that is a property of the BUILD rather than of one
+frontend into one value, so the two modules a default build makes cannot drift
+apart in any field but the one that is supposed to differ
+(`ShellConfig`). `hxdiff`'s headless core and `pardes-isolate` share the
+primary shell's, being the same frontend.
+
+`pardes-isolate` is a third compilation of the same graph whose only difference
+is one comptime bool. It cannot be the same binary with a flag, because the point
+is that the isolated build does not CONTAIN the filesystem: `look.zig`'s libc
+paths compile away, so no runtime mistake can reach a disk that a `--flag` build
+still links. Only the tty platform has one — gui adds a GPU it would still need,
+and web and macOS are libraries whose host owns `main()`.
+
+== Generated inputs
+
+Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (a
+pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default everywhere but the
+web and the board, lazy), ZLS, mvzr (the regex engine behind `s`/`S`),
+zig-tree-sitter with 29 grammars for 28 languages (markdown takes two, block and
+inline), and `zig_p4` — all pinned through `zig fetch` and wired in `build.zig`.
+
+Generated during the build: `highlights.scm` into an options module; the vendored
+helix and zed theme sources into the generated half of the theme ring;
+working-tree `.zig` sources into the web shell's read-only archive; and eight
+GLSL shaders into SPIR-V.
+
+The themes go straight into the build cache and are handed over as a module,
+rather than written back into `src/`. The output then has no freshness problem to
+own — zig re-runs the generator only when a vendored file changes, and a cached
+run leaves the compile's inputs byte-identical, so `zig build` with nothing
+touched still does nothing — there is nothing to gitignore, and no stale `.zig`
+can survive a deleted source. The INPUT list is read from the directory rather
+than written out, so adding a theme is dropping a file in, and each file goes in
+as a content-hashed argument, which is what makes that new file re-run the step
+and nothing else. The generator is compiled in Debug on purpose: it runs for
+about 40 ms, and every core module imports what it generates, so its compile is the
+first link of every cold build; ReleaseSafe cost 16 s of compile to save 47 ms of
+run time, and the files it writes are byte-identical either way
+(`theme_gen` in `build.zig`).
+
+The shaders are the only build input wanting a tool a stock machine lacks
+(`glslc`), so they are also the only one whose output is committed:
+`zig build shaders` refreshes each paired `shaders/prebuilt/*.spv` binary and
+`.glsl` source snapshot together. `-Dprebuilt-shaders` embeds that exact pair
+rather than shelling out, so `EffectCode` cannot describe different shader text
+from the binary on screen; this is also what lets a gui build need nothing but a
+C toolchain.
+
+The tutor and the embedded font are plain `@embedFile`s, not codegen. Debug builds
+are incremental for the seconds-loop; release builds are the product.
+
+== Versioning
+
+There is one version string in the tree and it is in the manifest.
+
+```zig
+// build.zig:8
+const zon = @import("build.zig.zon");
+// ...in shellOptions, build.zig:1915-1923:
+o.addOption([]const u8, "version", zon.version);
+o.addOption(?[]const u8, "commit", cfg.commit);
+```
+
+The `@import` is UNTYPED on purpose, and that is what makes it possible:
+annotating its type would demand an exact field match and reject
+`.dependencies`, `.paths` and the rest, which is the failure the hand-synced
+literal that used to sit there was working around.
+
+```zig
+// src/pardes.zig
+pub const version = @import("pardes_config").version;
+pub const commit: ?[]const u8 =
+ @import("pardes_config").commit;
+
+// src/main.zig
+const version_text = if (pardes.commit) |c|
+ "pardes " ++ pardes.version ++ " (" ++ c ++ ")\n"
+else
+ "pardes " ++ pardes.version ++ "\n";
+```
+
+Both halves are comptime, so `version_text` is one string in rodata and
+`pardes --version` is one write (`main.version_text`).
+
+`gitCommit(b)` runs
+`git -C <build root> rev-parse --short=12 HEAD` at CONFIGURE
+time, so the binary carries a string rather than the ability to shell out. That
+is the whole point: a `--version` that runs `git` itself reports the tree it
+happens to be standing in rather than the one it was built from, and on the board
+there is no `git` to run and no process to run it with. The `-C` is not
+decoration either — `zig build` may be run from anywhere, and a `git` resolved
+against the cwd would cheerfully answer about a different repository.
+
+Absence is not an error and must not be. A release tarball has no `.git`, a
+container may have no `git` binary, and a source drop is not a repository; every
+way of having no answer lands on the same `null` and the frontends print the
+version alone. It is deliberately NOT `--dirty`: marking a dirty tree would cost
+a worktree stat on every configure and — the real cost — would change
+`pardes_config` on every file edit. Every module in the build imports that
+options module, so a dirty marker means editing one line rebuilds the world. The
+commit alone changes only when a commit does.
+
+The manifest is read at comptime twice, and the second read is what keeps the
+one string this build cannot fold into the manifest honest. `zls_version`
+(`zls_version`) has to be spelled beside `.dependencies.zls.url`, because the
+semver half of it — `0.16.1-dev` — exists nowhere in the manifest, and ZLS's own
+`build.zig` needs it: that file otherwise shells out to `git describe`, which
+fails on a fetched package with no `.git`. So the duplication is unavoidable and
+a `comptime` block beside it makes the two AGREE instead: it takes the
+short commit after the `+`, takes the sha after the `#` in
+`zon.dependencies.zls.url`, and `@compileError`s unless the pinned sha starts
+with it. A `.zon` bump that forgets the line beside it is now a build error
+rather than a `SPC l i` naming an analyser nobody linked.
= Testing: the old program is the oracle
-Before the rewrite compiled, the prototype got a harness (`snapshot.zig`, the
-`zig build snap` step): it forks either binary in a pty, feeds it an *event
-script* (one line per input: keys, SGR mouse, resizes, sync points), and
-captures the rendered grid — text, cursor, and per-cell style runs — through
-its own ghostty terminal. Goldens are generated from the old binary
-(`zig build snap -- --update`); the new binary must reproduce them byte for
-byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts covered the
-checklist in Appendix A at the rewrite; ninety-five cover it and everything
-since. All pass. Determinism pins: fixed workdir paths (they
-appear in tags), a controlled `$HOME` with `PS1='$ '`, `LC_ALL=C`, and a
-grid-stability sync primitive instead of timing guesses.
+Before the rewrite compiled, the prototype got a harness (`test/snapshot.zig`,
+the `zig build snap` step): it forks either binary in a pty, feeds it an *event
+script* (one line per input: keys, SGR mouse, resizes, sync points), and captures
+the rendered grid — text, cursor, and per-cell style runs — through its own
+ghostty terminal. Goldens are generated from the old binary
+(`zig build snap -- --update`); the new binary must reproduce them byte for byte.
+Eighteen scripts covered the checklist in Appendix A at the rewrite; ninety-five
+cover it and everything since (`test/snapshots/` holds 95 `.snap`/`.golden`
+pairs). All pass.
-Two of those pins were doing damage rather than work. A capture used to restate
-the whole screen, so 82% of golden lines were a copy of the line above, every
-golden carried the topbar and a tag row, and adding one builtin word to a
-tagline rewrote 78 of them — 3,758 lines of diff for a change no test was
-about. A capture is now a *delta* against the previous capture of the same kind
-in the same script: the first is the whole screen, the rest are only the rows
-that changed. The corpus went from 16,700 lines to 5,722 and the same one-word
-edit now moves 337. A capture whose only change is the cursor is the empty
-delta its script always meant.
+Determinism pins: fixed workdir paths (they appear in tags), a controlled `$HOME`
+with `PS1='$ '`, `LC_ALL=C`, and a grid-stability sync primitive instead of
+timing guesses.
-And a click used to name a screen column, which is a coordinate into that same
+Two of those pins were doing damage rather than work.
+
+== Deltas, not screens
+
+A capture used to restate the whole screen, so 82% of golden lines were a copy of
+the line above (`test/snapshot.zig:59`), every golden carried the topbar and a tag
+row, and adding one builtin word to a tagline rewrote 78 of them — 3,758 lines of
+diff for a change no test was about. A capture is now a *delta* against the
+previous capture of the same kind in the same script: the first is the whole
+screen, the rest are only the rows that changed. The corpus went from 16,700 lines
+to 5,722 and the same one-word edit now moves 337 (`src/CHANGELOG.md:9-13`). A
+capture whose only change is the cursor is the empty delta its script always
+meant.
+
+== A click names a word
+
+A click used to name a screen column, which is a coordinate into that same
chrome. When `Newtty`, `Joincol` and `Changelog` were added, seven scripts began
-clicking the word next door — `tutor.snap` clicked `Grep`, `exec.snap` clicked
-`Newtty` where it meant `Del`, `tagbottomimage.snap` clicked blank space 176
-columns from the `Del` whose effect it asserted — and `--update` blessed the
-result, leaving 256 golden lines green while asserting the opposite of their own
-first line. A click may now name the word (`press middle @Del 2`, `@Del#2` for
-the second pane on a row, `@Save-2` for a column beside one), so the word is
-either there to be clicked or the script fails.
+clicking the word next door. `snapshot.zig`'s own note enumerates six of them:
+`tutor.snap` clicked `Grep` where it meant `Tutor`, `exec.snap`, `respawn.snap`
+and `tagalign.snap` clicked `Newtty` where they meant `Del`, `find.snap` clicked
+`Joincol` for `Find`, and `windowops.snap` clicked `Tutor` for `Debug` — and
+`--update` blessed all of it, so 256 golden lines were green while asserting the
+opposite of their script's first line (`test/snapshot.zig:817-825`). The seventh
+is the changelog's: `tagbottomimage.snap` clicked blank space 176 columns from
+the `Del` whose effect it asserted (`src/CHANGELOG.md:9-11`). A click may now
+name the word (`press middle @Del 2`, `@Del#2` for the second pane on a row,
+`@Save-2` for a column beside one), so the word is either there to be clicked or
+the script fails.
+
+== Regeneration verifies itself
Regeneration was the other half of that failure: `--update` captured once,
serially, and wrote whatever it saw, which is how six wrong clicks became
goldens. It now captures in parallel at the widest probe settings the harness
has and then runs the ordinary verify pass over what it wrote, so a capture that
-does not reproduce is reported instead of committed (77s to 41s, and the retry
-machinery that serial update never had).
+does not reproduce is reported instead of committed — 77 s to 41 s, and the retry
+machinery serial update never had (`src/CHANGELOG.md:14-16`).
+
+== Deviations kept after review
+
+Three deliberate deviations surfaced by the oracle and kept.
+
+The greeting `ls` waits for the exact OSC 133 B input mark after the real resize;
+the prototype raced bash's startup and won only by allocator luck. Shells without
+prompt integration omit that cosmetic greeting rather than guessing. Commands
+which create a fresh shell are owned by its terminal pane until the host reports
+the actual prompt capability, then wait for the same mark when it exists.
+
+Dump files compare with base64 pty history elided: it encodes prompt-redraw
+micro-timing, not state, and the cleaned text fields are the contract.
+
+Typed insert runs do not survive a dump replay. They are an overlay, not pty
+bytes, and the prototype's replay viewer had the same semantics.
-Three deliberate deviations surfaced by the oracle, kept after review: the
-greeting `ls` waits for the exact OSC 133 B input mark after the real resize
-(the prototype raced bash's startup and won only by allocator luck); shells
-without prompt integration omit that cosmetic greeting rather than guessing.
-Commands which create a fresh shell are owned by its terminal pane until the
-host reports the actual prompt capability, then wait for the same mark when it
-exists. Dump files compare with base64 pty
-history elided (it encodes prompt-redraw micro-timing, not state — the cleaned
-text fields are the contract); and typed insert runs don't survive a dump
-replay (they are an overlay, not pty bytes — the prototype's replay viewer had
-the same semantics).
+== The shell suites
Shell correctness is no longer out of scope, and that is the biggest change to
-this section since the rewrite. `web-snap` / `web-e2e` drive headless Chromium
-over CDP with real DOM pointer and touch events and diff
-`test/web-snapshots/`; `image-harness` and `pdf-harness` snapshot native PIXEL
-output through kitty graphics and SDL; `macos-e2e` is an offscreen AppKit
-snapshot suite over its own seven scripts. What remains untested by a snapshot
-is the last hop — that vaxis diffs correctly onto a real terminal — plus each
-shell's inline unit tests (the FreeType atlas raster, the trackpad and rotation
-maths, the gamepad replay).
+this section since the rewrite. `web-snap` and `web-e2e` drive headless Chromium
+over CDP with real DOM pointer and touch events and diff `test/web-snapshots/`;
+`image-harness` and `pdf-harness` snapshot native PIXEL output through kitty
+graphics and SDL; `macos-e2e` is an offscreen AppKit snapshot suite over its own
+seven scripts (`test/macos-snapshots/`: boot, cwd, drop, font, keys, rotate,
+trackpad). `esp32p4-test` runs an on-die suite on real hardware, and
+`esp32p4-image-size` and `esp32p4-image-check` answer the flash-budget question
+with no board attached.
-= Build
+Two suites are differential rather than golden: `hxdiff` compares the core's
+motion and operator results against helix case by case, and `hxparity` compares
+editing a file against editing the same text in a pty — which is what the
+per-pane ghostty-vt `Terminal` buys.
+
+What remains untested by a snapshot is the last hop — that vaxis diffs correctly
+onto a real terminal — plus each shell's inline unit tests (the FreeType atlas
+raster, the trackpad and rotation maths, the gamepad replay).
-Every `zig build` builds ONE platform: `platform` is an option defaulting to
-`.tty`, so a bare `zig build` gives `pardes` (vaxis) and the other three are
-separate invocations — `-Dplatform=gui` for `pardes-gui` (SDL3),
-`-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` for
-`pardes.wasm` plus a vanilla DOM shell (any other spelling of that one fails on
-purpose rather than building something silently wrong), and `-Dplatform=macos`
-plus the `macos-app` step for `pardes.app` wrapped around a static
-`libpardes.a` on a Darwin host. Native dependencies are ghostty, vaxis, uucode (shared config),
-zstbi, SDL (a pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default
-everywhere but the web, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), and
-zig-tree-sitter + 29 grammars for 28 languages (markdown takes two: block and
-inline) — all pinned
-through `zig fetch` and wired in `build.zig`. Generated during the build:
-`highlights.scm` → an options module; the vendored helix/zed theme
-sources → the generated half of the theme ring; working-tree `.zig` sources →
-the web shell's read-only archive; eight GLSL shaders → SPIR-V. That last one is
-the only build input wanting a tool a stock machine lacks (`glslc`), so it is
-also the only one whose output is committed: `zig build shaders` refreshes each
-paired `shaders/prebuilt/*.spv` binary and `.glsl` source snapshot together.
-`-Dprebuilt-shaders` embeds that exact pair rather than shelling out, so
-`EffectCode` cannot describe different shader text from the binary on screen;
-this is also what lets a gui build need nothing but a C toolchain. The
-tutor and the embedded font are plain `@embedFile`s, not codegen. Debug builds are
-incremental for the seconds-loop; release builds are the product.
+= Style
+
+TigerStyle, plus house rules proven in the prototype: imperative and flat; one
+big `update` dispatch, not handler objects; no one-line helpers — inline the
+four-line scan; assert invariants at entry (`assert(vsum > 0)`); static
+allocation at init, arenas per frame.
+
+The rewrite used source size as a pressure toward direct code, and that remains
+useful when a refactor deletes duplicate policy or state. A checked-in line-count
+inventory does not: it goes stale whenever a pane kind, backend, or generated
+asset moves. Measure the current tree when making that comparison; keep this
+document about ownership and invariants that should survive the next edit.
+
+Two things that number is not. It is not one program's worth of growth — the
+macOS, web and firmware shells and the PDF and language work are several products
+sharing a core. And it is not licence: the rule that survives is the local one,
+that a change should leave the file it touches no longer than it found it.
= What is deliberately absent
-No render abstraction over the shells (the surface *is* the abstraction). No
-plugin system. No async runtime in the core — the shells may
-thread, the core is single-threaded by construction. No 9P yet — but the
-library boundary is exactly where acme put the file server, and a fifth shell
-could serve `Surface` and `Event` over 9P without touching the core. Half of
-that is already built and unnamed: `src/nested.zig` listens on a unix socket at
-`$XDG_RUNTIME_DIR/pardes-<pid>.sock` (`~/.local/state/pardes` without one),
-accepting exactly one verb — `Look <path>`, and nothing else, which is the
-security property — and that is how
-a `pardes <file>` run inside a pardes hands the file to the outer session
-instead of stacking a second full-screen UI inside one of its panes.
+No render abstraction over the shells: the surface *is* the abstraction. No
+plugin system, because the control filesystem is the extension point
+(@fs) and needs no API of its own. No async runtime in the core — the
+shells may thread, the core is single-threaded by construction.
+
+No 9P. The library boundary is exactly where acme put the file server, and the
+FUSE mount is already that server with a Linux transport instead of a 9P one; a
+sixth shell could serve `Surface` and `Event` over 9P without touching the core.
-"No config files" held until the startup file (`docs/config.md`) arrived, and
-that is the narrowest thing the phrase could still cover: a list of builtin
-COMMANDS run before the first frame — no schema, no new vocabulary, and no key
-remapping. The keymap is `src/config.zig`, compiled in, where a wrong binding
-is a compile error rather than a silent no-op.
+"No config files" held until the startup file (`docs/config.md`) arrived, and that
+is the narrowest thing the phrase could still cover: a list of builtin COMMANDS
+run before the first frame — no schema, no new vocabulary, and no key remapping.
+The keymap is `src/config.zig`, compiled in, where a wrong binding is a compile
+error rather than a silent no-op.
-= Appendix A: feature parity checklist
+#heading(numbering: none)[Appendix A: feature parity checklist]
-From the prototype survey; every line is covered by at least one of the
-event scripts in `test/snapshots/` — the original eighteen (boot, tty, edit,
-scroll, modal, look-file, look-dir, exec, tag, theme, tutor, windowops, dump,
-load, ttyonly, syntax, fileedit, images), and sixty-nine more added since for
+From the prototype survey; every line is covered by at least one of the event
+scripts in `test/snapshots/` — the original eighteen (boot, tty, edit, scroll,
+modal, look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load,
+ttyonly, syntax, fileedit, images), and seventy-seven more added since for
everything below that the prototype never had.
*Layout.* Columns by weight (≤6), panes by vweight (16 panes in total, not
@@ -401,6 +1991,7 @@ the source (cursor row kept visible); `Newcol` takes width only from the source
column and cannot resize any unrelated column; dying pane's weight absorbed by one
sibling; emptied column hands width to a neighbor; border-drag resize on a
pane's own trailing edge (v and h), hover shows `╎`/`╌` glyph-only hints;
+corner grab moves exactly two boundaries;
move-drag via the gutter box with preview; Alt-n new shell below, Alt-c move
pane to new column; Ctrl-w h/j/k/l directional focus; body-normal Esc hops to
the pane you were in before this one, alternating between two (the same
@@ -465,8 +2056,8 @@ reports
`img petscii:<on|off> palette:<commodore|terminal> ascii:<on|off> <path>`
before the ordinary tail (its renderer toggles are builtins under `SPC t
p/l/a`). Topbar:
-`New Newcol Find Grep Help Tutor Dump NextColor Debug Kill` — execute-only
-(left click inert);
+`New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill` —
+execute-only (left click inert);
Colors and Crt left it for their leader paths.
*Panes.* Terminal: ghostty-vt, 16 MiB scrollback, OSC 133 prompt semantics,
@@ -522,7 +2113,19 @@ parent-directory/rename-over semantics. `DumpThemes` materializes the compiled
ring under `<config>/themes/builtin/`, providing the schema and a copyable
starting point without adding inheritance or a second theme vocabulary.
Colors toggles
-all recolor passes; Debug stats overlay; Dump writes state ZON.
+all recolor passes; Debug stats overlay; Dump writes state ZON. Eleven panel
+transitions and three scene bits are settings, one builtin each, generated from
+`runtime_config.settings`.
+
+*Session.* `--detach[=name]` runs a core with no terminal; `--attach[=name]`
+makes a thin frontend over a unix socket; `Attach [name]` (`SPC s a`) hands a
+running frontend's screen to a detached core, connecting before it swaps;
+`Detach` (`SPC s D`) leaves a session that carries on. Up to 32 frontends on one
+session, all showing the same screen; the pane shells belong to the daemon and
+outlive every frontend. `--fs` serves the acme control tree over FUSE; a nested
+`pardes <file>` hands its argument to the outer session over the per-pid socket.
+`pardes --version` prints the manifest version and the commit it was configured
+from.
*Web (replay viewer parity).* Embedded dump; focus, border/move drags, wheel,
scrollbar, Colors/NextColor, and link-LOOK opening a new
@@ -547,3 +2150,11 @@ surface styles with CSS. The browser `.snap` harness drives real Chromium touch
input and reads both text and per-cell styles from the DOM renderer's packed
surface. Joystick cursor for the steamdeck is *new* scope — the prototype ships
no gamepad input.
+
+*Board (ESP32-P4).* A 56×14 grid by default (`-Desp32p4-cols`/`-rows`), 384 KiB
+of heap, no ptys, no tree-sitter, no MuPDF, no filesystem of its own, and no
+embedded source table. vaxis unmodified over a byte sink the firmware supplies;
+DEC mode 2048 for resizes. `Peek`, `Poke`, `Hexdump` (eight bytes per row) and
+`Gpio` exist only here, and `Gpio` goes through the host vtable because the pad
+sequence belongs to the firmware that already tests it against ESP-IDF's headers
+on the die.